ID |
|
|---|---|
Status |
In Progress |
Bucket |
feature |
Created |
2026-05-27 |
Updated |
2026-05-27 |
Emit assembled schema.graphqls into generated-resources/graphitron, federation-aware
Redesign (Ready → Spec, 2026-05-27)
The first implementation pass (commit 8c7e101) landed faithfully against
the spec as written but introduced a new Mojo @Parameter
outputResourcesDirectory. That contradicts the "no Mojo flag, no
per-consumer toggle, no configurable filename" line below and adds a knob
nobody needs to turn. The redesign drops the parameter while keeping the
Maven-convention layout: the file lands at
${project.build.directory}/generated-resources/graphitron/<outputPackage
as path>/schema.graphqls, derived inside the Mojo from
project.getBuild().getDirectory() (not user-configurable, hardcoded
relative segment generated-resources/graphitron). GenerateMojo
registers that directory via project.addResource(…) so the
maven-resources-plugin copies the tree into target/classes at the
process-resources phase, and the file ships at
<outputPackage as path>/schema.graphqls in the consumer’s JAR.
The path still needs to thread from the Mojo down to the emitter, so
RewriteContext.outputResourcesDirectory stays as a record component; it
just becomes a Mojo-derived field instead of a user-bound @Parameter.
Implementation commits to revert / amend in the next pass (single revert plus fresh emission commit is cleanest):
-
8c7e101; drop theoutputResourcesDirectory@Parameterdeclaration onAbstractRewriteMojoand theGenerateMojoTestfield-setter additions. Replace thePath.of(outputResourcesDirectory)resolution step with a hardcodedPath.of(project.getBuild().getDirectory()).resolve("generated-resources/graphitron")derivation inAbstractRewriteMojo.buildContext. Keep theRewriteContext.outputResourcesDirectoryfield, theSchemaSdlEmitterdispatch + path logic, and the test shape.
See Implementation sites below for the revised mechanics.
Add one step to the rewrite pipeline that prints the assembled
GraphQLSchemato aschema.graphqlsfile under${project.build.directory}/generated-resources/graphitron/<outputPackage as path>/, so the file ships on the classpath at<outputPackage as path>/schema.graphqlslike a regular Java resource, alongside the generatedGraphitron.classfacade. When the schema carries the Apollo Federation@link, route throughcom.apollographql.federation.graphqljava.printer.ServiceSDLPrinter(already a runtime dep, pinned at6.0.0); otherwise use graphql-java’sSchemaPrinterwith a directive-aware configuration. The decision is driven byBundle.federationLink(), which the pipeline already computes; no Mojo flag, no per-consumer toggle, no configurable filename.
Motivation
Downstream CI pipelines (supergraph composition, schema publication,
contract diffing) need a single resolved schema.graphqls artifact, but
graphitron-rewrite emits Java only. The plugin’s input-side *.graphqls
files are pre-assembly fragments (per-file extend type declarations,
no resolved federation @link`s, no `@asConnection-synthesised
connection types). Consumers either reconstruct the schema at runtime
from GraphitronSchema.build() or hand-maintain a published copy
alongside the generated code; neither composes cleanly into a CI step
that needs the SDL as a build output.
R248 (commit a2b1705) materially de-risks this work by fixing the
assembled programmatic schema so it round-trips through
ServiceSDLPrinter: DirectiveDefinitionEmitter now carries argument
defaults, and federation-namespace scalars (e.g. federationFieldSet)
register under their SDL name via the new ScalarResolution.Synthesised
arm rather than collapsing into Scalars.GraphQLString. The
pipeline-tier assertion at FederationBuildSmokeTest.java:125-133
(graphitron-rewrite/graphitron-sakila-example/src/test/java/no/sikt/graphitron/rewrite/test/querydb/FederationBuildSmokeTest.java)
is the working reference: it calls Graphitron.buildSchema(…) then
ServiceSDLPrinter.generateServiceSDLV2(schema) and pattern-matches the
canonical @key(fields: federationFieldSet!, …) shape plus
scalar federation__FieldSet. R247’s pipeline step models its
federation branch directly on that call site.
Design decisions
-
Print the assembled
GraphQLSchema, not the input fragments. The assembled form is what subgraph consumers actually need; it carries@asConnectionsynthesis, the resolved federation@link, and any directive rewriting. R248 demonstrated the round-trip is correct. -
Filename is fixed:
schema.graphqls. No Mojo parameter, no per-consumer override. One name, one place, predictable for tooling. -
Output location:
${project.build.directory}/generated-resources/graphitron/<outputPackage as path>/schema.graphqls. Mirrors the Maven convention for plugin-generated resources (generated-resources/<plugin-name>/), so the file ends up at e.g.com/example/myapp/schema.graphqlson the classpath after maven-resources-plugin’sprocess-resourcesphase copies it intotarget/classes/, alongside the generatedGraphitron.classfacade. A consumer that wants the schema at runtime callsgetClass().getResourceAsStream("schema.graphqls")from any class inoutputPackage; the lookup is package-local, so two graphitron consumers in the same JVM can each find their own. The relative pathgenerated-resources/graphitronis hardcoded inside the Mojo (no@Parameter); only the build directory itself comes from Maven (project.getBuild().getDirectory()), the same source the existingoutputDirectorydefault resolves against. -
Federation detection:
Bundle.federationLink(). The boolean is already computed byFederationLinkApplier.applyand threaded throughGraphitronSchemaBuilder.buildBundle(GraphitronSchemaBuilder.java:96,GraphQLRewriteGenerator.java:167). True →ServiceSDLPrinter. False →SchemaPrinter. No reflective@linkinspection, no extra Mojo flag. -
One pipeline step, not a separate Mojo. The emission belongs in
GraphQLRewriteGenerator.runPipelinenext to the existing Javawrite(…)calls. There is no use case for emitting the SDL without the Java (or vice versa); coupling them in one pipeline is simpler and matches how every other artifact is generated.
Implementation sites
The work is a small fan-out across the plugin, the context, the generator, and one new emitter class.
1. RewriteContext: add a resources-output slot
graphitron-rewrite/graphitron/src/main/java/no/sikt/graphitron/rewrite/RewriteContext.java
Add Path outputResourcesDirectory as a record component alongside the
existing outputDirectory. Objects.requireNonNull it in the canonical
constructor. The two convenience constructors at :76 and :95 (used
only by unit-tier callers) derive a sensible default from
outputDirectory (a sibling named generated-resources-graphitron is
fine: unit-tier doesn’t run through Maven, so the literal Maven layout
is moot there). The generator reads the field directly when writing the
SDL file. The shape from 8c7e101 is correct here; only the
user-configurability story changes (see site 2).
2. AbstractRewriteMojo: derive (not parameterise) the resources root
graphitron-rewrite/graphitron-maven-plugin/src/main/java/no/sikt/graphitron/rewrite/maven/AbstractRewriteMojo.java
Do not add a @Parameter for the resources directory. In
buildContext(…), compute the path inline from the Maven build
directory, with a basedir/target fallback for hand-built MavenProject
instances (test fixtures call new MavenProject() without
setBuild(…), so project.getBuild() returns null until the lifecycle
populates it):
var buildDirectory = project.getBuild() != null
? project.getBuild().getDirectory()
: null;
var targetDir = buildDirectory != null
? Path.of(buildDirectory)
: basedir.resolve("target");
var resourcesAbs = (targetDir.isAbsolute() ? targetDir : basedir.resolve(targetDir))
.resolve("generated-resources/graphitron")
.normalize();
Pass resourcesAbs into the RewriteContext constructor in the slot
that lands at outputResourcesDirectory. The relative segment
generated-resources/graphitron is a hardcoded Maven convention
(generated-resources/<plugin-name>/) and not user-tunable, so no Mojo
flag, no doc-row, no MojoDocCoverageTest drift, and the hand-built
mojos in DevMojoTest / CodegenLoaderTest / GenerateMojoTest need
no field-setter changes. The getBuild() == null fallback also keeps
8c7e101’s `GenerateMojoTest modifications from being load-bearing:
back them out as part of the rework.
3. GenerateMojo: register the resource root with Maven
graphitron-rewrite/graphitron-maven-plugin/src/main/java/no/sikt/graphitron/rewrite/maven/GenerateMojo.java:29
After project.addCompileSourceRoot(outputDirectory); add an
org.apache.maven.model.Resource with its directory set to the
absolute path of the derived generated-resources/graphitron root
(computed the same way as in buildContext, or recovered from
ctx.outputResourcesDirectory() after runGenerator returns), and
call project.addResource(resource). This is the one-line "good Java
citizen" wiring: the generated-resources tree gets copied into
target/classes at the process-resources phase, and the file ships
in the jar at <outputPackage as path>/schema.graphqls.
4. New emitter: SchemaSdlEmitter
graphitron-rewrite/graphitron/src/main/java/no/sikt/graphitron/rewrite/generators/schema/SchemaSdlEmitter.java
Single static emit(GraphQLSchema assembled, boolean federationLink,
Path resourcesRoot, String outputPackage) method (parameter name
matches RewriteContext.outputResourcesDirectory(), but inside the
emitter "resourcesRoot" reads better):
-
Compute the target path:
resourcesRoot.resolve(outputPackage.replace('.', '/')).resolve("schema.graphqls"). -
Create parent directories.
-
Render the SDL:
-
federationLink == true→ServiceSDLPrinter.generateServiceSDLV2(assembled). -
federationLink == false→ aSchemaPrinterconfigured withincludeDirectives(true),includeScalarTypes(true),includeIntrospectionTypes(false),includeSchemaDefinition(true)(mirror whatServiceSDLPrinterdoes on its end so the non-federation branch is structurally comparable). -
Write the file with
Files.writeString(…, StandardCharsets.UTF_8). -
Return the
Pathso the caller can include it inemittedThisRun(see point 5).
The class lives under generators.schema next to the other
schema-emitting generators (GraphitronSchemaClassGenerator,
ObjectTypeGenerator, …) so the package locality matches its role.
5. GraphQLRewriteGenerator.runPipeline: invoke
graphitron-rewrite/graphitron/src/main/java/no/sikt/graphitron/rewrite/GraphQLRewriteGenerator.java:163-206
After the existing write(…) calls (anywhere after assembled is in
scope), add:
emittedThisRun.add(SchemaSdlEmitter.emit(
assembled, federationLink, ctx.outputResourcesDirectory(), outputPackage));
6. Orphan sweep
GraphQLRewriteGenerator.sweepOrphans at :225-249 scans
outputDirectory for stale .java files. The schema.graphqls lives
in the resources tree, which the existing sweep doesn’t visit, so
the existing sweep won’t accidentally delete it. No change required.
A future iteration could add a parallel sweep for the resources tree,
but with only one file at one fixed path the failure mode (an old
schema.graphqls lingering if outputPackage changes) is small and
self-correcting on a clean.
Tests
Pipeline-tier
FederationBuildSmokeTest is the natural home for the federation arm:
extend it (or add a sibling SchemaSdlEmissionTest) with two cases.
-
Federation case. A fixture schema that triggers the federation
@link. Assert:-
The file exists at the expected path.
-
It contains
directive @key(fields: federation__FieldSet!, …)(the post-rename canonical shape R248 introduced). -
It contains
scalar federation__FieldSet. -
It contains the
@linkdeclaration referencingFederationSpec.URL. These are the same assertions R248’s smoke test runs against the in-memoryServiceSDLPrinteroutput; this test moves them down to the emitted file so a regression in the emitter (wrong path, wrong printer, encoding) fails the build.
-
-
Non-federation case. A fixture schema without
@link. Assert:-
The file exists at the expected path.
-
It is parseable by
graphql.schema.idl.SchemaParser. -
It does not contain
federation__FieldSetor the federation@linkURL (negative assertions guard against accidentally always routing throughServiceSDLPrinter).
-
Unit
SchemaSdlEmitterTest covering the printer-selection branch directly
(a tiny hand-built GraphQLSchema with and without the federation
@link directive, no fixture-build needed). Keeps the dispatch logic
covered without paying the pipeline-tier cost.
Sakila example smoke
graphitron-sakila-example already runs the full generator and
exercises classpath loading. Add a tiny execute-tier assertion (or fold
into an existing one) that loads
<outputPackage>/schema.graphqls via
Thread.currentThread().getContextClassLoader().getResource(…) and
asserts non-null, demonstrating the resource shipped in the jar.
Pipeline ↔ runtime parity (property test)
In graphitron-sakila-example, add a test that asserts the emitted
schema.graphqls on the classpath is byte-identical to the result of
printing the runtime-built schema through the same printer the emitter
would use for that federation arm:
@Test
void emittedSdlMatchesRuntimePrint() throws IOException {
String emitted;
try (var in = Graphitron.class.getResourceAsStream("schema.graphqls")) {
assertThat(in).as("emitted schema.graphqls on classpath").isNotNull();
emitted = new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
GraphQLSchema schema = Graphitron.buildSchema(b -> {}, fed -> {});
String runtimePrint = ServiceSDLPrinter.generateServiceSDLV2(schema);
assertThat(emitted).isEqualTo(runtimePrint);
}
The printer arm is whichever Bundle.federationLink() resolves to for
the sakila fixture (federation is in use in the sakila example today,
so ServiceSDLPrinter; if a non-federation fixture is added later the
test pairs against SchemaPrinter with the same options
SchemaSdlEmitter.printPlain uses).
This pins the strongest invariant the spec can carry: the on-classpath SDL is exactly what a consumer reconstructing the schema at runtime would print. It catches:
-
Wrong printer at emission (
SchemaPrinterwhereServiceSDLPrinterwas needed, or vice-versa) ; the two arms produce visibly different shapes for federation types. -
Non-determinism in schema build, which would make the shipped file drift from any runtime regeneration consumers run.
-
Encoding or trailing-newline drift between
Files.writeStringand the printer’s output.
Byte-equality implies the emitter and the printer’s raw output must
agree on terminating-newline behaviour. If the printer omits a final
newline and Files.writeString does not add one, the test passes
naturally; if either side ever appends one, both must, and SchemaSdlEmitter
is the right place to normalise.
Out of scope
-
Making the filename configurable (the user has explicitly ruled this out; one name, one place).
-
Emitting multiple variants (e.g. a "stripped" non-federation copy alongside the federation copy, or per-tenant subgraph splits). Future items can layer that on; today’s consumers want one file.
-
Cross-module schema diff / contract validation tooling. R98 (
multi-source-input-validation) and the broader "knowledge base programme" (R117) cover that surface; R247 just produces the input. -
Retiring the in-process assembled-schema rebuild. R10 (
drop-assembled-schema-rebuild) is the tangent; R247 builds on whatever assembled form the pipeline produces at the time it runs, and will continue to work if R10 changes the rebuild’s plumbing without changing its output shape.