From 0c220a422479122e939beed66c68d27fa7df02b4 Mon Sep 17 00:00:00 2001 From: JordonPhillips Date: Thu, 18 Jun 2026 17:54:10 +0200 Subject: [PATCH 1/3] Update codegen to support closure-based generation This updates the code generators to support generating based on shape closures. Shape closures are a new way to define a set of shapes to generate in the model without rooting that set at a service shape. This has implications that cause some chages throughout the generator. Notably, a service might not exist. There are a number of places that required a service shape to exist that had to be updated to tolerate it being missing. Notably renames may now be driven through the closure definition itself rather than a service shape, so renames needed to be passed through in a lot of generator classes. Previously types codegen was implemented by creating a synthetic service shape and/or operation and attaching the shapes to it. The generator would then skip those synthetic shapes so that no unwanted scaffolding was generated. That whole system was removed. Another important implication of shape closures is that a closure can have multiple services. For now, smithy-java is keeping the restriction to have exactly 0 or 1 services. Supporting multiple can be done down the line if there's a need. --- .../java/codegen/JavaTypesCodegenPlugin.java | 60 +++--- .../codegen/TypesDirectedJavaCodegen.java | 12 +- .../java/codegen/CodeGenerationContext.java | 49 ++--- .../smithy/java/codegen/CodegenUtils.java | 19 +- .../java/codegen/JavaCodegenSettings.java | 75 ++++++- .../java/codegen/JavaSymbolProvider.java | 38 +++- .../codegen/SyntheticServiceTransform.java | 148 ------------- .../java/codegen/TypeCodegenSettings.java | 2 - .../codegen/generators/BuilderGenerator.java | 11 +- .../generators/DeserializerGenerator.java | 13 +- .../codegen/generators/EnumGenerator.java | 8 +- .../codegen/generators/ListGenerator.java | 4 +- .../java/codegen/generators/MapGenerator.java | 4 +- .../generators/OperationGenerator.java | 7 +- .../codegen/generators/ResourceGenerator.java | 3 +- .../codegen/generators/SchemaFieldOrder.java | 1 - .../generators/SchemaIndexGenerator.java | 12 +- .../generators/SerializerMemberGenerator.java | 11 +- .../StructureDeserializerGenerator.java | 7 +- .../generators/StructureGenerator.java | 12 +- .../StructureSerializerGenerator.java | 4 +- .../codegen/generators/SyntheticTrait.java | 29 --- .../codegen/generators/UnionGenerator.java | 15 +- .../java/codegen/JavaCodegenSettingsTest.java | 53 +++++ codegen/codegen-plugin/build.gradle.kts | 11 +- .../combined/CombinedModeIntegTest.java | 52 +++++ .../java/codegen/DirectedJavaCodegen.java | 20 +- .../java/codegen/JavaCodegenPlugin.java | 200 +++++++++++++----- .../client/generators/BddFileGenerator.java | 5 +- .../ClientImplementationGenerator.java | 6 +- .../generators/ClientInterfaceGenerator.java | 17 +- .../server/generators/ServiceGenerator.java | 4 +- .../java/codegen/combined/CodegenTest.java | 87 ++++++++ .../TestCombinedModeCodegenRunner.java | 47 ++++ .../java/codegen/types/CodegenTest.java | 72 +++++++ .../java/codegen/combined/combined-it.smithy | 54 +++++ .../codegen/types/authored-closure.smithy | 33 +++ .../model-bundle-api/smithy-build.json | 2 +- .../harness/ProtocolTestExtension.java | 2 + 39 files changed, 812 insertions(+), 397 deletions(-) delete mode 100644 codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/SyntheticServiceTransform.java delete mode 100644 codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/SyntheticTrait.java create mode 100644 codegen/codegen-core/src/test/java/software/amazon/smithy/java/codegen/JavaCodegenSettingsTest.java create mode 100644 codegen/codegen-plugin/src/it/java/software/amazon/smithy/java/codegen/combined/CombinedModeIntegTest.java create mode 100644 codegen/codegen-plugin/src/test/java/software/amazon/smithy/java/codegen/combined/CodegenTest.java create mode 100644 codegen/codegen-plugin/src/test/java/software/amazon/smithy/java/codegen/combined/TestCombinedModeCodegenRunner.java create mode 100644 codegen/codegen-plugin/src/test/resources/software/amazon/smithy/java/codegen/combined/combined-it.smithy create mode 100644 codegen/codegen-plugin/src/test/resources/software/amazon/smithy/java/codegen/types/authored-closure.smithy diff --git a/codegen/codegen-core/src/internal/java/software/amazon/smithy/java/codegen/JavaTypesCodegenPlugin.java b/codegen/codegen-core/src/internal/java/software/amazon/smithy/java/codegen/JavaTypesCodegenPlugin.java index b7639f4298..68634c7f09 100644 --- a/codegen/codegen-core/src/internal/java/software/amazon/smithy/java/codegen/JavaTypesCodegenPlugin.java +++ b/codegen/codegen-core/src/internal/java/software/amazon/smithy/java/codegen/JavaTypesCodegenPlugin.java @@ -5,17 +5,14 @@ package software.amazon.smithy.java.codegen; -import java.util.HashSet; import java.util.Set; +import java.util.StringJoiner; import software.amazon.smithy.build.PluginContext; import software.amazon.smithy.build.SmithyBuildPlugin; -import software.amazon.smithy.codegen.core.CodegenException; import software.amazon.smithy.codegen.core.directed.CodegenDirector; import software.amazon.smithy.java.codegen.writer.JavaWriter; import software.amazon.smithy.java.logging.InternalLogger; -import software.amazon.smithy.model.Model; -import software.amazon.smithy.model.loader.Prelude; -import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.metadata.ShapeClosure; import software.amazon.smithy.utils.SmithyInternalApi; /** @@ -42,6 +39,10 @@ public final class JavaTypesCodegenPlugin implements SmithyBuildPlugin { private static final InternalLogger LOGGER = InternalLogger.getLogger(JavaTypesCodegenPlugin.class); + // Id of the shape closure that drives types-only generation. Namespaced so it cannot collide + // with a model shape id or a model-authored closure. + private static final String CLOSURE_ID = "software.amazon.smithy.java.codegen#types"; + @Override public String getName() { return "internal-types-only"; @@ -60,35 +61,40 @@ public void execute(PluginContext context) { runner.settings(codegenSettings); runner.directedCodegen(new TypesDirectedJavaCodegen(modes)); runner.fileManifest(context.getFileManifest()); - runner.service(codegenSettings.service()); - // Compute closure and create synthetic service - var closure = getClosure(context.getModel(), settings); - LOGGER.info("Found {} shapes in generation closure", closure.size()); - var model = SyntheticServiceTransform.transform(context.getModel(), closure, settings.renames()); - runner.model(model); + // Generate the data shapes selected by the settings as a shape closure, with no + // primary service. The director resolves the closure's transitive data shapes. + runner.shapeClosure(typesClosure(settings)); + runner.generateDataShapesOnly(); + runner.model(context.getModel()); runner.integrationClass(JavaCodegenIntegration.class); DefaultTransforms.apply(runner, codegenSettings); runner.run(); LOGGER.info("Successfully generated Java class files."); } - private static Set getClosure(Model model, TypeCodegenSettings settings) { - Set closure = new HashSet<>(); - for (var shapeId : settings.shapes()) { - closure.add(model.expectShape(shapeId)); - } - settings.selector() - .shapes(model) - .filter(s -> !s.isMemberShape()) - .filter(s -> !Prelude.isPreludeShape(s)) - .forEach(closure::add); - - if (closure.isEmpty()) { - throw new CodegenException("Could not generate types. No shapes found in closure"); + /** + * Builds the shape closure to generate from the configured selector, explicitly listed shapes, + * and renames. Explicit shapes are folded into the selector as id-equality clauses and trait + * definitions are excluded so only data shapes are generated. + */ + private static ShapeClosure typesClosure(TypeCodegenSettings settings) { + // Fold the explicit shapes into the configured selector as id-equality alternatives, + // e.g. :is(, [id='ns#A'], [id='ns#B']). + String base = settings.selector().toString(); + if (!settings.shapes().isEmpty()) { + var joiner = new StringJoiner(", ", ":is(", ")"); + joiner.add(base); + for (var shape : settings.shapes()) { + joiner.add("[id='" + shape + "']"); + } + base = joiner.toString(); } - LOGGER.info("Found {} shapes in generation closure.", closure.size()); - - return closure; + return ShapeClosure.builder() + .id(CLOSURE_ID) + // Exclude trait definitions so only data shapes are generated. + .includeBySelector(base + " :not([trait|trait])") + .rename(settings.renames()) + .build(); } } diff --git a/codegen/codegen-core/src/internal/java/software/amazon/smithy/java/codegen/TypesDirectedJavaCodegen.java b/codegen/codegen-core/src/internal/java/software/amazon/smithy/java/codegen/TypesDirectedJavaCodegen.java index 812352ce49..86da1e969d 100644 --- a/codegen/codegen-core/src/internal/java/software/amazon/smithy/java/codegen/TypesDirectedJavaCodegen.java +++ b/codegen/codegen-core/src/internal/java/software/amazon/smithy/java/codegen/TypesDirectedJavaCodegen.java @@ -30,7 +30,6 @@ import software.amazon.smithy.java.codegen.generators.SharedSerdeGenerator; import software.amazon.smithy.java.codegen.generators.StructureGenerator; import software.amazon.smithy.java.codegen.generators.UnionGenerator; -import software.amazon.smithy.model.shapes.Shape; import software.amazon.smithy.utils.SmithyInternalApi; /** @@ -53,7 +52,8 @@ public SymbolProvider createSymbolProvider( ) { return new JavaSymbolProvider( directive.model(), - directive.service(), + directive.getService().orElse(null), + directive.getRenames(), directive.settings().packageNamespace(), directive.settings().name(), modes); @@ -68,9 +68,7 @@ public CodeGenerationContext createContext( @Override public void generateStructure(GenerateStructureDirective directive) { - if (!isSynthetic(directive.shape())) { - new StructureGenerator<>().accept(directive); - } + new StructureGenerator<>().accept(directive); } @Override @@ -129,8 +127,4 @@ public void customizeBeforeIntegrations(CustomizeDirective directive) { // No-op for types-only mode } - - private static boolean isSynthetic(Shape shape) { - return shape.getId().getNamespace().equals(SyntheticServiceTransform.SYNTHETIC_NAMESPACE); - } } diff --git a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/CodeGenerationContext.java b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/CodeGenerationContext.java index a89ff9ac76..83017547e6 100644 --- a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/CodeGenerationContext.java +++ b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/CodeGenerationContext.java @@ -14,7 +14,6 @@ import java.util.stream.Collectors; import software.amazon.smithy.build.FileManifest; import software.amazon.smithy.codegen.core.CodegenContext; -import software.amazon.smithy.codegen.core.CodegenException; import software.amazon.smithy.codegen.core.SymbolProvider; import software.amazon.smithy.codegen.core.WriterDelegator; import software.amazon.smithy.codegen.core.directed.CreateContextDirective; @@ -123,7 +122,7 @@ public CodeGenerationContext( fileManifest, this.symbolProvider, new JavaWriter.Factory(settings)); - this.runtimeTraits = collectRuntimeTraits(); + this.runtimeTraits = collectRuntimeTraits(directive.getService().orElse(null)); this.traitInitializers = collectTraitInitializers(); this.plugin = plugin; this.schemaFieldOrder = new SchemaFieldOrder(directive, this); @@ -186,32 +185,30 @@ public SchemaFieldOrder schemaFieldOrder() { * * @return Set of trait ShapeId's to include in generated Schemas. */ - private Set collectRuntimeTraits() { - ServiceShape shape = model.expectShape(settings.service()) - .asServiceShape() - .orElseThrow( - () -> new CodegenException( - "Expected shapeId: " - + settings.service() + " to be a service shape.")); - + private Set collectRuntimeTraits(ServiceShape service) { // Add all default runtime traits from the prelude Set traits = new HashSet<>(PRELUDE_RUNTIME_TRAITS); - for (var entry : shape.getAllTraits().entrySet()) { - Optional traitShapeOptional = model.getShape(entry.getKey()); - if (traitShapeOptional.isEmpty()) { - LOGGER.debug("Skipping unknown trait: {}", entry.getKey()); - continue; - } - var traitShape = traitShapeOptional.get(); - // Add all traits supported by a protocol the service supports - if (traitShape.hasTrait(ProtocolDefinitionTrait.class)) { - var protocolDef = traitShape.expectTrait(ProtocolDefinitionTrait.class); - traits.addAll(protocolDef.getTraits()); - } - // Add all traits supported by auth schemes the service supports - if (traitShape.hasTrait(AuthDefinitionTrait.class)) { - var authDef = traitShape.expectTrait(AuthDefinitionTrait.class); - traits.addAll(authDef.getTraits()); + + // Protocol- and auth-scheme-supported traits are rooted in the primary service. Pure types + // mode has no service, so only the prelude and customer-configured traits apply. + if (service != null) { + for (var entry : service.getAllTraits().entrySet()) { + Optional traitShapeOptional = model.getShape(entry.getKey()); + if (traitShapeOptional.isEmpty()) { + LOGGER.debug("Skipping unknown trait: {}", entry.getKey()); + continue; + } + var traitShape = traitShapeOptional.get(); + // Add all traits supported by a protocol the service supports + if (traitShape.hasTrait(ProtocolDefinitionTrait.class)) { + var protocolDef = traitShape.expectTrait(ProtocolDefinitionTrait.class); + traits.addAll(protocolDef.getTraits()); + } + // Add all traits supported by auth schemes the service supports + if (traitShape.hasTrait(AuthDefinitionTrait.class)) { + var authDef = traitShape.expectTrait(AuthDefinitionTrait.class); + traits.addAll(authDef.getTraits()); + } } } diff --git a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/CodegenUtils.java b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/CodegenUtils.java index 45015f7317..5986376616 100644 --- a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/CodegenUtils.java +++ b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/CodegenUtils.java @@ -9,6 +9,7 @@ import java.net.URL; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Objects; import java.util.regex.Pattern; import java.util.stream.Collectors; @@ -111,10 +112,24 @@ public static Symbol fromBoxedClass(Class unboxed, Class boxed) { * Gets the default class name to use for a given Smithy {@link Shape}. * * @param shape Shape to get name for. + * @param service Service whose renames provide contextual names, or null if none. * @return Default name. */ public static String getDefaultName(Shape shape, ServiceShape service) { - String baseName = shape.getId().getName(service); + return getDefaultName(shape, service == null ? Map.of() : service.getRename()); + } + + /** + * Gets the default class name to use for a given Smithy {@link Shape}. + * + * @param shape Shape to get name for. + * @param renames Renames applied to the generated shapes (a shape id maps to its + * contextual name). Used instead of a service so the closure-driven types path + * can apply renames without a service. + * @return Default name. + */ + public static String getDefaultName(Shape shape, Map renames) { + String baseName = renames.getOrDefault(shape.getId(), shape.getId().getName()); // If the name contains any problematic delimiters, use PascalCase converter, // otherwise, just capitalize first letter to avoid messing with user-defined @@ -423,7 +438,7 @@ public static boolean isISO8601Date(String string) { * @return the property if found, or null. */ public static T tryGetServiceProperty(ShapeDirective directive, Property prop) { - var service = directive.service(); + var service = directive.getService().orElse(null); if (service != null) { var symbol = directive.symbolProvider().toSymbol(service); if (symbol != null) { diff --git a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/JavaCodegenSettings.java b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/JavaCodegenSettings.java index 8984048fca..60db615f4c 100644 --- a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/JavaCodegenSettings.java +++ b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/JavaCodegenSettings.java @@ -14,6 +14,7 @@ import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.Set; import software.amazon.smithy.codegen.core.CodegenException; import software.amazon.smithy.codegen.core.Symbol; @@ -50,6 +51,13 @@ public final class JavaCodegenSettings { private static final String RUNTIME_TRAITS = "runtimeTraits"; private static final String RUNTIME_TRAITS_SELECTOR = "runtimeTraitsSelector"; private static final String MODES = "modes"; + private static final String CLOSURE = "closure"; + + // Legacy default name for types-only generation. Before types generation was driven by a shape + // closure, it relied on a synthetic service named "TypesGenService", so the generated name + // defaulted to that when none was configured. The name is unused by types-only output, but this + // value is preserved so existing configs without a 'name' keep behaving as they did. + private static final String LEGACY_TYPES_NAME = "TypesGenService"; private static final List PROPERTIES = List.of( SERVICE, NAME, @@ -66,9 +74,11 @@ public final class JavaCodegenSettings { EDITION, RUNTIME_TRAITS, RUNTIME_TRAITS_SELECTOR, - MODES); + MODES, + CLOSURE); private final ShapeId service; + private final String closure; private final String name; private final String packageNamespace; private final String header; @@ -87,8 +97,9 @@ public final class JavaCodegenSettings { private final Map> generatedSymbols = new HashMap<>(); private JavaCodegenSettings(Builder builder) { - this.service = Objects.requireNonNull(builder.service); - this.name = StringUtils.capitalize(Objects.requireNonNullElse(builder.name, service.getName())); + this.service = builder.service; + this.closure = builder.closure; + this.name = StringUtils.capitalize(resolveName(builder.name, service)); this.packageNamespace = Objects.requireNonNull(builder.packageNamespace); this.header = getHeader(builder.headerFilePath, builder.sourceLocation); this.addNullnessAnnotations = builder.addNullnessAnnotations; @@ -105,6 +116,20 @@ private JavaCodegenSettings(Builder builder) { this.httpConfig = builder.httpConfig; } + // Resolves the generated name: an explicit name wins, otherwise the service name when a service + // is set, otherwise the legacy types-only default (with a warning nudging callers to set one). + private static String resolveName(String configuredName, ShapeId service) { + if (configuredName != null) { + return configuredName; + } + if (service != null) { + return service.getName(); + } + LOGGER.warn("No 'name' configured for types-only generation; defaulting to '{}'. Set the " + + "'name' setting explicitly to silence this warning.", LEGACY_TYPES_NAME); + return LEGACY_TYPES_NAME; + } + /** * Creates a settings object from a plugin settings node * @@ -114,7 +139,8 @@ private JavaCodegenSettings(Builder builder) { public static JavaCodegenSettings fromNode(ObjectNode settingsNode) { var builder = new Builder(); settingsNode.warnIfAdditionalProperties(PROPERTIES) - .expectStringMember(SERVICE, builder::service) + .getStringMember(SERVICE, builder::service) + .getStringMember(CLOSURE, builder::closure) .getStringMember(NAME, builder::name) .expectStringMember(NAMESPACE, builder::packageNamespace) .getStringMember(HEADER_FILE, builder::headerFilePath) @@ -135,10 +161,45 @@ public static JavaCodegenSettings fromNode(ObjectNode settingsNode) { return builder.build(); } + /** + * Gets the primary service to generate. + * + * @return the configured service id. + * @see #getService() for a non-throwing variant. + */ public ShapeId service() { + if (service == null) { + throw new CodegenException( + "No service is configured for this code generation because types-only generation " + + "has no primary service."); + } return service; } + /** + * Gets the primary service to generate, if one is configured. + * + *

Types-only generation is driven by a shape closure and has no primary service, so this + * returns an empty optional in that case. + * + * @return the configured service id, or empty if none is set. + */ + public Optional getService() { + return Optional.ofNullable(service); + } + + /** + * Gets the id of a pre-authored shape closure to generate, if one was configured. + * + *

When set, generation is driven by the {@code shapeClosures} metadata entry with this id in + * the model, rather than by an inline {@code selector}/{@code shapes} configuration. + * + * @return the configured shape closure id, or empty if none is set. + */ + public Optional getClosure() { + return Optional.ofNullable(closure); + } + public String name() { return name; } @@ -232,6 +293,7 @@ public static Builder builder() { public static final class Builder { private ShapeId service; + private String closure; private String name; private String packageNamespace; private String headerFilePath; @@ -254,6 +316,11 @@ public Builder service(String string) { return this; } + public Builder closure(String closure) { + this.closure = closure; + return this; + } + public Builder name(String name) { this.name = name; return this; diff --git a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/JavaSymbolProvider.java b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/JavaSymbolProvider.java index fafa7ef442..af1095bbb9 100644 --- a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/JavaSymbolProvider.java +++ b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/JavaSymbolProvider.java @@ -16,6 +16,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import software.amazon.smithy.codegen.core.CodegenException; import software.amazon.smithy.codegen.core.Symbol; @@ -71,6 +72,7 @@ public class JavaSymbolProvider implements ShapeVisitor, SymbolProvider private final Model model; private final ServiceShape service; + private final Map renames; private final String packageNamespace; private final String serviceName; private final Set modes; @@ -79,18 +81,38 @@ public class JavaSymbolProvider implements ShapeVisitor, SymbolProvider public JavaSymbolProvider( Model model, ServiceShape service, + Map renames, String packageNamespace, String serviceName, Set modes ) { this.model = model; this.service = service; + this.renames = renames; this.packageNamespace = packageNamespace; this.serviceName = serviceName; this.modes = modes; this.externalTypes = buildExternalTypes(); } + /** + * Backwards-compatible constructor that derives renames from the service. + * + * @deprecated Use {@link #JavaSymbolProvider(Model, ServiceShape, Map, String, String, Set)} and + * pass renames explicitly (e.g. {@code directive.getRenames()}), which also supports + * closure-driven generation. + */ + @Deprecated + public JavaSymbolProvider( + Model model, + ServiceShape service, + String packageNamespace, + String serviceName, + Set modes + ) { + this(model, service, service.getRename(), packageNamespace, serviceName, modes); + } + private static Map buildExternalTypes() { Map mappings = new HashMap<>(); SchemaIndex.getCombinedSchemaIndex().visit(schema -> { @@ -284,7 +306,7 @@ public Symbol memberShape(MemberShape memberShape) { // name conflicts with the enum class name the suffix value is added after "Type". var memberName = CodegenUtils.toMemberName(memberShape, model); var className = CaseUtils.toPascalCase(memberName) + "Type"; - var targetName = CodegenUtils.getDefaultName(container, service); + var targetName = CodegenUtils.getDefaultName(container, renames); if (targetName.equals(className)) { className = className + "Value"; } @@ -391,16 +413,28 @@ public Symbol unionShape(UnionShape unionShape) { return getJavaClassSymbol(unionShape); } + /** + * @deprecated The service shape is now optional, use {@link #serviceShape()} instead. + */ + @Deprecated protected ServiceShape service() { return service; } + protected Optional serviceShape() { + return Optional.ofNullable(service); + } + + protected Map renames() { + return renames; + } + protected String packageNamespace() { return packageNamespace; } private Symbol getJavaClassSymbol(Shape shape) { - String name = CodegenUtils.getDefaultName(shape, service); + String name = CodegenUtils.getDefaultName(shape, renames); return Symbol.builder() .name(name) .putProperty(SymbolProperties.IS_PRIMITIVE, false) diff --git a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/SyntheticServiceTransform.java b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/SyntheticServiceTransform.java deleted file mode 100644 index 3a2f0e9f7d..0000000000 --- a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/SyntheticServiceTransform.java +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.java.codegen; - -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import software.amazon.smithy.java.codegen.generators.SyntheticTrait; -import software.amazon.smithy.java.logging.InternalLogger; -import software.amazon.smithy.model.Model; -import software.amazon.smithy.model.shapes.OperationShape; -import software.amazon.smithy.model.shapes.ServiceShape; -import software.amazon.smithy.model.shapes.Shape; -import software.amazon.smithy.model.shapes.ShapeId; -import software.amazon.smithy.model.shapes.StructureShape; -import software.amazon.smithy.model.traits.ErrorTrait; -import software.amazon.smithy.model.traits.PrivateTrait; -import software.amazon.smithy.model.transform.ModelTransformer; -import software.amazon.smithy.utils.SmithyInternalApi; - -/** - * Generates a synthetic service for a set of shapes. - * - *

Adds a set of shapes to the closure of a synthetic service shape. Operation shapes are added directly - * to the service shape while all other shapes are added via a single synthetic operation with a synthetic input - * that references each type as a member. - * - *

Directed codegen requires a root service shape to use for generating types. This service shape also - * provides renames for a set of shapes as well as the list of protocols the shapes should support. This - * transform creates a synthetic service that Directed codegen can use to generate the provided set of shapes. - */ -@SmithyInternalApi -public final class SyntheticServiceTransform { - private static final InternalLogger LOGGER = InternalLogger.getLogger(SyntheticServiceTransform.class); - public static final String SYNTHETIC_NAMESPACE = "smithy.synthetic"; - static final ShapeId SYNTHETIC_SERVICE_ID = ShapeId.fromParts(SYNTHETIC_NAMESPACE, "TypesGenService"); - - /** - * Types-only mode: creates a new synthetic service wrapping closure shapes. - */ - static Model transform(Model model, Set closure, Map renames) { - - ServiceShape.Builder serviceBuilder = ServiceShape.builder() - .id(SYNTHETIC_SERVICE_ID) - .addTrait(new SyntheticTrait()); - serviceBuilder.rename(renames); - - List typesToWrap = new ArrayList<>(); - List errorShapes = new ArrayList<>(); - - for (Shape shape : closure) { - switch (shape.getType()) { - case SERVICE, RESOURCE -> LOGGER.debug( - "Skipping service-associated shape {} for type codegen...", - shape); - case OPERATION -> serviceBuilder.addOperation(shape.asOperationShape().orElseThrow()); - case STRUCTURE, ENUM, INT_ENUM, UNION -> { - typesToWrap.add(shape); - if (shape.hasTrait(ErrorTrait.class)) { - errorShapes.add(shape); - } - } - default -> { - // All other shapes are skipped with no logging as they should be - // implicitly added by aggregate shapes. - } - } - } - - Set shapesToAdd = new HashSet<>(); - if (!typesToWrap.isEmpty()) { - var syntheticShapes = createSyntheticShapes(typesToWrap, errorShapes); - shapesToAdd.addAll(syntheticShapes); - // Find the operation shape to add to the service - for (Shape s : syntheticShapes) { - if (s instanceof OperationShape op) { - serviceBuilder.addOperation(op); - } - } - } - - shapesToAdd.add(serviceBuilder.build()); - return ModelTransformer.create().replaceShapes(model, shapesToAdd); - } - - /** - * Combined mode: adds a synthetic operation to an existing service to include additional shapes - * in its closure. - */ - static Model expandServiceClosure(Model model, ShapeId serviceId, Set additionalShapes) { - if (additionalShapes.isEmpty()) { - return model; - } - - List errorShapes = new ArrayList<>(); - for (Shape shape : additionalShapes) { - if (shape.hasTrait(ErrorTrait.class)) { - errorShapes.add(shape); - } - } - - var syntheticShapes = createSyntheticShapes(new ArrayList<>(additionalShapes), errorShapes); - - var service = model.expectShape(serviceId, ServiceShape.class); - var serviceBuilder = service.toBuilder(); - Set shapesToAdd = new HashSet<>(syntheticShapes); - for (Shape s : syntheticShapes) { - if (s instanceof OperationShape op) { - serviceBuilder.addOperation(op); - } - } - shapesToAdd.add(serviceBuilder.build()); - - return ModelTransformer.create().replaceShapes(model, shapesToAdd); - } - - private static Set createSyntheticShapes(List typesToWrap, List errorShapes) { - var inputBuilder = StructureShape.builder() - .id(ShapeId.fromParts(SYNTHETIC_NAMESPACE, "TypesOperationInput")) - .addTrait(new SyntheticTrait()); - for (int i = 0; i < typesToWrap.size(); i++) { - inputBuilder.addMember("m" + i, typesToWrap.get(i).getId()); - } - var syntheticInput = inputBuilder.build(); - - var syntheticOutput = StructureShape.builder() - .id(ShapeId.fromParts(SYNTHETIC_NAMESPACE, "TypesOperationOutput")) - .addTrait(new SyntheticTrait()) - .build(); - - var opBuilder = OperationShape.builder() - .id(ShapeId.fromParts(SYNTHETIC_NAMESPACE, "TypesOperation")) - .addTrait(new SyntheticTrait()) - .addTrait(new PrivateTrait()) - .input(syntheticInput) - .output(syntheticOutput); - for (Shape error : errorShapes) { - opBuilder.addError(error.toShapeId()); - } - - return Set.of(syntheticInput, syntheticOutput, opBuilder.build()); - } -} diff --git a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/TypeCodegenSettings.java b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/TypeCodegenSettings.java index bef5f887e5..2a82792187 100644 --- a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/TypeCodegenSettings.java +++ b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/TypeCodegenSettings.java @@ -78,8 +78,6 @@ private static JavaCodegenSettings getCodegenSettings(ObjectNode node) { // Remove unused properties PROPERTIES.forEach(nodeBuilder::withoutMember); - // Add the synthetic service - nodeBuilder.withMember("service", SyntheticServiceTransform.SYNTHETIC_SERVICE_ID.toString()); return JavaCodegenSettings.fromNode(nodeBuilder.build()); } diff --git a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/BuilderGenerator.java b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/BuilderGenerator.java index 15f4a3bb2c..c724b558c6 100644 --- a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/BuilderGenerator.java +++ b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/BuilderGenerator.java @@ -6,6 +6,7 @@ package software.amazon.smithy.java.codegen.generators; import java.util.List; +import java.util.Map; import software.amazon.smithy.codegen.core.SymbolProvider; import software.amazon.smithy.java.codegen.CodegenUtils; import software.amazon.smithy.java.codegen.writer.JavaWriter; @@ -14,8 +15,8 @@ import software.amazon.smithy.java.core.schema.ShapeBuilder; import software.amazon.smithy.model.Model; import software.amazon.smithy.model.shapes.MemberShape; -import software.amazon.smithy.model.shapes.ServiceShape; import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ShapeId; import software.amazon.smithy.model.shapes.ShapeType; /** @@ -26,20 +27,20 @@ abstract class BuilderGenerator implements Runnable { protected final Shape shape; protected final SymbolProvider symbolProvider; protected final Model model; - protected final ServiceShape service; + protected final Map renames; protected BuilderGenerator( JavaWriter writer, Shape shape, SymbolProvider symbolProvider, Model model, - ServiceShape service + Map renames ) { this.writer = writer; this.shape = shape; this.symbolProvider = symbolProvider; this.model = model; - this.service = service; + this.renames = renames; } @Override @@ -111,7 +112,7 @@ protected void generateErrorCorrection(JavaWriter writer) { } protected void generateDeserialization(JavaWriter writer) { - writer.writeInline("${C|}", new StructureDeserializerGenerator(writer, shape, symbolProvider, model, service)); + writer.writeInline("${C|}", new StructureDeserializerGenerator(writer, shape, symbolProvider, model, renames)); } protected abstract void generateProperties(JavaWriter writer); diff --git a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/DeserializerGenerator.java b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/DeserializerGenerator.java index 6b6dde1693..7925728253 100644 --- a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/DeserializerGenerator.java +++ b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/DeserializerGenerator.java @@ -5,6 +5,7 @@ package software.amazon.smithy.java.codegen.generators; +import java.util.Map; import software.amazon.smithy.codegen.core.Symbol; import software.amazon.smithy.codegen.core.SymbolProvider; import software.amazon.smithy.java.codegen.CodegenUtils; @@ -27,8 +28,8 @@ import software.amazon.smithy.model.shapes.LongShape; import software.amazon.smithy.model.shapes.MapShape; import software.amazon.smithy.model.shapes.MemberShape; -import software.amazon.smithy.model.shapes.ServiceShape; import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ShapeId; import software.amazon.smithy.model.shapes.ShapeVisitor; import software.amazon.smithy.model.shapes.ShortShape; import software.amazon.smithy.model.shapes.StringShape; @@ -43,7 +44,7 @@ final class DeserializerGenerator extends ShapeVisitor.DataShapeVisitor im private final Shape shape; private final SymbolProvider symbolProvider; private final Model model; - private final ServiceShape service; + private final Map renames; private final String deserializer; private final String schemaName; @@ -52,7 +53,7 @@ final class DeserializerGenerator extends ShapeVisitor.DataShapeVisitor im Shape shape, SymbolProvider symbolProvider, Model model, - ServiceShape service, + Map renames, String deserializer, String schemaName ) { @@ -60,7 +61,7 @@ final class DeserializerGenerator extends ShapeVisitor.DataShapeVisitor im this.shape = shape; this.symbolProvider = symbolProvider; this.model = model; - this.service = service; + this.renames = renames; this.deserializer = deserializer; this.schemaName = schemaName; } @@ -94,7 +95,7 @@ public Void booleanShape(BooleanShape booleanShape) { public Void listShape(ListShape listShape) { writer.write( "SharedSerde.deserialize$L(${schemaName:L}, ${deserializer:L})", - CodegenUtils.getDefaultName(listShape, service)); + CodegenUtils.getDefaultName(listShape, renames)); return null; } @@ -102,7 +103,7 @@ public Void listShape(ListShape listShape) { public Void mapShape(MapShape mapShape) { writer.write( "SharedSerde.deserialize$L(${schemaName:L}, ${deserializer:L})", - CodegenUtils.getDefaultName(mapShape, service)); + CodegenUtils.getDefaultName(mapShape, renames)); return null; } diff --git a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/EnumGenerator.java b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/EnumGenerator.java index aeca39279f..8d1b3e7b48 100644 --- a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/EnumGenerator.java +++ b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/EnumGenerator.java @@ -27,8 +27,8 @@ import software.amazon.smithy.model.Model; import software.amazon.smithy.model.shapes.EnumShape; import software.amazon.smithy.model.shapes.IntEnumShape; -import software.amazon.smithy.model.shapes.ServiceShape; import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ShapeId; import software.amazon.smithy.utils.SmithyInternalApi; @SmithyInternalApi @@ -97,7 +97,7 @@ default void serialize(${shapeSerializer:T} serializer) { shape, directive.symbolProvider(), directive.model(), - directive.service())); + directive.getRenames())); writer.writeNullMarkedAnnotation(); writer.write(template); writer.popState(); @@ -250,9 +250,9 @@ private static final class EnumBuilderGenerator extends BuilderGenerator { Shape shape, SymbolProvider symbolProvider, Model model, - ServiceShape service + Map renames ) { - super(writer, shape, symbolProvider, model, service); + super(writer, shape, symbolProvider, model, renames); } @Override diff --git a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/ListGenerator.java b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/ListGenerator.java index 1e61958960..9eb34328b3 100644 --- a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/ListGenerator.java +++ b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/ListGenerator.java @@ -35,7 +35,7 @@ public void accept(GenerateListDirective writer.onSection("sharedSerde", t -> { - var name = CodegenUtils.getDefaultName(directive.shape(), directive.service()); + var name = CodegenUtils.getDefaultName(directive.shape(), directive.getRenames()); var target = directive.model().expectShape(directive.shape().getMember().getTarget()); var valueSchema = writer.format( "$L.listMember()", @@ -121,7 +121,7 @@ public void accept(${shape:B} state, ${shapeDeserializer:T} deserializer) { target, directive.symbolProvider(), directive.model(), - directive.service(), + directive.getRenames(), "deserializer", valueSchema)); writer.write(template); diff --git a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/MapGenerator.java b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/MapGenerator.java index 63797e49e5..35df3dbb71 100644 --- a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/MapGenerator.java +++ b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/MapGenerator.java @@ -47,7 +47,7 @@ public void accept(GenerateMapDirective getExceptionSymbols( GenerateOperationDirective directive ) { List symbols = new ArrayList<>(); - for (var errorId : operation.getErrors(directive.service())) { + for (var errorId : operation.getErrors(directive.expectService())) { var shape = directive.model().expectShape(errorId); symbols.add(directive.symbolProvider().toSymbol(shape)); } diff --git a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/ResourceGenerator.java b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/ResourceGenerator.java index cfb8c0855e..8c4bb17901 100644 --- a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/ResourceGenerator.java +++ b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/ResourceGenerator.java @@ -113,7 +113,8 @@ public final class ${shape:T} implements ${resourceType:T} { shape)); var bottomUpIndex = BottomUpIndex.of(directive.model()); - var resourceOptional = bottomUpIndex.getResourceBinding(directive.service(), shape); + var resourceOptional = + bottomUpIndex.getResourceBinding(directive.expectService(), shape); writer.putContext("hasResource", resourceOptional.isPresent()); resourceOptional.ifPresent( resourceShape -> writer.putContext("resource", diff --git a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/SchemaFieldOrder.java b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/SchemaFieldOrder.java index b4aa5d9158..43396c0fb9 100644 --- a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/SchemaFieldOrder.java +++ b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/SchemaFieldOrder.java @@ -60,7 +60,6 @@ public SchemaFieldOrder(Directive directive, CodeGenerationContext context) { var index = TopologicalIndex.of(directive.model()); var allShapes = Stream.concat(index.getOrderedShapes().stream(), index.getRecursiveShapes().stream()) .filter(connectedShapes::contains) - .filter(s -> !s.hasTrait(SyntheticTrait.class)) .filter(s -> !EXCLUDED_TYPES.contains(s.getType())) .filter(not(Prelude::isPreludeShape)) .collect(Collectors.toCollection(LinkedHashSet::new)); diff --git a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/SchemaIndexGenerator.java b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/SchemaIndexGenerator.java index 6879c5bf06..a4cf00d8f8 100644 --- a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/SchemaIndexGenerator.java +++ b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/SchemaIndexGenerator.java @@ -69,8 +69,7 @@ private void generateSchemaIndexClass( } for (var shape : directive.connectedShapes().values()) { if ((shape.getType() == ShapeType.ENUM || shape.getType() == ShapeType.INT_ENUM) - && !Prelude.isPreludeShape(shape) - && !shape.hasTrait(SyntheticTrait.class)) { + && !Prelude.isPreludeShape(shape)) { var symbol = directive.symbolProvider().toSymbol(shape); if (!symbol.getProperty(SymbolProperties.EXTERNAL_TYPE).orElse(false)) { totalCount++; @@ -138,8 +137,7 @@ public void run() { .values() .stream() .anyMatch(shape -> (shape.getType() == ShapeType.ENUM || shape.getType() == ShapeType.INT_ENUM) - && !Prelude.isPreludeShape(shape) - && !shape.hasTrait(SyntheticTrait.class)); + && !Prelude.isPreludeShape(shape)); if (hasEnums) { writer.write("_initEnums();"); } @@ -180,8 +178,7 @@ public void run() { boolean hasEnums = false; for (var shape : directive.connectedShapes().values()) { if ((shape.getType() == ShapeType.ENUM || shape.getType() == ShapeType.INT_ENUM) - && !Prelude.isPreludeShape(shape) - && !shape.hasTrait(SyntheticTrait.class)) { + && !Prelude.isPreludeShape(shape)) { hasEnums = true; break; } @@ -190,8 +187,7 @@ public void run() { writer.openBlock("private static void _initEnums() {"); for (var shape : directive.connectedShapes().values()) { if ((shape.getType() == ShapeType.ENUM || shape.getType() == ShapeType.INT_ENUM) - && !Prelude.isPreludeShape(shape) - && !shape.hasTrait(SyntheticTrait.class)) { + && !Prelude.isPreludeShape(shape)) { var symbol = directive.symbolProvider().toSymbol(shape); if (symbol.getProperty(SymbolProperties.EXTERNAL_TYPE).orElse(false)) { continue; diff --git a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/SerializerMemberGenerator.java b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/SerializerMemberGenerator.java index 47287874b5..357fba78c9 100644 --- a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/SerializerMemberGenerator.java +++ b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/SerializerMemberGenerator.java @@ -5,6 +5,7 @@ package software.amazon.smithy.java.codegen.generators; +import java.util.Map; import software.amazon.smithy.codegen.core.SymbolProvider; import software.amazon.smithy.codegen.core.directed.ContextualDirective; import software.amazon.smithy.java.codegen.CodeGenerationContext; @@ -28,8 +29,8 @@ import software.amazon.smithy.model.shapes.LongShape; import software.amazon.smithy.model.shapes.MapShape; import software.amazon.smithy.model.shapes.MemberShape; -import software.amazon.smithy.model.shapes.ServiceShape; import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ShapeId; import software.amazon.smithy.model.shapes.ShapeVisitor; import software.amazon.smithy.model.shapes.ShortShape; import software.amazon.smithy.model.shapes.StringShape; @@ -45,7 +46,7 @@ final class SerializerMemberGenerator extends ShapeVisitor.DataShapeVisitor renames; private final Shape shape; private final String state; private final ContextualDirective directive; @@ -71,7 +72,7 @@ final class SerializerMemberGenerator extends ShapeVisitor.DataShapeVisitor renames) implements Runnable { @Override public void run() { @@ -78,7 +79,7 @@ private void generateMemberSwitchCases(JavaWriter writer) { writer.write( "case $L -> builder.${memberName:L}($C);", idx, - new DeserializerGenerator(writer, member, symbolProvider, model, service, "de", "member")); + new DeserializerGenerator(writer, member, symbolProvider, model, renames, "de", "member")); writer.popState(); } } diff --git a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/StructureGenerator.java b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/StructureGenerator.java index 20efd40e46..0604b182dd 100644 --- a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/StructureGenerator.java +++ b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/StructureGenerator.java @@ -18,6 +18,7 @@ import java.util.Comparator; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.function.Consumer; import software.amazon.smithy.codegen.core.Symbol; @@ -62,8 +63,8 @@ import software.amazon.smithy.model.shapes.LongShape; import software.amazon.smithy.model.shapes.MapShape; import software.amazon.smithy.model.shapes.MemberShape; -import software.amazon.smithy.model.shapes.ServiceShape; import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ShapeId; import software.amazon.smithy.model.shapes.ShapeType; import software.amazon.smithy.model.shapes.ShapeVisitor; import software.amazon.smithy.model.shapes.ShortShape; @@ -173,8 +174,7 @@ public final class ${shape:T}${classModifiers:C} { writer, shape, directive.symbolProvider(), - directive.model(), - directive.service())); + directive.model())); writer.putContext( "builder", new StructureBuilderGenerator( @@ -182,7 +182,7 @@ public final class ${shape:T}${classModifiers:C} { shape, directive.symbolProvider(), directive.model(), - directive.service())); + directive.getRenames())); writer.putContext("getMemberValue", new GetMemberValueGenerator(writer, directive.symbolProvider(), shape)); writer.putContext("toBuilder", new ToBuilderGenerator(writer, shape, directive.symbolProvider())); writer.writeNullMarkedAnnotation(); @@ -658,9 +658,9 @@ private static final class StructureBuilderGenerator extends BuilderGenerator { Shape shape, SymbolProvider symbolProvider, Model model, - ServiceShape service + Map renames ) { - super(writer, shape, symbolProvider, model, service); + super(writer, shape, symbolProvider, model, renames); } // Required shapes marked with clientOptional should not be required to create the type. For these shapes, diff --git a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/StructureSerializerGenerator.java b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/StructureSerializerGenerator.java index 632f8ff8ff..8ca3b1bb6c 100644 --- a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/StructureSerializerGenerator.java +++ b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/StructureSerializerGenerator.java @@ -14,7 +14,6 @@ import software.amazon.smithy.java.core.schema.SerializableShape; import software.amazon.smithy.java.core.serde.ShapeSerializer; import software.amazon.smithy.model.Model; -import software.amazon.smithy.model.shapes.ServiceShape; import software.amazon.smithy.model.shapes.StructureShape; import software.amazon.smithy.model.traits.ErrorTrait; @@ -28,8 +27,7 @@ record StructureSerializerGenerator( JavaWriter writer, StructureShape shape, SymbolProvider symbolProvider, - Model model, - ServiceShape service) implements Runnable { + Model model) implements Runnable { @Override public void run() { diff --git a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/SyntheticTrait.java b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/SyntheticTrait.java deleted file mode 100644 index 01b216d8a8..0000000000 --- a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/SyntheticTrait.java +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.java.codegen.generators; - -import software.amazon.smithy.model.node.Node; -import software.amazon.smithy.model.shapes.ShapeId; -import software.amazon.smithy.model.traits.Trait; -import software.amazon.smithy.utils.SmithyInternalApi; - -@SmithyInternalApi -public final class SyntheticTrait implements Trait { - @Override - public ShapeId toShapeId() { - return null; - } - - @Override - public boolean isSynthetic() { - return true; - } - - @Override - public Node toNode() { - return null; - } -} diff --git a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/UnionGenerator.java b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/UnionGenerator.java index 2860c167d4..e6196ea4fb 100644 --- a/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/UnionGenerator.java +++ b/codegen/codegen-core/src/main/java/software/amazon/smithy/java/codegen/generators/UnionGenerator.java @@ -7,6 +7,7 @@ import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.function.Consumer; import software.amazon.smithy.codegen.core.SymbolProvider; @@ -24,8 +25,8 @@ import software.amazon.smithy.java.core.serde.ShapeSerializer; import software.amazon.smithy.model.Model; import software.amazon.smithy.model.shapes.MemberShape; -import software.amazon.smithy.model.shapes.ServiceShape; import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ShapeId; import software.amazon.smithy.model.shapes.UnionShape; import software.amazon.smithy.model.traits.UnitTypeTrait; import software.amazon.smithy.utils.SmithyInternalApi; @@ -88,8 +89,7 @@ default T getMemberValue(${sdkSchema:T} member) { writer, shape, directive.symbolProvider(), - directive.model(), - directive.service())); + directive.model())); writer.putContext( "builder", new UnionBuilderGenerator( @@ -97,7 +97,7 @@ default T getMemberValue(${sdkSchema:T} member) { shape, directive.symbolProvider(), directive.model(), - directive.service())); + directive.getRenames())); writer.writeNullMarkedAnnotation(); writer.write(template); writer.popState(); @@ -109,8 +109,7 @@ private record ValueClassGenerator( JavaWriter writer, UnionShape shape, SymbolProvider symbolProvider, - Model model, - ServiceShape service) implements Runnable { + Model model) implements Runnable { @Override public void run() { @@ -221,9 +220,9 @@ private static final class UnionBuilderGenerator extends BuilderGenerator { Shape shape, SymbolProvider symbolProvider, Model model, - ServiceShape service + Map renames ) { - super(writer, shape, symbolProvider, model, service); + super(writer, shape, symbolProvider, model, renames); } @Override diff --git a/codegen/codegen-core/src/test/java/software/amazon/smithy/java/codegen/JavaCodegenSettingsTest.java b/codegen/codegen-core/src/test/java/software/amazon/smithy/java/codegen/JavaCodegenSettingsTest.java new file mode 100644 index 0000000000..2ab6731d2f --- /dev/null +++ b/codegen/codegen-core/src/test/java/software/amazon/smithy/java/codegen/JavaCodegenSettingsTest.java @@ -0,0 +1,53 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.codegen; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +class JavaCodegenSettingsTest { + + @Test + void serviceLessWithoutNameDefaultsToLegacyName() { + // Types-only generation has no service to derive a name from. Rather than failing, it falls + // back to the legacy default (and logs a warning) so existing nameless configs keep working. + var settings = JavaCodegenSettings.builder() + .packageNamespace("com.example.standalone") + .build(); + assertThat(settings.getService()).isEmpty(); + assertThat(settings.name()).isEqualTo("TypesGenService"); + } + + @Test + void serviceLessWithExplicitNameSucceeds() { + var settings = JavaCodegenSettings.builder() + .packageNamespace("com.example.standalone") + .name("standalone") + .build(); + assertThat(settings.getService()).isEmpty(); + assertThat(settings.name()).isEqualTo("Standalone"); + } + + @Test + void withServiceDerivesNameFromService() { + var settings = JavaCodegenSettings.builder() + .packageNamespace("com.example") + .service("com.example#MyService") + .build(); + assertThat(settings.name()).isEqualTo("MyService"); + } + + @Test + void explicitNameTakesPrecedenceOverService() { + var settings = JavaCodegenSettings.builder() + .packageNamespace("com.example") + .service("com.example#MyService") + .name("override") + .build(); + assertThat(settings.name()).isEqualTo("Override"); + } +} diff --git a/codegen/codegen-plugin/build.gradle.kts b/codegen/codegen-plugin/build.gradle.kts index 6e9ae18c73..38152daabc 100644 --- a/codegen/codegen-plugin/build.gradle.kts +++ b/codegen/codegen-plugin/build.gradle.kts @@ -64,6 +64,8 @@ addGenerateSrcsTask("software.amazon.smithy.java.codegen.client.TestServerJavaCl addGenerateSrcsTask("software.amazon.smithy.java.codegen.server.TestServerJavaCodegenRunner", "Server", null) // Types codegen test runner addGenerateSrcsTask("software.amazon.smithy.java.codegen.types.TestJavaTypeCodegenRunner", "Types", null) +// Combined client + types codegen test runner +addGenerateSrcsTask("software.amazon.smithy.java.codegen.combined.TestCombinedModeCodegenRunner", "Combined", null) sourceSets { it { @@ -85,11 +87,12 @@ tasks.named("compileJmhJava") { jmh {} // Ensure generate tasks that use it source set resources depend on base generateSources -listOf("generateSourcesClient", "generateSourcesServer", "generateSourcesTypes").forEach { taskName -> - tasks.named(taskName) { - dependsOn("generateSources") +listOf("generateSourcesClient", "generateSourcesServer", "generateSourcesTypes", "generateSourcesCombined") + .forEach { taskName -> + tasks.named(taskName) { + dependsOn("generateSources") + } } -} tasks.test { failOnNoDiscoveredTests = false diff --git a/codegen/codegen-plugin/src/it/java/software/amazon/smithy/java/codegen/combined/CombinedModeIntegTest.java b/codegen/codegen-plugin/src/it/java/software/amazon/smithy/java/codegen/combined/CombinedModeIntegTest.java new file mode 100644 index 0000000000..e87e0fd224 --- /dev/null +++ b/codegen/codegen-plugin/src/it/java/software/amazon/smithy/java/codegen/combined/CombinedModeIntegTest.java @@ -0,0 +1,52 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.codegen.combined; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; +import smithy.java.codegen.combined.it.client.CombinedItServiceClient; +import smithy.java.codegen.combined.it.model.Gadget; +import smithy.java.codegen.combined.it.model.StandaloneType; +import software.amazon.smithy.java.core.serde.document.Document; + +/** + * Verifies combined {@code [client, types]} generation produces code that actually compiles and + * runs. The mere ability to import these classes proves the generated sources compiled: + *

    + *
  • {@link CombinedItServiceClient} - the service was generated as a client.
  • + *
  • {@code Gadget} - the {@code Widget} shape was renamed via the service {@code rename} + * block, so the rename threaded all the way through to the generated class name (there is + * no {@code Widget} class to import).
  • + *
  • {@link StandaloneType} - an unconnected type a plain service walk would not reach was + * still generated.
  • + *
+ */ +public class CombinedModeIntegTest { + + @Test + void renamedTypeRoundTrips() { + var gadget = Gadget.builder().name("a").build(); + var output = Gadget.builder(); + Document.of(gadget).deserializeInto(output); + assertEquals(gadget, output.build()); + } + + @Test + void standaloneTypeRoundTrips() { + var standalone = StandaloneType.builder().value("v").build(); + var output = StandaloneType.builder(); + Document.of(standalone).deserializeInto(output); + assertEquals(standalone, output.build()); + } + + @Test + void clientInterfaceIsGenerated() { + // Referencing the generated client type confirms combined mode emitted compilable client + // code alongside the data shapes. + assertEquals("CombinedItServiceClient", CombinedItServiceClient.class.getSimpleName()); + } +} diff --git a/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/DirectedJavaCodegen.java b/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/DirectedJavaCodegen.java index bf5900fd5c..68ccf7a9b4 100644 --- a/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/DirectedJavaCodegen.java +++ b/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/DirectedJavaCodegen.java @@ -39,7 +39,6 @@ import software.amazon.smithy.java.codegen.generators.UnionGenerator; import software.amazon.smithy.java.codegen.server.generators.OperationInterfaceGenerator; import software.amazon.smithy.java.codegen.server.generators.ServiceGenerator; -import software.amazon.smithy.model.shapes.Shape; import software.amazon.smithy.model.shapes.ShapeId; import software.amazon.smithy.utils.SmithyInternalApi; import software.amazon.smithy.utils.SmithyUnstableApi; @@ -66,7 +65,8 @@ public SymbolProvider createSymbolProvider( ) { return new JavaSymbolProvider( directive.model(), - directive.service(), + directive.getService().orElse(null), + directive.getRenames(), directive.settings().packageNamespace(), directive.settings().name(), modes); @@ -95,9 +95,7 @@ private String getPluginName() { @Override public void generateStructure(GenerateStructureDirective directive) { - if (!isSynthetic(directive.shape())) { - new StructureGenerator<>().accept(directive); - } + new StructureGenerator<>().accept(directive); } @Override @@ -132,9 +130,6 @@ public void generateIntEnumShape(GenerateIntEnumDirective directive) { - if (isSynthetic(directive.shape())) { - return; - } if (isTypesOnly()) { return; } @@ -146,7 +141,8 @@ public void generateOperation(GenerateOperationDirective directive) { - // In TYPES-only mode, generateService is a no-op (the synthetic service has no real service shape) + // In types-only mode there is no service to generate. The director also skips this call when + // generating data shapes only, but it can't hurt to double check. if (isTypesOnly()) { return; } @@ -164,7 +160,7 @@ public void generateService(GenerateServiceDirective().accept(directive); if (modes.contains(CodegenMode.CLIENT)) { - var service = directive.service(); + var service = directive.expectService(); if (service.hasTrait(ShapeId.from("smithy.rules#endpointBdd")) || service.hasTrait(ShapeId.from("smithy.rules#endpointRuleSet"))) { new BddFileGenerator().accept(directive); @@ -192,10 +188,6 @@ public void customizeAfterIntegrations(CustomizeDirectiveThe {@code modes} setting selects what is generated and how generation is driven: + *
    + *
  • Service mode ({@code ["client"]}, {@code ["server"]}, or both) - generation is + * driven by the {@code service}, which is required. Produces the client and/or server for + * that service and every shape in its closure.
  • + *
  • Types mode ({@code ["types"]}) - generation is driven by a shape closure built + * from the {@code selector}/{@code shapes} settings, with no {@code service}. Produces only + * data shapes (structures, unions, enums, intEnums, lists, maps). A {@code name} is used + * here only as a label and is not required.
  • + *
  • Combined mode ({@code ["types"]} alongside {@code ["client"]} and/or + * {@code ["server"]}) - generates the service (as in service mode) plus any + * standalone data shapes that are not reachable from the service. The {@code service} is + * still required and remains the primary service.
  • + *
+ * *

Configure via {@code smithy-build.json}: *

{@code
  * {
@@ -52,6 +68,15 @@
 public final class JavaCodegenPlugin implements SmithyBuildPlugin {
     private static final InternalLogger LOGGER = InternalLogger.getLogger(JavaCodegenPlugin.class);
 
+    // Id of the shape closure that drives types and combined-mode generation. Namespaced so it
+    // cannot collide with a model shape id or a model-authored closure.
+    private static final String CLOSURE_ID = "software.amazon.smithy.java.codegen#types";
+
+    // The `closure` setting references a pre-authored shape closure by id; the settings below define
+    // a closure inline. The two are mutually exclusive.
+    private static final String CLOSURE = "closure";
+    private static final List INLINE_CLOSURE_SETTINGS = List.of("selector", "shapes", "renames");
+
     @Override
     public String getName() {
         return "java-codegen";
@@ -63,6 +88,8 @@ public void execute(PluginContext context) {
         var modes = parseModes(settingsNode);
         LOGGER.info("Running java-codegen with modes: {}", modes);
 
+        validateClosureSettings(settingsNode, modes);
+
         if (modes.contains(CodegenMode.TYPES)
                 && !modes.contains(CodegenMode.CLIENT)
                 && !modes.contains(CodegenMode.SERVER)) {
@@ -72,6 +99,42 @@ public void execute(PluginContext context) {
         }
     }
 
+    // Validates the closure-related settings uniformly across all modes (types and combined), since
+    // a closure can drive either. Done against the raw settings node so the rules apply before the
+    // mode-specific settings objects are built.
+    private static void validateClosureSettings(ObjectNode settingsNode, Set modes) {
+        boolean hasClosure = settingsNode.getMember(CLOSURE).isPresent();
+
+        // A `closure` only drives the set of generated types, so it is meaningless without TYPES mode.
+        if (hasClosure && !modes.contains(CodegenMode.TYPES)) {
+            throw new CodegenException("The `closure` setting requires the `types` mode, but modes were: " + modes);
+        }
+
+        if (!hasInlineClosureSettings(settingsNode)) {
+            return;
+        }
+
+        // A pre-authored closure fully defines what to generate, so the inline settings must not also
+        // be set; otherwise nudge users that the inline definition could live in the model instead.
+        if (hasClosure) {
+            throw new CodegenException("The `closure` setting references a pre-authored shape closure and "
+                    + "cannot be combined with the inline " + INLINE_CLOSURE_SETTINGS + " setting(s).");
+        }
+        LOGGER.warn("The {} setting(s) define a shape closure inline. Consider authoring a `shapeClosures` "
+                + "entry in your model and referencing it with the `closure` setting so the closure travels "
+                + "with the model.", INLINE_CLOSURE_SETTINGS);
+    }
+
+    // True if any inline closure setting (selector/shapes/renames) is present.
+    private static boolean hasInlineClosureSettings(ObjectNode settingsNode) {
+        for (var setting : INLINE_CLOSURE_SETTINGS) {
+            if (settingsNode.getMember(setting).isPresent()) {
+                return true;
+            }
+        }
+        return false;
+    }
+
     private void setIntegrationsSettings(CodegenDirector runner, ObjectNode settingsNode) {
         settingsNode.asObjectNode()
                 .flatMap(node -> node.getObjectMember("integrations"))
@@ -91,7 +154,16 @@ private void executeServiceMode(PluginContext context, ObjectNode settingsNode,
         // TODO: use built-in once this has been upstreamed
         var model = AddFrameworkErrorsTransform.transform(ModelTransformer.create(), context.getModel());
         if (modes.contains(CodegenMode.TYPES)) {
-            model = expandServiceClosureForTypes(model, settings.service());
+            // Combined mode: the service stays the primary service (so client/server generation is
+            // unchanged) but the generated set also includes standalone types. Either drive from a
+            // pre-authored closure referenced by id (the director enforces the service is a member),
+            // or build a closure of the service plus any standalone types not reachable from it.
+            var closure = settings.getClosure();
+            if (closure.isPresent()) {
+                runner.shapeClosure(closure.get());
+            } else {
+                runner.shapeClosure(combinedClosure(model, settings.service()));
+            }
         }
         validateDependencies(modes, model, settings);
         runner.model(model);
@@ -112,13 +184,18 @@ private void executeTypesMode(PluginContext context, ObjectNode settingsNode, Se
         setIntegrationsSettings(runner, settingsNode);
         runner.directedCodegen(new DirectedJavaCodegen(modes));
         runner.fileManifest(context.getFileManifest());
-        runner.service(codegenSettings.service());
 
-        // Compute closure and create synthetic service
-        var closure = getClosure(context.getModel(), settings);
-        LOGGER.info("Found {} shapes in generation closure", closure.size());
-        var model = SyntheticServiceTransform.transform(context.getModel(), closure, settings.renames());
-        runner.model(model);
+        // Drive generation from a pre-authored closure when one is referenced by id, otherwise build
+        // the closure from the inline selector/shapes settings. Either way only data shapes are
+        // generated, and the director resolves the closure's transitive data shapes.
+        var closure = codegenSettings.getClosure();
+        if (closure.isPresent()) {
+            runner.shapeClosure(closure.get());
+        } else {
+            runner.shapeClosure(typesClosure(settings));
+        }
+        runner.generateDataShapesOnly();
+        runner.model(context.getModel());
         runner.integrationClass(JavaCodegenIntegration.class);
         DefaultTransforms.apply(runner, codegenSettings);
         runner.run();
@@ -126,34 +203,73 @@ private void executeTypesMode(PluginContext context, ObjectNode settingsNode, Se
     }
 
     /**
-     * Expands the service closure to include all model types not already reachable from the service.
-     * This is used when TYPES mode is combined with CLIENT or SERVER mode, so that standalone types
-     * outside the service closure are also generated.
+     * Builds the generation closure for combined TYPES + CLIENT/SERVER mode: the full closure of
+     * the primary service plus any standalone types not reachable from the service. The service is
+     * included by id so directed traversal pulls in its operations, I/O, and connected shapes; the
+     * extra standalone types are folded in by id. The service's renames carry over so naming is
+     * unchanged from a plain service build.
      */
-    private static Model expandServiceClosureForTypes(Model model, ShapeId serviceId) {
+    private static ShapeClosure combinedClosure(Model model, ShapeId serviceId) {
         var service = model.expectShape(serviceId, ServiceShape.class);
-        var walker = new Walker(model);
-        var serviceClosure = walker.walkShapes(service);
+        var serviceClosure = new Walker(model).walkShapes(service);
         var implicitErrorIndex = ImplicitErrorIndex.of(model);
 
-        // Default types selector: all structures, unions, enums, and intEnums
-        var selector = Selector.parse(":is(structure, union, enum, intEnum)");
-        var allTypes = selector.shapes(model)
-                .filter(s -> !s.isMemberShape())
-                .filter(s -> !Prelude.isPreludeShape(s))
-                .filter(s -> !s.hasTrait(MixinTrait.class))
-                .filter(s -> !s.hasTrait(TraitDefinition.class))
-                .filter(s -> !s.hasTrait(SyntheticTrait.class))
-                .filter(s -> !implicitErrorIndex.isImplicitError(s.getId()))
-                .filter(s -> !serviceClosure.contains(s))
-                .collect(Collectors.toSet());
-
-        if (allTypes.isEmpty()) {
-            return model;
+        // Default types selector: all structures, unions, enums, and intEnums not already reachable
+        // from the service. Mixins, trait definitions, and implicit (framework) errors are excluded
+        // so they are not emitted as standalone POJOs.
+        var standaloneTypes = new TreeSet();
+        for (var shape : Selector.parse(":is(structure, union, enum, intEnum)").select(model)) {
+            if (shape.isMemberShape()
+                    || Prelude.isPreludeShape(shape)
+                    || shape.hasTrait(MixinTrait.class)
+                    || shape.hasTrait(TraitDefinition.class)
+                    || implicitErrorIndex.isImplicitError(shape.getId())
+                    || serviceClosure.contains(shape)) {
+                continue;
+            }
+            standaloneTypes.add(shape.getId());
         }
 
-        LOGGER.info("Expanding service closure with {} additional type shapes for TYPES mode", allTypes.size());
-        return SyntheticServiceTransform.expandServiceClosure(model, serviceId, allTypes);
+        LOGGER.info("Generating service closure plus {} standalone type shapes for TYPES mode",
+                standaloneTypes.size());
+
+        // Match the service (which pulls in its full directed closure) and each standalone type by id.
+        var selector = new StringBuilder(":is([id='").append(serviceId).append("']");
+        for (var id : standaloneTypes) {
+            selector.append(", [id='").append(id).append("']");
+        }
+        selector.append(")");
+
+        return ShapeClosure.builder()
+                .id(CLOSURE_ID)
+                .includeBySelector(selector.toString())
+                .rename(service.getRename())
+                .build();
+    }
+
+    /**
+     * Builds the types-only generation closure from the configured selector, explicitly listed
+     * shapes, and renames. Explicit shapes are folded into the selector as id-equality clauses and
+     * trait definitions are excluded so only data shapes are generated.
+     */
+    private static ShapeClosure typesClosure(TypeCodegenSettings settings) {
+        // Fold the explicit shapes into the configured selector as id-equality alternatives,
+        // e.g. :is(, [id='ns#A'], [id='ns#B']).
+        String base = settings.selector().toString();
+        if (!settings.shapes().isEmpty()) {
+            var joiner = new StringJoiner(", ", ":is(", ")");
+            joiner.add(base);
+            for (var shape : settings.shapes()) {
+                joiner.add("[id='" + shape + "']");
+            }
+            base = joiner.toString();
+        }
+        return ShapeClosure.builder()
+                .id(CLOSURE_ID)
+                // Exclude trait definitions so only data shapes are generated.
+                .includeBySelector(base + " :not([trait|trait])")
+                .rename(settings.renames())
+                .build();
     }
 
     private static Set parseModes(ObjectNode settingsNode) {
@@ -221,24 +337,4 @@ private static void requireDependency(String className, String moduleName, Strin
                             + "Add 'software.amazon.smithy.java:" + moduleName + "' to your smithyBuild dependencies.");
         }
     }
-
-    private static Set getClosure(Model model, TypeCodegenSettings settings) {
-        Set closure = new HashSet<>();
-        settings.shapes()
-                .stream()
-                .map(model::expectShape)
-                .forEach(closure::add);
-        settings.selector()
-                .shapes(model)
-                .filter(s -> !s.isMemberShape())
-                .filter(s -> !Prelude.isPreludeShape(s))
-                .forEach(closure::add);
-
-        if (closure.isEmpty()) {
-            throw new CodegenException("Could not generate types. No shapes found in closure");
-        }
-        LOGGER.info("Found {} shapes in generation closure.", closure.size());
-
-        return closure;
-    }
 }
diff --git a/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/client/generators/BddFileGenerator.java b/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/client/generators/BddFileGenerator.java
index d68c3bc036..fa110f08e0 100644
--- a/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/client/generators/BddFileGenerator.java
+++ b/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/client/generators/BddFileGenerator.java
@@ -24,8 +24,9 @@ public final class BddFileGenerator
         implements Consumer> {
     @Override
     public void accept(GenerateServiceDirective directive) {
-        var serviceName = directive.service().toShapeId().getName();
-        var bytecode = compileBytecode(directive.service());
+        var service = directive.expectService();
+        var serviceName = service.toShapeId().getName();
+        var bytecode = compileBytecode(service);
         directive.fileManifest()
                 .writeFile(
                         format("./resources/META-INF/endpoints/%s.bdd", serviceName),
diff --git a/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/client/generators/ClientImplementationGenerator.java b/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/client/generators/ClientImplementationGenerator.java
index bb06cc00da..18c665ea9c 100644
--- a/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/client/generators/ClientImplementationGenerator.java
+++ b/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/client/generators/ClientImplementationGenerator.java
@@ -20,7 +20,6 @@
 import software.amazon.smithy.java.codegen.CodeGenerationContext;
 import software.amazon.smithy.java.codegen.CodegenUtils;
 import software.amazon.smithy.java.codegen.JavaCodegenSettings;
-import software.amazon.smithy.java.codegen.SyntheticServiceTransform;
 import software.amazon.smithy.java.codegen.client.sections.ClientImplAdditionalMethodsSection;
 import software.amazon.smithy.java.codegen.client.waiters.WaiterCodegenUtils;
 import software.amazon.smithy.java.codegen.generators.TypeRegistryGenerator;
@@ -92,7 +91,7 @@ final class ${impl:T} extends ${client:T} implements ${interface:T} {${?implicit
             var errorSymbols = getImplicitErrorSymbols(
                     directive.symbolProvider(),
                     directive.model(),
-                    directive.service());
+                    directive.expectService());
             writer.putContext("implicitErrors", !errorSymbols.isEmpty());
             writer.putContext(
                     "typeRegistry",
@@ -134,9 +133,6 @@ public void run() {
             writer.putContext("overrideConfig", RequestOverrideConfig.class);
             var opIndex = OperationIndex.of(model);
             for (var operation : TopDownIndex.of(model).getContainedOperations(service)) {
-                if (operation.getId().getNamespace().equals(SyntheticServiceTransform.SYNTHETIC_NAMESPACE)) {
-                    continue;
-                }
                 writer.pushState();
                 writer.putContext("name", StringUtils.uncapitalize(CodegenUtils.getDefaultName(operation, service)));
                 writer.putContext("operation", symbolProvider.toSymbol(operation));
diff --git a/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/client/generators/ClientInterfaceGenerator.java b/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/client/generators/ClientInterfaceGenerator.java
index 0782fb448e..da9b9c7611 100644
--- a/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/client/generators/ClientInterfaceGenerator.java
+++ b/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/client/generators/ClientInterfaceGenerator.java
@@ -34,7 +34,6 @@
 import software.amazon.smithy.java.codegen.CodegenUtils;
 import software.amazon.smithy.java.codegen.JavaCodegenSettings;
 import software.amazon.smithy.java.codegen.SymbolProperties;
-import software.amazon.smithy.java.codegen.SyntheticServiceTransform;
 import software.amazon.smithy.java.codegen.client.sections.ClientInterfaceAdditionalMethodsSection;
 import software.amazon.smithy.java.codegen.client.waiters.WaiterCodegenUtils;
 import software.amazon.smithy.java.codegen.integrations.core.GenericTraitInitializer;
@@ -171,12 +170,13 @@ final class RequestOverrideBuilder extends ${requestOverride:T}.OverrideBuilder<
                     writer.putContext("hasDefaultProtocol", defaultProtocolTrait != null);
                     writer.putContext("protocolFactory",
                             new FactoryGenerator(writer, getFactory(defaultProtocolTrait)));
+                    var service = directive.expectService();
                     writer.putContext(
                             "defaultProtocol",
                             new DefaultProtocolGenerator(
                                     writer,
                                     settings.service(),
-                                    directive.service().getVersion(),
+                                    service.getVersion(),
                                     defaultProtocolTrait,
                                     directive.context()));
                     writer.putContext("clientPlugin", ClientPlugin.class);
@@ -187,10 +187,10 @@ final class RequestOverrideBuilder extends ${requestOverride:T}.OverrideBuilder<
                     writer.putContext("impl", symbol.expectProperty(ClientSymbolProperties.CLIENT_IMPL));
                     writer.putContext("hasDefaultTransport", settings.transport() != null);
                     writer.putContext("hasBdd",
-                            directive.service().hasTrait(ENDPOINT_BDD_TRAIT)
-                                    || directive.service().hasTrait(ENDPOINT_RULESET_TRAIT));
+                            service.hasTrait(ENDPOINT_BDD_TRAIT)
+                                    || service.hasTrait(ENDPOINT_RULESET_TRAIT));
                     writer.putContext("loadBddInfo",
-                            new LoadBddInfoGenerator(writer, directive.service().toShapeId().getName()));
+                            new LoadBddInfoGenerator(writer, service.toShapeId().getName()));
                     var hasTransportSettings = settings.transportSettings() != null && !settings.transportSettings()
                             .isEmpty();
                     writer.putContext("hasTransportSettings", hasTransportSettings);
@@ -213,7 +213,7 @@ final class RequestOverrideBuilder extends ${requestOverride:T}.OverrideBuilder<
                                     symbol,
                                     directive.model(),
                                     directive.settings()));
-                    var defaultAuth = getAuthFactoryMapping(directive.model(), directive.service());
+                    var defaultAuth = getAuthFactoryMapping(directive.model(), service);
                     writer.putContext(
                             "defaultAuth",
                             new AuthInitializerGenerator(writer, directive.context(), defaultAuth));
@@ -224,7 +224,7 @@ final class RequestOverrideBuilder extends ${requestOverride:T}.OverrideBuilder<
                     writer.putContext("defaultPlugins", new PluginPropertyWriter(writer, defaultPlugins));
                     writer.putContext("settings", getBuilderSettings(directive.settings()));
 
-                    var serviceSymbol = directive.symbolProvider().toSymbol(directive.service());
+                    var serviceSymbol = directive.symbolProvider().toSymbol(service);
                     writer.putContext("serviceApi", serviceSymbol.expectProperty(SymbolProperties.SERVICE_API_SERVICE));
                     writer.write(template);
                     writer.popState();
@@ -370,9 +370,6 @@ public void run() {
 
             var opIndex = OperationIndex.of(model);
             for (var operation : TopDownIndex.of(model).getContainedOperations(service)) {
-                if (operation.getId().getNamespace().equals(SyntheticServiceTransform.SYNTHETIC_NAMESPACE)) {
-                    continue;
-                }
                 writer.pushState();
                 writer.putContext("name", StringUtils.uncapitalize(CodegenUtils.getDefaultName(operation, service)));
                 writer.putContext("input", symbolProvider.toSymbol(opIndex.expectInputShape(operation)));
diff --git a/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/server/generators/ServiceGenerator.java b/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/server/generators/ServiceGenerator.java
index c7f0bcf296..a281685841 100644
--- a/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/server/generators/ServiceGenerator.java
+++ b/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/server/generators/ServiceGenerator.java
@@ -17,7 +17,6 @@
 import software.amazon.smithy.java.codegen.CodeGenerationContext;
 import software.amazon.smithy.java.codegen.JavaCodegenSettings;
 import software.amazon.smithy.java.codegen.ServerSymbolProperties;
-import software.amazon.smithy.java.codegen.SyntheticServiceTransform;
 import software.amazon.smithy.java.codegen.generators.IdStringGenerator;
 import software.amazon.smithy.java.codegen.generators.SchemaFieldGenerator;
 import software.amazon.smithy.java.codegen.generators.TypeRegistryGenerator;
@@ -50,7 +49,6 @@ public void accept(
         TopDownIndex index = TopDownIndex.of(directive.model());
         List operationsInfo = index.getContainedOperations(shape)
                 .stream()
-                .filter(o -> !o.getId().getNamespace().equals(SyntheticServiceTransform.SYNTHETIC_NAMESPACE))
                 .map(o -> {
                     var inputSymbol =
                             directive.symbolProvider().toSymbol(directive.model().expectShape(o.getInputShape()));
@@ -138,7 +136,7 @@ public final class ${service:T} implements ${serviceType:T} {
                             var errorSymbols = getImplicitErrorSymbols(
                                     directive.symbolProvider(),
                                     directive.model(),
-                                    directive.service());
+                                    directive.expectService());
                             writer.putContext(
                                     "typeRegistry",
                                     new TypeRegistryGenerator(writer, errorSymbols));
diff --git a/codegen/codegen-plugin/src/test/java/software/amazon/smithy/java/codegen/combined/CodegenTest.java b/codegen/codegen-plugin/src/test/java/software/amazon/smithy/java/codegen/combined/CodegenTest.java
new file mode 100644
index 0000000000..8bfdb273cb
--- /dev/null
+++ b/codegen/codegen-plugin/src/test/java/software/amazon/smithy/java/codegen/combined/CodegenTest.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.java.codegen.combined;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.net.URL;
+import java.nio.file.Path;
+import java.util.Objects;
+import org.junit.jupiter.api.Test;
+import software.amazon.smithy.build.MockManifest;
+import software.amazon.smithy.build.PluginContext;
+import software.amazon.smithy.build.SmithyBuildPlugin;
+import software.amazon.smithy.java.codegen.JavaCodegenPlugin;
+import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.node.ArrayNode;
+import software.amazon.smithy.model.node.ObjectNode;
+
+/**
+ * Verifies combined TYPES + CLIENT generation: the primary service is generated as a client
+ * and an unconnected standalone type (which a plain service walk would not reach) is also
+ * generated as a POJO from the same run.
+ */
+public class CodegenTest {
+    private static final URL testFile =
+            Objects.requireNonNull(CodegenTest.class.getResource("combined-it.smithy"));
+    private static final Model model = Model.assembler()
+            .addImport(testFile)
+            .assemble()
+            .unwrap();
+
+    @Test
+    void generatesServiceAndStandaloneTypes() {
+        var manifest = new MockManifest();
+        SmithyBuildPlugin plugin = new JavaCodegenPlugin();
+        var settings = ObjectNode.builder()
+                .withMember("service", "smithy.java.codegen.combined.it#CombinedItService")
+                .withMember("namespace", "test.smithy.codegen")
+                .withMember("modes", ArrayNode.fromStrings("client", "types"))
+                .build();
+        var context = PluginContext.builder()
+                .fileManifest(manifest)
+                .settings(settings)
+                .model(model)
+                .build();
+
+        plugin.execute(context);
+
+        assertThat(manifest.getFiles())
+                // Client artifact for the primary service.
+                .contains(Path.of("/java/test/smithy/codegen/client/CombinedItServiceClient.java"))
+                // Type connected to the service through an operation, generated under its renamed name.
+                .contains(Path.of("/java/test/smithy/codegen/model/Gadget.java"))
+                // Standalone type not reachable from the service is still generated.
+                .contains(Path.of("/java/test/smithy/codegen/model/StandaloneType.java"));
+    }
+
+    @Test
+    void generatesFromAuthoredClosureInCombinedMode() {
+        // The model authors a `shapeClosures` entry that includes the service and the standalone type.
+        // Referencing it by id drives combined generation; the director enforces the service is a member.
+        var manifest = new MockManifest();
+        SmithyBuildPlugin plugin = new JavaCodegenPlugin();
+        var settings = ObjectNode.builder()
+                .withMember("service", "smithy.java.codegen.combined.it#CombinedItService")
+                .withMember("namespace", "test.smithy.codegen")
+                .withMember("modes", ArrayNode.fromStrings("client", "types"))
+                .withMember("closure", "smithy.java.codegen.combined.it#combinedClosure")
+                .build();
+        var context = PluginContext.builder()
+                .fileManifest(manifest)
+                .settings(settings)
+                .model(model)
+                .build();
+
+        plugin.execute(context);
+
+        assertThat(manifest.getFiles())
+                // The service still generates as a client, and the standalone type is included.
+                .contains(Path.of("/java/test/smithy/codegen/client/CombinedItServiceClient.java"))
+                .contains(Path.of("/java/test/smithy/codegen/model/Gadget.java"))
+                .contains(Path.of("/java/test/smithy/codegen/model/StandaloneType.java"));
+    }
+}
diff --git a/codegen/codegen-plugin/src/test/java/software/amazon/smithy/java/codegen/combined/TestCombinedModeCodegenRunner.java b/codegen/codegen-plugin/src/test/java/software/amazon/smithy/java/codegen/combined/TestCombinedModeCodegenRunner.java
new file mode 100644
index 0000000000..11855bfa70
--- /dev/null
+++ b/codegen/codegen-plugin/src/test/java/software/amazon/smithy/java/codegen/combined/TestCombinedModeCodegenRunner.java
@@ -0,0 +1,47 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+package software.amazon.smithy.java.codegen.combined;
+
+import java.nio.file.Paths;
+import java.util.Objects;
+import software.amazon.smithy.build.FileManifest;
+import software.amazon.smithy.build.PluginContext;
+import software.amazon.smithy.java.codegen.JavaCodegenPlugin;
+import software.amazon.smithy.model.Model;
+import software.amazon.smithy.model.node.ArrayNode;
+import software.amazon.smithy.model.node.ObjectNode;
+
+/**
+ * Executes the Java codegen plugin in combined {@code [client, types]} mode for integration tests.
+ *
+ * 

The model is loaded in isolation via {@code addImport} (rather than {@code discoverModels}) so + * the combined closure's standalone-type scan is scoped to just this model's shapes. + */ +public final class TestCombinedModeCodegenRunner { + private TestCombinedModeCodegenRunner() { + // Utility class does not have constructor + } + + public static void main(String[] args) { + JavaCodegenPlugin plugin = new JavaCodegenPlugin(); + Model model = Model.assembler(TestCombinedModeCodegenRunner.class.getClassLoader()) + .addImport(Objects.requireNonNull( + TestCombinedModeCodegenRunner.class.getResource("combined-it.smithy"))) + .assemble() + .unwrap(); + PluginContext context = PluginContext.builder() + .fileManifest(FileManifest.create(Paths.get(System.getenv("output")))) + .settings( + ObjectNode.builder() + .withMember("service", "smithy.java.codegen.combined.it#CombinedItService") + .withMember("namespace", "smithy.java.codegen.combined.it") + .withMember("modes", ArrayNode.fromStrings("client", "types")) + .build()) + .model(model) + .build(); + plugin.execute(context); + } +} diff --git a/codegen/codegen-plugin/src/test/java/software/amazon/smithy/java/codegen/types/CodegenTest.java b/codegen/codegen-plugin/src/test/java/software/amazon/smithy/java/codegen/types/CodegenTest.java index e79f0a0bc0..93782a71e1 100644 --- a/codegen/codegen-plugin/src/test/java/software/amazon/smithy/java/codegen/types/CodegenTest.java +++ b/codegen/codegen-plugin/src/test/java/software/amazon/smithy/java/codegen/types/CodegenTest.java @@ -6,6 +6,7 @@ package software.amazon.smithy.java.codegen.types; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertEquals; import java.net.URL; @@ -16,6 +17,7 @@ import software.amazon.smithy.build.MockManifest; import software.amazon.smithy.build.PluginContext; import software.amazon.smithy.build.SmithyBuildPlugin; +import software.amazon.smithy.codegen.core.CodegenException; import software.amazon.smithy.java.codegen.JavaCodegenPlugin; import software.amazon.smithy.model.Model; import software.amazon.smithy.model.node.ArrayNode; @@ -99,4 +101,74 @@ void specificShapesAdded() { Path.of("/resources/META-INF/services/software.amazon.smithy.java.core.schema.SchemaIndex")); } + @Test + void emptySelectorFailsLoudly() { + // A selector that matches no shapes (here, operations in a data-only model) must fail fast + // rather than silently generating nothing. + var settings = settingsBuilder + .withMember("selector", ":is(operation)") + .build(); + var context = contextBuilder.settings(settings).build(); + assertThatThrownBy(() -> plugin.execute(context)) + .isInstanceOf(CodegenException.class) + .hasMessageContaining("no shapes"); + } + + @Test + void appliesRenames() { + var renamed = Path.of("/java/test/smithy/codegen/types/test/model/RenamedStructure.java"); + var original = Path.of("/java/test/smithy/codegen/types/test/model/StructureShape.java"); + var settings = settingsBuilder + .withMember("selector", ":is(structure)") + .withMember("renames", + ObjectNode.builder() + .withMember("smithy.java.codegen.types.test#StructureShape", "RenamedStructure") + .build()) + .build(); + var context = contextBuilder.settings(settings).build(); + plugin.execute(context); + // The renamed shape produces the renamed class file, and the original name is gone. + assertThat(manifest.getFiles()).contains(renamed).doesNotContain(original); + assertThat(manifest.expectFileString(renamed)).contains("public final class RenamedStructure"); + } + + @Test + void generatesFromAuthoredClosure() { + // The model authors a `shapeClosures` entry that selects only StructureShape and renames it. + // Referencing it by id drives generation off that closure instead of an inline selector. + var closureModel = Model.assembler() + .addImport(Objects.requireNonNull(CodegenTest.class.getResource("authored-closure.smithy"))) + .assemble() + .unwrap(); + var settings = settingsBuilder + .withMember("closure", "smithy.java.codegen.types.test#authoredClosure") + .build(); + var context = PluginContext.builder() + .fileManifest(manifest) + .model(closureModel) + .settings(settings) + .build(); + plugin.execute(context); + + var renamed = Path.of("/java/test/smithy/codegen/types/test/model/AuthoredStructure.java"); + assertThat(manifest.getFiles()) + // Only the single closure member is generated, under its closure-defined rename. + .contains(renamed) + .doesNotContain(Path.of("/java/test/smithy/codegen/types/test/model/StructureShape.java")) + .doesNotContain(Path.of("/java/test/smithy/codegen/types/test/model/UnionShape.java")); + assertThat(manifest.expectFileString(renamed)).contains("public final class AuthoredStructure"); + } + + @Test + void closureCannotBeCombinedWithInlineSettings() { + var settings = settingsBuilder + .withMember("closure", "smithy.java.codegen.types.test#authoredClosure") + .withMember("selector", ":is(structure)") + .build(); + var context = contextBuilder.settings(settings).build(); + assertThatThrownBy(() -> plugin.execute(context)) + .isInstanceOf(CodegenException.class) + .hasMessageContaining("cannot be combined with the inline"); + } + } diff --git a/codegen/codegen-plugin/src/test/resources/software/amazon/smithy/java/codegen/combined/combined-it.smithy b/codegen/codegen-plugin/src/test/resources/software/amazon/smithy/java/codegen/combined/combined-it.smithy new file mode 100644 index 0000000000..320239bea3 --- /dev/null +++ b/codegen/codegen-plugin/src/test/resources/software/amazon/smithy/java/codegen/combined/combined-it.smithy @@ -0,0 +1,54 @@ +$version: "2" + +metadata shapeClosures = [ + { + // Includes the primary service (so it satisfies combined mode) plus the unconnected + // standalone type. Used to verify driving combined mode from a pre-authored closure. + id: "smithy.java.codegen.combined.it#combinedClosure" + includeBySelector: ":is([id = 'smithy.java.codegen.combined.it#CombinedItService'], [id = 'smithy.java.codegen.combined.it#StandaloneType'])" + rename: { + "smithy.java.codegen.combined.it#Widget": "Gadget" + } + } +] + +namespace smithy.java.codegen.combined.it + +@protocolDefinition( + traits: [timestampFormat, cors, endpoint, hostLabel, http] +) +@trait(selector: "service") +structure testProtocol {} + +/// Service used to verify combined TYPES + CLIENT generation compiles and runs: the service +/// closure (with a rename applied) plus an unconnected standalone type are generated together. +@testProtocol +service CombinedItService { + version: "today" + operations: [ + GetThing + ] + rename: { + "smithy.java.codegen.combined.it#Widget": "Gadget" + } +} + +operation GetThing { + input := { + id: String + } + output := { + widget: Widget + } +} + +/// Connected to the service through an operation and renamed to Gadget; exercises rename +/// threading end to end (the generated class must be named Gadget, not Widget). +structure Widget { + name: String +} + +/// Not reachable from the service. In combined mode this must still be generated. +structure StandaloneType { + value: String +} diff --git a/codegen/codegen-plugin/src/test/resources/software/amazon/smithy/java/codegen/types/authored-closure.smithy b/codegen/codegen-plugin/src/test/resources/software/amazon/smithy/java/codegen/types/authored-closure.smithy new file mode 100644 index 0000000000..5a388eb8bd --- /dev/null +++ b/codegen/codegen-plugin/src/test/resources/software/amazon/smithy/java/codegen/types/authored-closure.smithy @@ -0,0 +1,33 @@ +$version: "2.0" + +metadata shapeClosures = [ + { + id: "smithy.java.codegen.types.test#authoredClosure" + includeBySelector: "[id = 'smithy.java.codegen.types.test#StructureShape']" + rename: { + "smithy.java.codegen.types.test#StructureShape": "AuthoredStructure" + } + } +] + +namespace smithy.java.codegen.types.test + +structure StructureShape { + fieldA: String + fieldB: String +} + +union UnionShape { + a: String + b: Integer +} + +enum EnumShape { + A + B +} + +intEnum IntEnumShape { + A = 1 + B = 2 +} diff --git a/model-bundle/model-bundle-api/smithy-build.json b/model-bundle/model-bundle-api/smithy-build.json index 6b0627b1cf..16c388013f 100644 --- a/model-bundle/model-bundle-api/smithy-build.json +++ b/model-bundle/model-bundle-api/smithy-build.json @@ -7,4 +7,4 @@ "modes": ["types"] } } -} \ No newline at end of file +} diff --git a/protocol-test-harness/src/main/java/software/amazon/smithy/java/protocoltests/harness/ProtocolTestExtension.java b/protocol-test-harness/src/main/java/software/amazon/smithy/java/protocoltests/harness/ProtocolTestExtension.java index 1f4e2124c9..bc669243ba 100644 --- a/protocol-test-harness/src/main/java/software/amazon/smithy/java/protocoltests/harness/ProtocolTestExtension.java +++ b/protocol-test-harness/src/main/java/software/amazon/smithy/java/protocoltests/harness/ProtocolTestExtension.java @@ -126,6 +126,7 @@ public void beforeAll(ExtensionContext context) throws Exception { var symbolProvider = SymbolProvider.cache( new JavaSymbolProvider(serviceModel, service, + service.getRename(), serviceId.getNamespace(), serviceId.getName(), Set.of(CodegenMode.SERVER))); @@ -310,6 +311,7 @@ private static List getTestOperations( var symbolProvider = new JavaSymbolProvider( serviceModel, service, + service.getRename(), service.toShapeId().getNamespace(), service.toShapeId().getName(), Set.of()); From 8d7d3f3e1caffc1405dc820352c8c03886eb1f4a Mon Sep 17 00:00:00 2001 From: JordonPhillips Date: Tue, 23 Jun 2026 13:21:42 +0200 Subject: [PATCH 2/3] Skip orphan ops/resources in combined mode. --- .../java/codegen/DirectedJavaCodegen.java | 35 ++++++++++++++++++ .../java/codegen/combined/CodegenTest.java | 36 +++++++++++++++++++ .../java/codegen/combined/combined-it.smithy | 24 +++++++++++++ 3 files changed, 95 insertions(+) diff --git a/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/DirectedJavaCodegen.java b/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/DirectedJavaCodegen.java index 68ccf7a9b4..a61e0bc603 100644 --- a/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/DirectedJavaCodegen.java +++ b/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/DirectedJavaCodegen.java @@ -21,6 +21,7 @@ import software.amazon.smithy.codegen.core.directed.GenerateServiceDirective; import software.amazon.smithy.codegen.core.directed.GenerateStructureDirective; import software.amazon.smithy.codegen.core.directed.GenerateUnionDirective; +import software.amazon.smithy.codegen.core.directed.ShapeDirective; import software.amazon.smithy.java.codegen.client.generators.BddFileGenerator; import software.amazon.smithy.java.codegen.client.generators.ClientImplementationGenerator; import software.amazon.smithy.java.codegen.client.generators.ClientInterfaceGenerator; @@ -39,6 +40,7 @@ import software.amazon.smithy.java.codegen.generators.UnionGenerator; import software.amazon.smithy.java.codegen.server.generators.OperationInterfaceGenerator; import software.amazon.smithy.java.codegen.server.generators.ServiceGenerator; +import software.amazon.smithy.model.knowledge.TopDownIndex; import software.amazon.smithy.model.shapes.ShapeId; import software.amazon.smithy.utils.SmithyInternalApi; import software.amazon.smithy.utils.SmithyUnstableApi; @@ -133,6 +135,12 @@ public void generateOperation(GenerateOperationDirective directive) { + // A closure can include resources not bound to the primary service. Until multi-service + // generation is supported, skip them. + if (notBoundToPrimaryService(directive)) { + return; + } new ResourceGenerator().accept(directive); } @@ -193,4 +212,20 @@ private boolean isTypesOnly() { && !modes.contains(CodegenMode.CLIENT) && !modes.contains(CodegenMode.SERVER); } + + // True if the operation or resource being generated is not in the primary service's closure. + // In combined mode a pre-authored closure can pull in operations and resources bound to other + // services (or to none); those should be skipped until multi-service generation is supported. + // This is a no-op outside combined mode: with no primary service nothing is skipped, and in + // pure-service mode the generated closure is exactly the service's closure. + private boolean notBoundToPrimaryService(ShapeDirective directive) { + var service = directive.getService().orElse(null); + if (service == null) { + return false; + } + var index = TopDownIndex.of(directive.model()); + var shape = directive.shape(); + return !index.getContainedOperations(service).contains(shape) + && !index.getContainedResources(service).contains(shape); + } } diff --git a/codegen/codegen-plugin/src/test/java/software/amazon/smithy/java/codegen/combined/CodegenTest.java b/codegen/codegen-plugin/src/test/java/software/amazon/smithy/java/codegen/combined/CodegenTest.java index 8bfdb273cb..539857a678 100644 --- a/codegen/codegen-plugin/src/test/java/software/amazon/smithy/java/codegen/combined/CodegenTest.java +++ b/codegen/codegen-plugin/src/test/java/software/amazon/smithy/java/codegen/combined/CodegenTest.java @@ -84,4 +84,40 @@ void generatesFromAuthoredClosureInCombinedMode() { .contains(Path.of("/java/test/smithy/codegen/model/Gadget.java")) .contains(Path.of("/java/test/smithy/codegen/model/StandaloneType.java")); } + + @Test + void skipsShapesNotBoundToPrimaryService() { + // The authored closure pulls in an operation and a resource that are not bound to the + // primary service. Combined mode must skip both artifacts while still generating the + // primary service. + var manifest = new MockManifest(); + SmithyBuildPlugin plugin = new JavaCodegenPlugin(); + var settings = ObjectNode.builder() + .withMember("service", "smithy.java.codegen.combined.it#CombinedItService") + .withMember("namespace", "test.smithy.codegen") + .withMember("modes", ArrayNode.fromStrings("client", "types")) + .withMember("closure", "smithy.java.codegen.combined.it#closureWithUnboundShapes") + .build(); + var context = PluginContext.builder() + .fileManifest(manifest) + .settings(settings) + .model(model) + .build(); + + plugin.execute(context); + + assertThat(manifest.getFiles()) + // The primary service still generates. + .contains(Path.of("/java/test/smithy/codegen/client/CombinedItServiceClient.java")) + // No operation or resource artifact is generated for the shapes outside the primary + // service's closure. (OrphanOpInput below proves the operation did enter the closure, + // so the missing OrphanOp artifact reflects the skip, not an empty closure.) + .doesNotContain(Path.of("/java/test/smithy/codegen/model/OrphanOp.java")) + .doesNotContain(Path.of("/java/test/smithy/codegen/model/OrphanResource.java")) + // The unbound operation's input and output are still generated as standalone data + // shapes, which is expected: they are plain data, unlike the operation artifact + // whose service wiring would be wrong. + .contains(Path.of("/java/test/smithy/codegen/model/OrphanOpInput.java")) + .contains(Path.of("/java/test/smithy/codegen/model/OrphanOpOutput.java")); + } } diff --git a/codegen/codegen-plugin/src/test/resources/software/amazon/smithy/java/codegen/combined/combined-it.smithy b/codegen/codegen-plugin/src/test/resources/software/amazon/smithy/java/codegen/combined/combined-it.smithy index 320239bea3..0861161560 100644 --- a/codegen/codegen-plugin/src/test/resources/software/amazon/smithy/java/codegen/combined/combined-it.smithy +++ b/codegen/codegen-plugin/src/test/resources/software/amazon/smithy/java/codegen/combined/combined-it.smithy @@ -10,6 +10,13 @@ metadata shapeClosures = [ "smithy.java.codegen.combined.it#Widget": "Gadget" } } + { + // Includes the primary service plus shapes not bound to it: a standalone operation and a + // standalone resource. Used to verify that combined mode skips operations and resources + // outside the primary service's closure. + id: "smithy.java.codegen.combined.it#closureWithUnboundShapes" + includeBySelector: ":is([id = 'smithy.java.codegen.combined.it#CombinedItService'], [id = 'smithy.java.codegen.combined.it#OrphanOp'], [id = 'smithy.java.codegen.combined.it#OrphanResource'])" + } ] namespace smithy.java.codegen.combined.it @@ -52,3 +59,20 @@ structure Widget { structure StandaloneType { value: String } + +/// An operation not bound to the primary service. A closure can pull it in, but combined mode must +/// not generate it until multi-service generation is supported. +operation OrphanOp { + input := { + value: String + } + output := { + result: String + } +} + +/// A resource not bound to the primary service. A closure can pull it in, but combined mode must +/// not generate it until multi-service generation is supported. +resource OrphanResource { + identifiers: { id: String } +} From 80f0b1323dcbfc2096d45d683fff9ebaeb5c9e53 Mon Sep 17 00:00:00 2001 From: JordonPhillips Date: Wed, 24 Jun 2026 16:47:50 +0200 Subject: [PATCH 3/3] Add example for closure codegen --- examples/closure-types/README.md | 22 ++ examples/closure-types/build.gradle.kts | 55 ++++ examples/closure-types/license.txt | 4 + examples/closure-types/model/events.smithy | 120 +++++++++ examples/closure-types/model/service.smithy | 255 ++++++++++++++++++ examples/closure-types/settings.gradle.kts | 19 ++ examples/closure-types/smithy-build.json | 12 + .../closure/CombinedGenerationTest.java | 64 +++++ examples/standalone-types/README.md | 2 +- examples/standalone-types/model/person.smithy | 10 + examples/standalone-types/smithy-build.json | 3 +- settings.gradle.kts | 1 + smithy-templates.json | 8 + 13 files changed, 573 insertions(+), 2 deletions(-) create mode 100644 examples/closure-types/README.md create mode 100644 examples/closure-types/build.gradle.kts create mode 100644 examples/closure-types/license.txt create mode 100644 examples/closure-types/model/events.smithy create mode 100644 examples/closure-types/model/service.smithy create mode 100644 examples/closure-types/settings.gradle.kts create mode 100644 examples/closure-types/smithy-build.json create mode 100644 examples/closure-types/src/test/java/software/amazon/smithy/java/example/closure/CombinedGenerationTest.java diff --git a/examples/closure-types/README.md b/examples/closure-types/README.md new file mode 100644 index 0000000000..b6d37b1857 --- /dev/null +++ b/examples/closure-types/README.md @@ -0,0 +1,22 @@ +## Examples: Closure-Driven Combined Generation + +This example demonstrates driving code generation from a shape closure defined in the model rather +than from a service shape alone. It uses "combined mode": a service is generated as a server, and +the data shapes in a modeled shape closure are generated alongside it. + +The bird-watching service (`smithy.example.birds#iBird`) is generated as an RPC v2 CBOR server. The +`smithy.example.birds#fullService` closure includes the whole service namespace, so the event types +(such as `VerifiedSighting`) are generated alongside the server even though some are not reachable +from the service's operations. + +The `closure` setting in `smithy-build.json` references the `shapeClosures` entry authored in the +model, so the closure definition travels with the model rather than living in build configuration. + +### Usage + +To use this example as a template, run the following command with the +[Smithy CLI](https://smithy.io/2.0/guides/smithy-cli/index.html): + +```console +smithy init -t closure-types --url git@github.com:smithy-lang/smithy-java.git +``` diff --git a/examples/closure-types/build.gradle.kts b/examples/closure-types/build.gradle.kts new file mode 100644 index 0000000000..fe87034610 --- /dev/null +++ b/examples/closure-types/build.gradle.kts @@ -0,0 +1,55 @@ + +plugins { + `java-library` + id("software.amazon.smithy.gradle.smithy-base") +} + +dependencies { + val smithyJavaVersion: String by project + + smithyBuild("software.amazon.smithy.java:codegen-plugin:$smithyJavaVersion") + // Combined mode generates a server, so server-api must be on the codegen classpath (the plugin + // validates it) and on the runtime classpath for the generated server. + smithyBuild("software.amazon.smithy.java:server-api:$smithyJavaVersion") + api("software.amazon.smithy.java:server-api:$smithyJavaVersion") + // The RPC v2 CBOR server protocol the generated service is served with at runtime. + implementation("software.amazon.smithy.java:server-rpcv2-cbor:$smithyJavaVersion") + + testImplementation("org.hamcrest:hamcrest:3.0") + testImplementation("org.junit.jupiter:junit-jupiter:6.0.3") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testImplementation("org.assertj:assertj-core:3.27.7") +} + +// Add the generated Java sources and resources to the main source set so they compile. +afterEvaluate { + val generatedPath = smithy.getPluginProjectionPath(smithy.sourceProjection.get(), "java-codegen").get() + sourceSets { + main { + java { + srcDir("$generatedPath/java") + } + resources { + srcDir("$generatedPath/resources") + } + } + } +} + +tasks { + val smithyBuild by getting + compileJava { + dependsOn(smithyBuild) + } + processResources { + dependsOn(smithyBuild) + } + withType { + useJUnitPlatform() + } +} + +repositories { + mavenLocal() + mavenCentral() +} diff --git a/examples/closure-types/license.txt b/examples/closure-types/license.txt new file mode 100644 index 0000000000..8a5e500b4a --- /dev/null +++ b/examples/closure-types/license.txt @@ -0,0 +1,4 @@ +/* + * Example license header. + * File header line two + */ diff --git a/examples/closure-types/model/events.smithy b/examples/closure-types/model/events.smithy new file mode 100644 index 0000000000..17aff635d6 --- /dev/null +++ b/examples/closure-types/model/events.smithy @@ -0,0 +1,120 @@ +$version: "2" + +metadata shapeClosures = [ + // A closure that only includes shapes tagged as events. + { + id: "smithy.example.birds#events" + includeBySelector: "[trait|tags|(values) = event]" + } +] + +namespace smithy.example.birds + +/// Reports an unclassified potential sighting of a bird from a video stream, +/// detected by a simple computer vision application. +/// +/// These events are sent to a queue where a more robust algorithm is applied +/// to verify the sighting and classify the species. +/// +/// This is an internal event. +@internal +@tags(["event"]) +structure UnclassifiedStreamSighting { + /// The ID of the camera stream where the bird was detected. + @required + streamId: UUID + + /// The timestamp of the stream where the bird was first detected. + @required + start: Timestamp + + /// The timestamp of the stream where the bird was last detected. + @required + end: Timestamp +} + +/// Reports a sighting from a stream that has been classified. +/// +/// If confidence is high, these may be automatically added to the list +/// of verified sightings. Otherwise these are sent to a queue for +/// review. +/// +/// This is an internal event. +@internal +@tags(["event"]) +structure ClassifiedStreamSighting { + /// The ID of the camera stream where the bird was detected. + @required + streamId: UUID + + /// The timestamp of the stream where the bird was first detected. + @required + start: Timestamp + + /// The timestamp of the stream where the bird was last detected. + @required + end: Timestamp + + /// The proposed classification of the bird. + @required + classification: Classification + + /// The confidence in the classification as a percentage. + @required + @range(min: 0, max: 100) + confidence: Float +} + +/// Reports an unverified sighting submitted by a user. +/// +/// These are sent to a queue for review. +/// +/// This is an internal event +@internal +@tags(["event"]) +@references([ + { + resource: Bird + } + { + resource: Sighting + } +]) +structure UnverifiedSighting for Sighting { + @required + $birdId + + @required + $sightingId +} + +// This is a public event. +/// Reports a verified sighting of a bird. +@tags(["event"]) +@references([ + { + resource: Bird + } + { + resource: Sighting + } +]) +structure VerifiedSighting for Sighting { + @required + $birdId + + @required + classification: Classification + + @required + $sightingId + + @required + $timestamp + + @required + $location + + @required + $verified +} diff --git a/examples/closure-types/model/service.smithy b/examples/closure-types/model/service.smithy new file mode 100644 index 0000000000..dc4d6e980f --- /dev/null +++ b/examples/closure-types/model/service.smithy @@ -0,0 +1,255 @@ +$version: "2" + +metadata shapeClosures = [ + // A closure that includes every shape in the service namespace, + // including events. + { + id: "smithy.example.birds#fullService" + includeNamespaces: ["smithy.example.birds"] + } +] + +namespace smithy.example.birds + +use smithy.protocols#rpcv2Cbor + +/// A service that tracks bird sightings for research purposes. +@rpcv2Cbor +@paginated(inputToken: "nextToken", outputToken: "nextToken", pageSize: "pageSize") +service iBird { + resources: [ + Bird + ] +} + +/// A resource representing the bird itself. +/// +/// These may only be created by the service operators. +resource Bird { + identifiers: { + birdId: UUID + } + properties: { + classification: Classification + } + resources: [ + Sighting + ] + create: CreateBird + read: GetBird + list: ListBirds +} + +/// The taxonomic classification of a bird. +/// +/// Ranks above order are shared by all birds, so they are omitted. +structure Classification { + @required + order: NonEmptyString + + @required + family: NonEmptyString + + @required + genus: NonEmptyString + + @required + species: NonEmptyString + + subspecies: NonEmptyString +} + +/// Adds a bird to the database. This is for internal use only. +@internal +operation CreateBird { + input := for Bird { + @required + $classification + } +} + +/// Retrieves information about a specific bird. +@readonly +operation GetBird { + input := for Bird { + @required + $birdId + } + + output := for Bird { + @required + $birdId + + @required + $classification + } +} + +/// Lists birds present in the database. +@paginated(items: "birds") +@readonly +operation ListBirds { + input := with [PaginatedInput] {} + + output := with [PaginatedOutput] { + @required + birds: BirdList + } +} + +list BirdList { + member: BirdSummary +} + +/// A summary of a bird's properties. +structure BirdSummary for Bird { + $birdId + $classification +} + +/// A resource representing a bird sighting. +/// +/// These may be created either by user submission or by automated monitoring +/// systems. Sightings are verified before appearing in listings. +resource Sighting { + identifiers: { + birdId: UUID + sightingId: UUID + } + properties: { + timestamp: Timestamp + location: Coordinates + image: Image + verified: Boolean + } + create: CreateSighting + read: GetSighting + list: ListSightings +} + +/// Creates a sighting. +operation CreateSighting { + input := for Sighting { + @required + $birdId + + @required + $timestamp + + @required + $location + + @required + image: Image + + // For internal use only. Automated sightings from a stream may set + // this to true if their confidence is high. + @internal + verified: Boolean + } + + output := for Sighting { + @required + $sightingId + } +} + +/// Gets a sighting. +/// +/// Unverified sightings may be retrieved here, even if they don't appear in +/// listings. +@readonly +operation GetSighting { + input := for Sighting { + @required + $birdId + + @required + $sightingId + } + + output := for Sighting { + @required + $timestamp + + @required + $location + + @required + $image + + @required + $verified + } +} + +/// List verified sightings for a particular bird. +@paginated(items: "sightings") +@readonly +operation ListSightings { + input := for Bird with [PaginatedInput] { + @required + $birdId + } + + output := with [PaginatedOutput] { + @required + sightings: SightingSummaryList + } +} + +/// Geographical coordinates from where a sighting took place. +structure Coordinates { + latitude: BigDecimal + longitude: BigDecimal +} + +list SightingSummaryList { + member: SightingSummary +} + +/// A summary of a sighting's properties. +structure SightingSummary for Sighting { + @required + $birdId + + @required + $sightingId + + @required + $timestamp + + @required + $location + + @required + $verified +} + +// A mixin to share input pagination parameters. +@mixin +@private +structure PaginatedInput { + nextToken: NonEmptyString + + @range(min: 1, max: 1000) + pageSize: Integer = 100 +} + +// A mixin to share output pagination parameters. +@mixin +@private +structure PaginatedOutput { + nextToken: NonEmptyString +} + +/// A UUID-v4 string. +@pattern("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") +string UUID + +@length(min: 1) +string NonEmptyString + +/// A JPEG image. +@mediaType("image/jpeg") +blob Image diff --git a/examples/closure-types/settings.gradle.kts b/examples/closure-types/settings.gradle.kts new file mode 100644 index 0000000000..f2d2f8859e --- /dev/null +++ b/examples/closure-types/settings.gradle.kts @@ -0,0 +1,19 @@ +/** + * Combined generation of a server plus standalone types, driven by a shape closure. + */ + +pluginManagement { + val smithyGradleVersion: String by settings + + plugins { + id("software.amazon.smithy.gradle.smithy-base").version(smithyGradleVersion) + } + + repositories { + mavenLocal() + mavenCentral() + gradlePluginPortal() + } +} + +rootProject.name = "ClosureTypes" diff --git a/examples/closure-types/smithy-build.json b/examples/closure-types/smithy-build.json new file mode 100644 index 0000000000..f44222ad6e --- /dev/null +++ b/examples/closure-types/smithy-build.json @@ -0,0 +1,12 @@ +{ + "version": "1.0", + "plugins": { + "java-codegen": { + "service": "smithy.example.birds#iBird", + "namespace": "software.amazon.smithy.java.example.closure", + "headerFile": "license.txt", + "modes": ["server", "types"], + "closure": "smithy.example.birds#fullService" + } + } +} diff --git a/examples/closure-types/src/test/java/software/amazon/smithy/java/example/closure/CombinedGenerationTest.java b/examples/closure-types/src/test/java/software/amazon/smithy/java/example/closure/CombinedGenerationTest.java new file mode 100644 index 0000000000..0a72c4f0c8 --- /dev/null +++ b/examples/closure-types/src/test/java/software/amazon/smithy/java/example/closure/CombinedGenerationTest.java @@ -0,0 +1,64 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.example.closure; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Instant; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.java.example.closure.model.Classification; +import software.amazon.smithy.java.example.closure.model.Coordinates; +import software.amazon.smithy.java.example.closure.model.VerifiedSighting; +import software.amazon.smithy.java.example.closure.service.IBird; +import software.amazon.smithy.java.server.Service; + +/** + * Verifies combined-mode generation driven by the {@code fullService} shape closure: the bird + * service is generated as a server (see {@code IBird} under the {@code service} package) and the + * service closure's data shapes, including the event types, are generated alongside it. + */ +public class CombinedGenerationTest { + + @Test + void generatesServerForService() { + // The service is generated as a server Service implementation. + assertThat(Service.class).isAssignableFrom(IBird.class); + } + + @Test + void generatesServiceClosureTypes() { + // Classification is part of the service closure and generates as a normal data shape. + var classification = Classification.builder() + .order("Passeriformes") + .family("Corvidae") + .genus("Corvus") + .species("corax") + .build(); + assertThat(classification.getGenus()).isEqualTo("Corvus"); + assertThat(classification.getSpecies()).isEqualTo("corax"); + } + + @Test + void generatesEventTypesFromClosure() { + // VerifiedSighting is a public event tagged "event"; the closure pulls it into generation + // alongside the server. All of its members are required, so populate them all. + var sighting = VerifiedSighting.builder() + .birdId("bird-1") + .sightingId("sighting-1") + .timestamp(Instant.EPOCH) + .location(Coordinates.builder().build()) + .verified(true) + .classification(Classification.builder() + .order("Passeriformes") + .family("Corvidae") + .genus("Corvus") + .species("corax") + .build()) + .build(); + assertThat(sighting.getBirdId()).isEqualTo("bird-1"); + assertThat(sighting.isVerified()).isTrue(); + } +} diff --git a/examples/standalone-types/README.md b/examples/standalone-types/README.md index 7c8a59a6bf..d7476992d0 100644 --- a/examples/standalone-types/README.md +++ b/examples/standalone-types/README.md @@ -5,7 +5,7 @@ Package that generates Java types for a model without a service. To use this example as a template, run the following command with the [Smithy CLI](https://smithy.io/2.0/guides/smithy-cli/index.html): ```console -smithy init -t restjson-client --url https://github.com/smithy-lang/smithy-java +smithy init -t standalone-types --url https://github.com/smithy-lang/smithy-java ``` or diff --git a/examples/standalone-types/model/person.smithy b/examples/standalone-types/model/person.smithy index 7ee04e66a0..02eb6e9ea2 100644 --- a/examples/standalone-types/model/person.smithy +++ b/examples/standalone-types/model/person.smithy @@ -1,5 +1,15 @@ $version: "2" +metadata shapeClosures = [ + // A closure that includes every shape in the example namespace. Referencing this by id from + // smithy-build.json drives types generation from the model rather than from the plugin's + // default selector, and keeps the closure definition alongside the model. + { + id: "smithy.example#allTypes" + includeNamespaces: ["smithy.example"] + } +] + namespace smithy.example use smithy.framework#ValidationException diff --git a/examples/standalone-types/smithy-build.json b/examples/standalone-types/smithy-build.json index c28581d8e3..dbaaca8ffa 100644 --- a/examples/standalone-types/smithy-build.json +++ b/examples/standalone-types/smithy-build.json @@ -4,7 +4,8 @@ "java-codegen": { "namespace": "software.amazon.smithy.java.example.standalone", "headerFile": "license.txt", - "modes": ["types"] + "modes": ["types"], + "closure": "smithy.example#allTypes" } } } diff --git a/settings.gradle.kts b/settings.gradle.kts index ba398180bf..16b9339389 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -107,6 +107,7 @@ include(":examples:event-streaming-client") include(":examples:lambda") include(":examples:restjson-client") include(":examples:standalone-types") +include(":examples:closure-types") include(":examples:mcp-server") include(":examples:mcp-traits-example") diff --git a/smithy-templates.json b/smithy-templates.json index b1d264d003..0ccdf3cb21 100644 --- a/smithy-templates.json +++ b/smithy-templates.json @@ -57,6 +57,14 @@ ".gitignore" ] }, + "closure-types": { + "documentation": "Code generation of both standalone types and mixed service + standalone types, driven by a modeled shape closure.", + "path": "examples/closure-types", + "include": [ + "examples/gradle.properties", + ".gitignore" + ] + }, "mcp-server" : { "documentation" : "Generate or create an MCP server.", "path": "examples/mcp-server",