diff --git a/codegen/src/main/java/software/amazon/awssdk/codegen/smithy/AddSmithyExceptionShapes.java b/codegen/src/main/java/software/amazon/awssdk/codegen/smithy/AddSmithyExceptionShapes.java new file mode 100644 index 000000000000..c2fc51f31473 --- /dev/null +++ b/codegen/src/main/java/software/amazon/awssdk/codegen/smithy/AddSmithyExceptionShapes.java @@ -0,0 +1,112 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.codegen.smithy; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import software.amazon.awssdk.codegen.IntermediateModelShapeProcessor; +import software.amazon.awssdk.codegen.internal.TypeUtils; +import software.amazon.awssdk.codegen.model.config.customization.CustomizationConfig; +import software.amazon.awssdk.codegen.model.intermediate.OperationModel; +import software.amazon.awssdk.codegen.model.intermediate.ShapeModel; +import software.amazon.awssdk.codegen.model.intermediate.ShapeType; +import software.amazon.awssdk.codegen.naming.NamingStrategy; +import software.amazon.smithy.aws.traits.protocols.AwsQueryErrorTrait; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.knowledge.HttpBindingIndex; +import software.amazon.smithy.model.knowledge.OperationIndex; +import software.amazon.smithy.model.knowledge.TopDownIndex; +import software.amazon.smithy.model.shapes.OperationShape; +import software.amazon.smithy.model.shapes.ServiceShape; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.shapes.StructureShape; +import software.amazon.smithy.model.traits.HttpErrorTrait; + +/** + * Builds an exception {@link ShapeModel} for every error shape referenced by a reachable + * operation. An error shared by several operations is translated once. + */ +final class AddSmithyExceptionShapes extends AddSmithyShapes implements IntermediateModelShapeProcessor { + + AddSmithyExceptionShapes(Model model, + ServiceShape service, + NamingStrategy namingStrategy, + CustomizationConfig customConfig, + String protocol, + TypeUtils typeUtils) { + super(model, service, namingStrategy, customConfig, protocol, typeUtils); + } + + @Override + public Map process(Map currentOperations, + Map currentShapes) { + Map shapes = new HashMap<>(); + Model model = getModel(); + NamingStrategy naming = getNamingStrategy(); + HttpBindingIndex bindingIndex = HttpBindingIndex.of(model); + TopDownIndex topDown = TopDownIndex.of(model); + OperationIndex operationIndex = OperationIndex.of(model); + + for (OperationShape op : topDown.getContainedOperations(getService())) { + // Merged (service + operation) errors, matching the source AddSmithyOperations uses for + // OperationModel.exceptions. Reading op.getErrors() would skip service-level errors and + // leave the operation referencing an exception class with no shape. + for (StructureShape errorShape : operationIndex.getErrors(getService(), op)) { + ShapeId errorId = errorShape.getId(); + String javaClassName = naming.getExceptionName(errorId.getName()); + if (shapes.containsKey(javaClassName) || currentShapes.containsKey(javaClassName)) { + continue; + } + + ShapeModel shapeModel = generateShapeModel(javaClassName, errorShape, + httpBindingsHonored() + ? bindingIndex.getResponseBindings(errorId) + : Collections.emptyMap()); + shapeModel.setType(ShapeType.Exception.getValue()); + shapeModel.setErrorCode(resolveErrorCode(errorShape)); + errorShape.getTrait(HttpErrorTrait.class) + .ifPresent(t -> shapeModel.setHttpStatusCode(t.getCode())); + + shapes.put(javaClassName, shapeModel); + } + } + + return shapes; + } + + /** + * The wire error code: {@link AwsQueryErrorTrait} where the protocol allows an override, + * otherwise the error shape's name. + */ + private String resolveErrorCode(StructureShape errorShape) { + if (protocolSupportsErrorCodeOverride()) { + String override = errorShape.getTrait(AwsQueryErrorTrait.class) + .map(AwsQueryErrorTrait::getCode) + .orElse(null); + if (override != null && !override.isEmpty()) { + return override; + } + } + return errorShape.getId().getName(); + } + + private boolean protocolSupportsErrorCodeOverride() { + String protocol = getProtocol(); + // awsJson and rpcv2Cbor always use the shape name as the code. + return !"json".equals(protocol) && !"smithy-rpc-v2-cbor".equals(protocol); + } +} diff --git a/codegen/src/main/java/software/amazon/awssdk/codegen/smithy/AddSmithyInputShapes.java b/codegen/src/main/java/software/amazon/awssdk/codegen/smithy/AddSmithyInputShapes.java new file mode 100644 index 000000000000..de8ff812e0cd --- /dev/null +++ b/codegen/src/main/java/software/amazon/awssdk/codegen/smithy/AddSmithyInputShapes.java @@ -0,0 +1,197 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.codegen.smithy; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import software.amazon.awssdk.codegen.IntermediateModelShapeProcessor; +import software.amazon.awssdk.codegen.internal.TypeUtils; +import software.amazon.awssdk.codegen.model.config.customization.CustomizationConfig; +import software.amazon.awssdk.codegen.model.intermediate.EndpointDiscovery; +import software.amazon.awssdk.codegen.model.intermediate.Metadata; +import software.amazon.awssdk.codegen.model.intermediate.OperationModel; +import software.amazon.awssdk.codegen.model.intermediate.Protocol; +import software.amazon.awssdk.codegen.model.intermediate.ShapeMarshaller; +import software.amazon.awssdk.codegen.model.intermediate.ShapeModel; +import software.amazon.awssdk.codegen.model.intermediate.ShapeType; +import software.amazon.awssdk.codegen.model.intermediate.VariableModel; +import software.amazon.awssdk.codegen.naming.NamingStrategy; +import software.amazon.awssdk.utils.StringUtils; +import software.amazon.smithy.aws.traits.clientendpointdiscovery.ClientEndpointDiscoveryIndex; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.knowledge.HttpBinding; +import software.amazon.smithy.model.knowledge.HttpBindingIndex; +import software.amazon.smithy.model.knowledge.TopDownIndex; +import software.amazon.smithy.model.shapes.OperationShape; +import software.amazon.smithy.model.shapes.ServiceShape; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.shapes.StructureShape; +import software.amazon.smithy.model.traits.HttpTrait; +import software.amazon.smithy.model.traits.XmlNameTrait; +import software.amazon.smithy.model.traits.XmlNamespaceTrait; + +/** + * Builds the request {@link ShapeModel} and its {@link ShapeMarshaller} for each operation + * reachable from the service. A {@code smithy.api#Unit} input yields a synthesized empty request. + */ +final class AddSmithyInputShapes extends AddSmithyShapes implements IntermediateModelShapeProcessor { + + private static final ShapeId UNIT = ShapeId.from("smithy.api#Unit"); + + AddSmithyInputShapes(Model model, + ServiceShape service, + NamingStrategy namingStrategy, + CustomizationConfig customConfig, + String protocol, + TypeUtils typeUtils) { + super(model, service, namingStrategy, customConfig, protocol, typeUtils); + } + + @Override + public Map process(Map currentOperations, + Map currentShapes) { + Map shapes = new HashMap<>(); + Model model = getModel(); + NamingStrategy naming = getNamingStrategy(); + HttpBindingIndex bindingIndex = HttpBindingIndex.of(model); + TopDownIndex topDown = TopDownIndex.of(model); + + for (OperationShape op : topDown.getContainedOperations(getService())) { + String opName = op.toShapeId().getName(); + String javaClassName = naming.getRequestClassName(opName); + + ShapeId inputId = op.getInputShape(); + ShapeModel shapeModel; + if (UNIT.equals(inputId)) { + shapeModel = synthesizeEmptyRequest(op, javaClassName); + } else { + StructureShape inputShape = model.expectShape(inputId, StructureShape.class); + Map requestBindings = + httpBindingsHonored() ? bindingIndex.getRequestBindings(op.getId()) + : Collections.emptyMap(); + shapeModel = generateShapeModel(javaClassName, inputShape, requestBindings); + shapeModel.setEndpointDiscovery(endpointDiscovery(op)); + } + shapeModel.setType(ShapeType.Request.getValue()); + shapeModel.setMarshaller(buildMarshaller(op, bindingIndex, /* synthetic */ UNIT.equals(inputId))); + + shapes.put(javaClassName, shapeModel); + } + + return shapes; + } + + private EndpointDiscovery endpointDiscovery(OperationShape op) { + return ClientEndpointDiscoveryIndex.of(getModel()) + .getEndpointDiscoveryInfo(getService(), op) + .map(info -> { + EndpointDiscovery discovery = new EndpointDiscovery(); + discovery.setRequired(info.isRequired()); + return discovery; + }) + .orElse(null); + } + + private ShapeModel synthesizeEmptyRequest(OperationShape op, String javaClassName) { + ShapeModel shape = new ShapeModel(javaClassName); + shape.setShapeName(javaClassName); + shape.setVariable(new VariableModel(getNamingStrategy().getVariableName(javaClassName), + javaClassName)); + return shape; + } + + private ShapeMarshaller buildMarshaller(OperationShape op, HttpBindingIndex bindingIndex, boolean synthetic) { + String protocol = getProtocol(); + ShapeMarshaller marshaller = new ShapeMarshaller() + .withAction(op.toShapeId().getName()) + .withProtocol(protocol); + + // RPC protocols ignore any @http trait and always POST to "/". For smithy-rpc-v2-cbor the + // "/service/{id}/operation/{op}" URI is applied later by the deferred rpcv2Cbor processor. + if (httpBindingsHonored() && op.hasTrait(HttpTrait.class)) { + HttpTrait http = op.expectTrait(HttpTrait.class); + marshaller.withVerb(http.getMethod()); + marshaller.withRequestUri(http.getUri().toString()); + } else { + marshaller.withVerb("POST"); + marshaller.withRequestUri("/"); + } + + // Populated only for protocols that identify the operation by name. + if (Metadata.usesOperationIdentifier(protocol)) { + String targetPrefix = usesTargetPrefix(protocol) ? getService().getId().getName() : null; + marshaller.withTarget(StringUtils.isEmpty(targetPrefix) + ? op.toShapeId().getName() + : targetPrefix + "." + op.toShapeId().getName()); + } + + if (!UNIT.equals(op.getInputShape())) { + StructureShape input = getModel().expectShape(op.getInputShape(), StructureShape.class); + // C2J sources both from the operation's input reference. The converter drops the + // locationName (it equals the request shape name) and hoists the namespace to the + // service, so both are reconstructed here. rest-xml only: ec2Query also carries a + // service @xmlNamespace, but C2J does not propagate it to the marshaller. + if (Protocol.REST_XML.getValue().equals(protocol) && hasDocumentBody(bindingIndex, op)) { + marshaller.withLocationName(xmlRootElementName(input)); + marshaller.withXmlNameSpaceUri(xmlNamespaceUri(input)); + } else { + input.getTrait(XmlNamespaceTrait.class) + .ifPresent(ns -> marshaller.withXmlNameSpaceUri(ns.getUri())); + } + } + + marshaller.withIsSynthetic(synthetic); + return marshaller; + } + + /** + * True when at least one member is serialized in the body. C2J authors + * {@code input.locationName} / {@code input.xmlNamespace} only on body-bearing operations. + */ + private static boolean hasDocumentBody(HttpBindingIndex bindingIndex, OperationShape op) { + return bindingIndex.getRequestBindings(op.getId()).values().stream() + .anyMatch(b -> b.getLocation() == HttpBinding.Location.DOCUMENT); + } + + /** + * XML root element name: the {@code @xmlName} override when present, otherwise the shape name. + */ + private static String xmlRootElementName(StructureShape input) { + return input.getTrait(XmlNameTrait.class) + .map(XmlNameTrait::getValue) + .orElse(input.getId().getName()); + } + + /** + * The input shape's own {@code @xmlNamespace}, falling back to the service-level one. + */ + private String xmlNamespaceUri(StructureShape input) { + return input.getTrait(XmlNamespaceTrait.class) + .map(XmlNamespaceTrait::getUri) + .orElseGet(() -> getService().getTrait(XmlNamespaceTrait.class) + .map(XmlNamespaceTrait::getUri) + .orElse(null)); + } + + /** + * The awsJson family prefixes the target with the service shape name; query/ec2 do not. + */ + private static boolean usesTargetPrefix(String protocol) { + return Protocol.AWS_JSON.getValue().equals(protocol) + || Protocol.CBOR.getValue().equals(protocol); + } +} diff --git a/codegen/src/main/java/software/amazon/awssdk/codegen/smithy/AddSmithyModelShapes.java b/codegen/src/main/java/software/amazon/awssdk/codegen/smithy/AddSmithyModelShapes.java new file mode 100644 index 000000000000..f850927bb107 --- /dev/null +++ b/codegen/src/main/java/software/amazon/awssdk/codegen/smithy/AddSmithyModelShapes.java @@ -0,0 +1,141 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.codegen.smithy; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import software.amazon.awssdk.codegen.IntermediateModelShapeProcessor; +import software.amazon.awssdk.codegen.internal.TypeUtils; +import software.amazon.awssdk.codegen.model.config.customization.CustomizationConfig; +import software.amazon.awssdk.codegen.model.intermediate.OperationModel; +import software.amazon.awssdk.codegen.model.intermediate.ShapeModel; +import software.amazon.awssdk.codegen.model.intermediate.ShapeType; +import software.amazon.awssdk.codegen.model.intermediate.ShapeUnmarshaller; +import software.amazon.awssdk.codegen.naming.NamingStrategy; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.knowledge.TopDownIndex; +import software.amazon.smithy.model.shapes.MemberShape; +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.traits.ErrorTrait; +import software.amazon.smithy.model.traits.XmlFlattenedTrait; + +/** + * Builds a {@link ShapeModel} for every structure, union, and enum reachable from the service's + * operations that the input, output, and exception processors did not already produce. + * + *

Reachability walks outward from each operation's input, output, and error shapes; shapes no + * operation references are not translated. + */ +final class AddSmithyModelShapes extends AddSmithyShapes implements IntermediateModelShapeProcessor { + + AddSmithyModelShapes(Model model, + ServiceShape service, + NamingStrategy namingStrategy, + CustomizationConfig customConfig, + String protocol, + TypeUtils typeUtils) { + super(model, service, namingStrategy, customConfig, protocol, typeUtils); + } + + @Override + public Map process(Map currentOperations, + Map currentShapes) { + Map newShapes = new HashMap<>(); + Model model = getModel(); + NamingStrategy naming = getNamingStrategy(); + TopDownIndex topDown = TopDownIndex.of(model); + + Set processed = new HashSet<>(); + Deque queue = new ArrayDeque<>(); + + for (OperationShape op : topDown.getContainedOperations(getService())) { + queue.add(op.getInputShape()); + queue.add(op.getOutputShape()); + queue.addAll(op.getErrors()); + } + + while (!queue.isEmpty()) { + ShapeId shapeId = queue.poll(); + if (!processed.add(shapeId)) { + continue; + } + // Prelude sentinels (Unit, primitives) have no generated shape. + if ("smithy.api".equals(shapeId.getNamespace())) { + continue; + } + + Shape shape = model.getShape(shapeId).orElse(null); + if (shape == null) { + continue; + } + + for (MemberShape m : shape.members()) { + queue.add(m.getTarget()); + } + if (shape.isListShape()) { + queue.add(shape.asListShape().get().getMember().getTarget()); + continue; + } + if (shape.isMapShape()) { + queue.add(shape.asMapShape().get().getKey().getTarget()); + queue.add(shape.asMapShape().get().getValue().getTarget()); + continue; + } + + if (!isTranslatableAsModelShape(shape)) { + continue; + } + + String javaClassName = naming.getShapeClassName(shapeId.getName()); + if (currentShapes.containsKey(javaClassName) || newShapes.containsKey(javaClassName)) { + continue; + } + // Handled by AddSmithyExceptionShapes. + if (shape.hasTrait(ErrorTrait.class)) { + continue; + } + + ShapeModel shapeModel = generateShapeModel(javaClassName, shape, null); + shapeModel.setType(isEnumKind(shape) ? ShapeType.Enum.getValue() : ShapeType.Model.getValue()); + + ShapeUnmarshaller unmarshaller = new ShapeUnmarshaller(); + unmarshaller.setFlattened(shape.hasTrait(XmlFlattenedTrait.class)); + shapeModel.setUnmarshaller(unmarshaller); + + newShapes.put(javaClassName, shapeModel); + } + + return newShapes; + } + + // intEnum is excluded deliberately: C2J models it as a plain integer with no generated shape. + private static boolean isTranslatableAsModelShape(Shape shape) { + return shape.isStructureShape() + || shape.isUnionShape() + || shape.isEnumShape(); + } + + private static boolean isEnumKind(Shape shape) { + return shape.isEnumShape(); + } +} diff --git a/codegen/src/main/java/software/amazon/awssdk/codegen/smithy/AddSmithyOutputShapes.java b/codegen/src/main/java/software/amazon/awssdk/codegen/smithy/AddSmithyOutputShapes.java new file mode 100644 index 000000000000..8564708f7c94 --- /dev/null +++ b/codegen/src/main/java/software/amazon/awssdk/codegen/smithy/AddSmithyOutputShapes.java @@ -0,0 +1,112 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.codegen.smithy; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import software.amazon.awssdk.codegen.IntermediateModelShapeProcessor; +import software.amazon.awssdk.codegen.internal.TypeUtils; +import software.amazon.awssdk.codegen.model.config.customization.CustomizationConfig; +import software.amazon.awssdk.codegen.model.intermediate.OperationModel; +import software.amazon.awssdk.codegen.model.intermediate.Protocol; +import software.amazon.awssdk.codegen.model.intermediate.ShapeModel; +import software.amazon.awssdk.codegen.model.intermediate.ShapeType; +import software.amazon.awssdk.codegen.model.intermediate.ShapeUnmarshaller; +import software.amazon.awssdk.codegen.model.intermediate.VariableModel; +import software.amazon.awssdk.codegen.naming.NamingStrategy; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.knowledge.HttpBindingIndex; +import software.amazon.smithy.model.knowledge.TopDownIndex; +import software.amazon.smithy.model.shapes.OperationShape; +import software.amazon.smithy.model.shapes.ServiceShape; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.shapes.StructureShape; +import software.amazon.smithy.model.traits.XmlFlattenedTrait; + +/** + * Builds the response {@link ShapeModel} for each operation reachable from the service. A + * {@code smithy.api#Unit} output yields a synthesized empty response. + */ +final class AddSmithyOutputShapes extends AddSmithyShapes implements IntermediateModelShapeProcessor { + + private static final ShapeId UNIT = ShapeId.from("smithy.api#Unit"); + + AddSmithyOutputShapes(Model model, + ServiceShape service, + NamingStrategy namingStrategy, + CustomizationConfig customConfig, + String protocol, + TypeUtils typeUtils) { + super(model, service, namingStrategy, customConfig, protocol, typeUtils); + } + + @Override + public Map process(Map currentOperations, + Map currentShapes) { + Map shapes = new HashMap<>(); + Model model = getModel(); + NamingStrategy naming = getNamingStrategy(); + HttpBindingIndex bindingIndex = HttpBindingIndex.of(model); + TopDownIndex topDown = TopDownIndex.of(model); + + for (OperationShape op : topDown.getContainedOperations(getService())) { + String opName = op.toShapeId().getName(); + String javaClassName = naming.getResponseClassName(opName); + + ShapeId outputId = op.getOutputShape(); + ShapeModel shapeModel; + if (UNIT.equals(outputId)) { + shapeModel = synthesizeEmptyResponse(javaClassName); + } else { + StructureShape outputShape = model.expectShape(outputId, StructureShape.class); + shapeModel = generateShapeModel(javaClassName, outputShape, + httpBindingsHonored() + ? bindingIndex.getResponseBindings(op.getId()) + : Collections.emptyMap()); + shapeModel.setUnmarshaller(buildUnmarshaller(op, outputShape)); + } + + if (shapeModel.getUnmarshaller() == null) { + shapeModel.setUnmarshaller(new ShapeUnmarshaller()); + } + shapeModel.setType(ShapeType.Response.getValue()); + + shapes.put(javaClassName, shapeModel); + } + + return shapes; + } + + private ShapeModel synthesizeEmptyResponse(String javaClassName) { + ShapeModel shape = new ShapeModel(javaClassName); + shape.setShapeName(javaClassName); + shape.setVariable(new VariableModel(getNamingStrategy().getVariableName(javaClassName), + javaClassName)); + return shape; + } + + private ShapeUnmarshaller buildUnmarshaller(OperationShape op, StructureShape outputShape) { + ShapeUnmarshaller unmarshaller = new ShapeUnmarshaller(); + unmarshaller.setFlattened(outputShape.hasTrait(XmlFlattenedTrait.class)); + // awsQuery wraps each response in a element, which C2J records as + // output.resultWrapper. ec2Query and rest-xml responses are not result-wrapped. + if (Protocol.QUERY.getValue().equals(getProtocol())) { + unmarshaller.setResultWrapper(op.toShapeId().getName() + "Result"); + } + return unmarshaller; + } +} diff --git a/codegen/src/test/java/software/amazon/awssdk/codegen/smithy/AddSmithyInputShapesTest.java b/codegen/src/test/java/software/amazon/awssdk/codegen/smithy/AddSmithyInputShapesTest.java new file mode 100644 index 000000000000..30cbe4bc2c2d --- /dev/null +++ b/codegen/src/test/java/software/amazon/awssdk/codegen/smithy/AddSmithyInputShapesTest.java @@ -0,0 +1,226 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.codegen.smithy; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Collections; +import java.util.Map; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.codegen.internal.TypeUtils; +import software.amazon.awssdk.codegen.model.config.customization.CustomizationConfig; +import software.amazon.awssdk.codegen.model.intermediate.MemberModel; +import software.amazon.awssdk.codegen.model.intermediate.ShapeMarshaller; +import software.amazon.awssdk.codegen.model.intermediate.ShapeModel; +import software.amazon.awssdk.codegen.naming.DefaultSmithyNamingStrategy; +import software.amazon.awssdk.codegen.naming.NamingStrategy; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.ServiceShape; +import software.amazon.smithy.model.validation.ValidatedResult; + +/** + * Tests for the rest-xml request marshaller's {@code locationName} and {@code xmlNameSpaceUri}, + * and for RPC protocols ignoring HTTP bindings. Surfaced by the route53 and kendraranking fixtures. + */ +class AddSmithyInputShapesTest { + + private static Model modelOf(String protocolUse, String serviceTraits, String body) { + return modelOf(protocolUse, serviceTraits, body, false); + } + + /** + * @param tolerateAdvisories take the model even if validation events were raised, as the parity + * harness does when an RPC service carries HTTP bindings. + */ + private static Model modelOf(String protocolUse, String serviceTraits, String body, boolean tolerateAdvisories) { + String src = + "$version: \"2.0\"\nnamespace demo\n\n" + + "use aws.api#service\n" + + "use aws.auth#sigv4\n" + + protocolUse + + "\n" + + "@service(sdkId: \"Demo\", arnNamespace: \"demo\")\n" + + "@sigv4(name: \"demo\")\n" + + serviceTraits + + "service DemoService { version: \"2024-01-01\", operations: [BodyOp, UriOnlyOp] }\n\n" + + body; + ValidatedResult result = Model.assembler() + .discoverModels(Model.class.getClassLoader()) + .addUnparsedModel("test.smithy", src) + .assemble(); + return tolerateAdvisories + ? result.getResult().orElseThrow(() -> new IllegalStateException("model did not assemble")) + : result.unwrap(); + } + + private static Map inputs(Model model, String protocol) { + ServiceShape service = model.getServiceShapes().iterator().next(); + NamingStrategy naming = new DefaultSmithyNamingStrategy(model, service, CustomizationConfig.create()); + TypeUtils typeUtils = new TypeUtils(naming); + return new AddSmithyInputShapes(model, service, naming, CustomizationConfig.create(), protocol, typeUtils) + .process(Collections.emptyMap(), Collections.emptyMap()); + } + + private static final String REST_XML_OPS = + // Body-bearing request: a member serialized in the XML body plus a URI label. + "@http(method: \"POST\", uri: \"/zone/{ZoneId}\")\n" + + "operation BodyOp { input: BodyOpRequest, output: BodyOpResponse }\n" + + "structure BodyOpRequest {\n" + + " @required @httpLabel ZoneId: String,\n" + + " Comment: String\n" + + "}\n" + + "structure BodyOpResponse {}\n" + // URI-only request: no body members. + + "@http(method: \"GET\", uri: \"/zone/{ZoneId}\")\n" + + "operation UriOnlyOp { input: UriOnlyOpRequest, output: UriOnlyOpResponse }\n" + + "structure UriOnlyOpRequest { @required @httpLabel ZoneId: String }\n" + + "structure UriOnlyOpResponse {}\n"; + + @Test + void restXml_bodyRequest_setsLocationNameAndServiceXmlNamespace() { + Model model = modelOf( + "use aws.protocols#restXml\n", + "@restXml\n@xmlNamespace(uri: \"https://demo.amazonaws.com/doc/2024-01-01/\")\n", + REST_XML_OPS); + ShapeMarshaller marshaller = inputs(model, "rest-xml").get("BodyOpRequest").getMarshaller(); + + assertThat(marshaller.getLocationName()).isEqualTo("BodyOpRequest"); + assertThat(marshaller.getXmlNameSpaceUri()).isEqualTo("https://demo.amazonaws.com/doc/2024-01-01/"); + } + + @Test + void restXml_uriOnlyRequest_hasNoLocationNameOrNamespace() { + Model model = modelOf( + "use aws.protocols#restXml\n", + "@restXml\n@xmlNamespace(uri: \"https://demo.amazonaws.com/doc/2024-01-01/\")\n", + REST_XML_OPS); + ShapeMarshaller marshaller = inputs(model, "rest-xml").get("UriOnlyOpRequest").getMarshaller(); + + // No body member -> no XML root element name / namespace, matching C2J, which authors + // input.locationName / input.xmlNamespace only on body-bearing operations. + assertThat(marshaller.getLocationName()).isNull(); + assertThat(marshaller.getXmlNameSpaceUri()).isNull(); + } + + @Test + void restXml_bodyRequest_honorsXmlNameOverrideForRootElement() { + Model model = modelOf( + "use aws.protocols#restXml\n", + "@restXml\n@xmlNamespace(uri: \"https://demo.amazonaws.com/doc/2024-01-01/\")\n", + "@http(method: \"POST\", uri: \"/zone/{ZoneId}\")\n" + + "operation BodyOp { input: BodyOpRequest, output: BodyOpResponse }\n" + + "@xmlName(\"CustomRoot\")\n" + + "structure BodyOpRequest { @required @httpLabel ZoneId: String, Comment: String }\n" + + "structure BodyOpResponse {}\n" + + "@http(method: \"GET\", uri: \"/zone/{ZoneId}\")\n" + + "operation UriOnlyOp { input: UriOnlyOpRequest, output: UriOnlyOpResponse }\n" + + "structure UriOnlyOpRequest { @required @httpLabel ZoneId: String }\n" + + "structure UriOnlyOpResponse {}\n"); + ShapeMarshaller marshaller = inputs(model, "rest-xml").get("BodyOpRequest").getMarshaller(); + + assertThat(marshaller.getLocationName()).isEqualTo("CustomRoot"); + } + + @Test + void rpcProtocol_ignoresHttpBindings_postToRootWithBodyMembers() { + // An RPC service may still carry @http/@httpLabel; the bindings must be ignored, so every + // member goes in the body. The rpcv2Cbor URI is applied later by the deferred processor. + Model model = modelOf( + "use smithy.protocols#rpcv2Cbor\n", + "@rpcv2Cbor\n", + "@http(method: \"DELETE\", uri: \"/plan/{Id}\")\n" + + "operation BodyOp { input: BodyOpRequest, output: BodyOpResponse }\n" + + "structure BodyOpRequest { @required @httpLabel Id: String, Comment: String }\n" + + "structure BodyOpResponse {}\n" + + "@http(method: \"GET\", uri: \"/plan/{Id}\")\n" + + "operation UriOnlyOp { input: UriOnlyOpRequest, output: UriOnlyOpResponse }\n" + + "structure UriOnlyOpRequest { @required @httpLabel Id: String }\n" + + "structure UriOnlyOpResponse {}\n", + true); + ShapeModel req = inputs(model, "smithy-rpc-v2-cbor").get("BodyOpRequest"); + + assertThat(req.getMarshaller().getVerb()).isEqualTo("POST"); + assertThat(req.getMarshaller().getRequestUri()).isEqualTo("/"); + + MemberModel id = req.getMembersAsMap().get("Id"); + // @httpLabel ignored: no URI location, and the wire name is the member name, not a label. + assertThat(id.getHttp().getLocation()).isNull(); + assertThat(id.getHttp().getMarshallLocationName()).isEqualTo("Id"); + } + + @Test + void discoveredEndpointOperation_setsEndpointDiscoveryOnRequestShape() { + // SyncClientClass/AsyncClientClass dereference this off the request shape, guarded only by + // the operation-level field, so a null here NPEs codegen. + Model model = Model.assembler() + .discoverModels(Model.class.getClassLoader()) + .addUnparsedModel("discovery.smithy", + "$version: \"2.0\"\nnamespace demo\n\n" + + "use aws.api#service\n" + + "use aws.auth#sigv4\n" + + "use aws.protocols#restJson1\n" + + "use aws.api#clientEndpointDiscovery\n" + + "use aws.api#clientDiscoveredEndpoint\n\n" + + "@service(sdkId: \"Demo\", arnNamespace: \"demo\")\n" + + "@restJson1\n" + + "@sigv4(name: \"demo\")\n" + + "@clientEndpointDiscovery(operation: DescribeEndpoints, error: BadRequestException)\n" + + "service DemoService { version: \"2024-01-01\", operations: [DescribeEndpoints, GetItem] }\n\n" + + "@http(method: \"POST\", uri: \"/endpoints\")\n" + + "operation DescribeEndpoints {\n" + + " input: DescribeEndpointsIn,\n" + + " output: DescribeEndpointsOut,\n" + + " errors: [BadRequestException]\n" + + "}\n" + + "structure DescribeEndpointsIn {}\n" + + "structure DescribeEndpointsOut { @required Endpoints: Endpoints }\n" + + "list Endpoints { member: Endpoint }\n" + + "structure Endpoint {\n" + + " @required Address: String\n" + + " @required CachePeriodInMinutes: Long\n" + + "}\n\n" + + "@clientDiscoveredEndpoint(required: true)\n" + + "@http(method: \"POST\", uri: \"/get\")\n" + + "operation GetItem { input: GetItemIn, output: GetItemOut, errors: [BadRequestException] }\n" + + "structure GetItemIn { Key: String }\n" + + "structure GetItemOut {}\n\n" + + "@error(\"client\")\n" + + "structure BadRequestException { message: String }\n") + .assemble() + .unwrap(); + + Map shapes = inputs(model, "rest-json"); + + assertThat(shapes.get("GetItemRequest").getEndpointDiscovery()).isNotNull(); + assertThat(shapes.get("GetItemRequest").getEndpointDiscovery().isRequired()).isTrue(); + // The discovery provider is not itself a consumer. + assertThat(shapes.get("DescribeEndpointsRequest").getEndpointDiscovery()).isNull(); + } + + @Test + void nonRestXmlProtocol_doesNotSetServiceNamespaceOnMarshaller() { + // ec2Query carries a service @xmlNamespace, but C2J does not propagate it to the request + // marshaller; only rest-xml does. Guards against regressing the ec2 fixture. + Model model = modelOf( + "use aws.protocols#ec2Query\n", + "@ec2Query\n@xmlNamespace(uri: \"https://demo.amazonaws.com/doc/2024-01-01/\")\n", + REST_XML_OPS); + ShapeMarshaller marshaller = inputs(model, "ec2").get("BodyOpRequest").getMarshaller(); + + assertThat(marshaller.getLocationName()).isNull(); + assertThat(marshaller.getXmlNameSpaceUri()).isNull(); + } +} diff --git a/codegen/src/test/java/software/amazon/awssdk/codegen/smithy/AddSmithyOutputShapesTest.java b/codegen/src/test/java/software/amazon/awssdk/codegen/smithy/AddSmithyOutputShapesTest.java new file mode 100644 index 000000000000..9420aa9db26a --- /dev/null +++ b/codegen/src/test/java/software/amazon/awssdk/codegen/smithy/AddSmithyOutputShapesTest.java @@ -0,0 +1,92 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.codegen.smithy; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Collections; +import java.util.Map; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.codegen.internal.TypeUtils; +import software.amazon.awssdk.codegen.model.config.customization.CustomizationConfig; +import software.amazon.awssdk.codegen.model.intermediate.ShapeModel; +import software.amazon.awssdk.codegen.naming.DefaultSmithyNamingStrategy; +import software.amazon.awssdk.codegen.naming.NamingStrategy; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.ServiceShape; + +/** + * Tests for the awsQuery response {@code unmarshaller.resultWrapper}, which only awsQuery uses. + * Surfaced by the sts fixture. + */ +class AddSmithyOutputShapesTest { + + private static Model modelOf(String protocolUse, String serviceTraits) { + String src = + "$version: \"2.0\"\nnamespace demo\n\n" + + "use aws.api#service\n" + + "use aws.auth#sigv4\n" + + protocolUse + + "\n" + + "@service(sdkId: \"Demo\", arnNamespace: \"demo\")\n" + + "@sigv4(name: \"demo\")\n" + + serviceTraits + + "service DemoService { version: \"2024-01-01\", operations: [Op] }\n\n" + + "@http(method: \"POST\", uri: \"/op\")\n" + + "operation Op { input: OpRequest, output: OpResponse }\n" + + "structure OpRequest {}\n" + + "structure OpResponse { name: String }\n"; + return Model.assembler() + .discoverModels(Model.class.getClassLoader()) + .addUnparsedModel("test.smithy", src) + .assemble() + .unwrap(); + } + + private static Map outputs(Model model, String protocol) { + ServiceShape service = model.getServiceShapes().iterator().next(); + NamingStrategy naming = new DefaultSmithyNamingStrategy(model, service, CustomizationConfig.create()); + TypeUtils typeUtils = new TypeUtils(naming); + return new AddSmithyOutputShapes(model, service, naming, CustomizationConfig.create(), protocol, typeUtils) + .process(Collections.emptyMap(), Collections.emptyMap()); + } + + @Test + void awsQuery_setsResultWrapperToOperationNamePlusResult() { + Model model = modelOf( + "use aws.protocols#awsQuery\n", + "@awsQuery\n@xmlNamespace(uri: \"https://demo.amazonaws.com/doc/2024-01-01/\")\n"); + assertThat(outputs(model, "query").get("OpResponse").getUnmarshaller().getResultWrapper()) + .isEqualTo("OpResult"); + } + + @Test + void ec2Query_hasNoResultWrapper() { + // ec2Query responses are not result-wrapped; the ec2 C2J models carry no resultWrapper. + Model model = modelOf( + "use aws.protocols#ec2Query\n", + "@ec2Query\n@xmlNamespace(uri: \"https://demo.amazonaws.com/doc/2024-01-01/\")\n"); + assertThat(outputs(model, "ec2").get("OpResponse").getUnmarshaller().getResultWrapper()) + .isNull(); + } + + @Test + void restJson_hasNoResultWrapper() { + Model model = modelOf("use aws.protocols#restJson1\n", "@restJson1\n"); + assertThat(outputs(model, "rest-json").get("OpResponse").getUnmarshaller().getResultWrapper()) + .isNull(); + } +} diff --git a/codegen/src/test/java/software/amazon/awssdk/codegen/smithy/AddSmithyProcessorsTest.java b/codegen/src/test/java/software/amazon/awssdk/codegen/smithy/AddSmithyProcessorsTest.java new file mode 100644 index 000000000000..b98ea6975fad --- /dev/null +++ b/codegen/src/test/java/software/amazon/awssdk/codegen/smithy/AddSmithyProcessorsTest.java @@ -0,0 +1,271 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.codegen.smithy; + +import static java.util.Collections.emptyMap; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.codegen.IntermediateModelShapeProcessor; +import software.amazon.awssdk.codegen.internal.TypeUtils; +import software.amazon.awssdk.codegen.model.config.customization.CustomizationConfig; +import software.amazon.awssdk.codegen.model.intermediate.OperationModel; +import software.amazon.awssdk.codegen.model.intermediate.ShapeModel; +import software.amazon.awssdk.codegen.model.intermediate.ShapeType; +import software.amazon.awssdk.codegen.naming.DefaultSmithyNamingStrategy; +import software.amazon.awssdk.codegen.naming.NamingStrategy; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.ServiceShape; + +/** + * Runs the four shape processors as a chain, in builder order, and asserts on the combined + * {@code Map}. + */ +class AddSmithyProcessorsTest { + + private static final String IDL_HEADER = + "$version: \"2.0\"\n" + + "namespace demo\n\n" + + "use aws.api#service\n" + + "use aws.auth#sigv4\n" + + "use aws.protocols#restJson1\n\n" + + "@service(sdkId: \"Demo\", arnNamespace: \"demo\")\n" + + "@restJson1\n" + + "@sigv4(name: \"demo\")\n"; + + private static Model loadModel(String body) { + return Model.assembler() + .discoverModels(Model.class.getClassLoader()) + .addUnparsedModel("test.smithy", IDL_HEADER + body) + .assemble() + .unwrap(); + } + + private static Map runProcessorChain(Model model, String protocol) { + ServiceShape service = model.getServiceShapes().iterator().next(); + NamingStrategy naming = new DefaultSmithyNamingStrategy(model, service, CustomizationConfig.create()); + TypeUtils typeUtils = new TypeUtils(naming); + CustomizationConfig cc = CustomizationConfig.create(); + + IntermediateModelShapeProcessor[] processors = { + new AddSmithyInputShapes(model, service, naming, cc, protocol, typeUtils), + new AddSmithyOutputShapes(model, service, naming, cc, protocol, typeUtils), + new AddSmithyExceptionShapes(model, service, naming, cc, protocol, typeUtils), + new AddSmithyModelShapes(model, service, naming, cc, protocol, typeUtils), + }; + + Map shapes = new HashMap<>(); + Map ops = emptyMap(); + for (IntermediateModelShapeProcessor p : processors) { + shapes.putAll(p.process(ops, Collections.unmodifiableMap(shapes))); + } + return shapes; + } + + @Test + void fourProcessors_produceRequestResponseExceptionAndModelShapes() { + Model model = loadModel( + "service DemoService { version: \"2024-01-01\", operations: [PutThing, GetThing] }\n" + + "@http(method: \"PUT\", uri: \"/things/{id}\")\n" + + "operation PutThing { input: PutThingInput, output: PutThingOutput, errors: [ConflictException] }\n" + + "@http(method: \"GET\", uri: \"/things/{id}\")\n" + + "operation GetThing { input: GetThingInput, output: GetThingOutput, errors: [ConflictException] }\n" + + "\n" + + "structure PutThingInput {\n" + + " @required @httpLabel id: String\n" + + " payload: ThingPayload\n" + + "}\n" + + "structure PutThingOutput { thing: Thing }\n" + + "structure GetThingInput { @required @httpLabel id: String }\n" + + "structure GetThingOutput { thing: Thing }\n" + + "structure ThingPayload { data: String }\n" + + "structure Thing { id: String, name: String, status: Status }\n" + + "enum Status { ACTIVE, INACTIVE }\n" + + "@error(\"client\") @httpError(409) structure ConflictException { message: String }\n"); + + Map shapes = runProcessorChain(model, "rest-json"); + + assertThat(shapes).containsKeys("PutThingRequest", "GetThingRequest"); + assertThat(shapes.get("PutThingRequest").getType()).isEqualTo(ShapeType.Request.getValue()); + assertThat(shapes.get("PutThingRequest").getMarshaller()).isNotNull(); + assertThat(shapes.get("PutThingRequest").getMarshaller().getVerb()).isEqualTo("PUT"); + assertThat(shapes.get("PutThingRequest").getMarshaller().getRequestUri()).isEqualTo("/things/{id}"); + + assertThat(shapes).containsKeys("PutThingResponse", "GetThingResponse"); + assertThat(shapes.get("PutThingResponse").getType()).isEqualTo(ShapeType.Response.getValue()); + assertThat(shapes.get("PutThingResponse").getUnmarshaller()).isNotNull(); + + assertThat(shapes).containsKey("ConflictException"); + assertThat(shapes.get("ConflictException").getType()).isEqualTo(ShapeType.Exception.getValue()); + assertThat(shapes.get("ConflictException").getHttpStatusCode()).isEqualTo(409); + + assertThat(shapes).containsKeys("Thing", "ThingPayload", "Status"); + assertThat(shapes.get("Thing").getType()).isEqualTo(ShapeType.Model.getValue()); + assertThat(shapes.get("Status").getType()).isEqualTo(ShapeType.Enum.getValue()); + } + + @Test + void unitInputAndOutput_synthesizeEmptyRequestAndResponse() { + Model model = loadModel( + "service DemoService { version: \"2024-01-01\", operations: [Ping] }\n" + + "@http(method: \"GET\", uri: \"/ping\")\n" + + "operation Ping { input: Unit, output: Unit }\n"); + + Map shapes = runProcessorChain(model, "rest-json"); + + assertThat(shapes).containsKey("PingRequest"); + assertThat(shapes.get("PingRequest").getMembers()).isEmpty(); + assertThat(shapes.get("PingRequest").getMarshaller().getIsSynthetic()).isTrue(); + + assertThat(shapes).containsKey("PingResponse"); + assertThat(shapes.get("PingResponse").getMembers()).isEmpty(); + assertThat(shapes.get("PingResponse").getUnmarshaller()).isNotNull(); + } + + @Test + void awsJsonProtocol_defaultsVerbToPostAndUriToSlashAndSetsTarget() { + Model model = Model.assembler() + .discoverModels(Model.class.getClassLoader()) + .addUnparsedModel("rpc.smithy", + "$version: \"2.0\"\nnamespace demo\n\n" + + "use aws.api#service\n" + + "use aws.auth#sigv4\n" + + "use aws.protocols#awsJson1_1\n" + + "@service(sdkId: \"Demo\", arnNamespace: \"demo\")\n" + + "@awsJson1_1\n" + + "@sigv4(name: \"demo\")\n" + + "service Demo { version: \"2024-01-01\", operations: [Compute] }\n" + + "operation Compute { input: In, output: Out }\n" + + "structure In { x: Integer }\n" + + "structure Out { y: Integer }\n") + .assemble() + .unwrap(); + + Map shapes = runProcessorChain(model, "json"); + + assertThat(shapes.get("ComputeRequest").getMarshaller().getVerb()).isEqualTo("POST"); + assertThat(shapes.get("ComputeRequest").getMarshaller().getRequestUri()).isEqualTo("/"); + // awsJson target prefix is the Smithy service shape name. + assertThat(shapes.get("ComputeRequest").getMarshaller().getTarget()).isEqualTo("Demo.Compute"); + } + + @Test + void queryProtocol_setsBareOperationNameAsTarget() { + Model model = Model.assembler() + .discoverModels(Model.class.getClassLoader()) + .addUnparsedModel("query.smithy", + "$version: \"2.0\"\nnamespace demo\n\n" + + "use aws.api#service\n" + + "use aws.auth#sigv4\n" + + "use aws.protocols#awsQuery\n" + + "@service(sdkId: \"Demo\", arnNamespace: \"demo\")\n" + + "@awsQuery\n" + + "@xmlNamespace(uri: \"https://demo.amazonaws.com/\")\n" + + "@sigv4(name: \"demo\")\n" + + "service Demo { version: \"2024-01-01\", operations: [Compute] }\n" + + "operation Compute { input: In, output: Out }\n" + + "structure In { x: Integer }\n" + + "structure Out { y: Integer }\n") + .assemble() + .unwrap(); + + Map shapes = runProcessorChain(model, "query"); + + // query uses an operation identifier but has no target prefix — bare operation name. + assertThat(shapes.get("ComputeRequest").getMarshaller().getTarget()).isEqualTo("Compute"); + } + + @Test + void sharedExceptionAcrossOperations_isOnlyProducedOnce() { + Model model = loadModel( + "service DemoService { version: \"2024-01-01\", operations: [A, B] }\n" + + "@http(method: \"GET\", uri: \"/a\")\n" + + "operation A { input: Unit, output: Unit, errors: [Boom] }\n" + + "@http(method: \"GET\", uri: \"/b\")\n" + + "operation B { input: Unit, output: Unit, errors: [Boom] }\n" + + "@error(\"server\") @httpError(500)\n" + + "structure Boom { message: String }\n"); + + Map shapes = runProcessorChain(model, "rest-json"); + + assertThat(shapes).containsKey("BoomException"); + assertThat(shapes.get("BoomException").getErrorCode()).isEqualTo("Boom"); + assertThat(shapes.get("BoomException").getHttpStatusCode()).isEqualTo(500); + assertThat(shapes.get("BoomException").isFault()).isTrue(); + } + + @Test + void serviceLevelError_producesExceptionShape() { + // AddSmithyOperations lists service-level errors in OperationModel.exceptions, so the + // matching shape must exist or codegen references a class nothing generates. + Model model = loadModel( + "service DemoService {\n" + + " version: \"2024-01-01\",\n" + + " operations: [Op],\n" + + " errors: [ServiceLevelError]\n" + + "}\n" + + "@http(method: \"GET\", uri: \"/op\")\n" + + "operation Op { input: Unit, output: Unit, errors: [OpError] }\n" + + "@error(\"server\") @httpError(500)\n" + + "structure ServiceLevelError { message: String }\n" + + "@error(\"client\") @httpError(400)\n" + + "structure OpError { message: String }\n"); + + Map shapes = runProcessorChain(model, "rest-json"); + + assertThat(shapes).containsKeys("OpErrorException", "ServiceLevelErrorException"); + assertThat(shapes.get("ServiceLevelErrorException").getType()) + .isEqualTo(ShapeType.Exception.getValue()); + } + + @Test + void modelShapesProcessor_skipsShapesUnreferencedByOperations() { + Model model = loadModel( + "service DemoService { version: \"2024-01-01\", operations: [GetOne] }\n" + + "@http(method: \"GET\", uri: \"/one\")\n" + + "operation GetOne { input: Unit, output: GetOneOutput }\n" + + "structure GetOneOutput { thing: Thing }\n" + + "structure Thing { id: String }\n" + + "structure Orphan { junk: String }\n"); + + Map shapes = runProcessorChain(model, "rest-json"); + + assertThat(shapes).containsKey("Thing"); + assertThat(shapes).doesNotContainKey("Orphan"); + } + + @Test + void modelShapesProcessor_doesNotOverwriteRequestAndResponseShapes() { + Model model = loadModel( + "service DemoService { version: \"2024-01-01\", operations: [Op] }\n" + + "@http(method: \"POST\", uri: \"/things\")\n" + + "operation Op { input: OpInput, output: OpOutput }\n" + + "structure OpInput { thing: Thing }\n" + + "structure OpOutput { thing: Thing }\n" + + "structure Thing { id: String }\n"); + + Map shapes = runProcessorChain(model, "rest-json"); + + assertThat(shapes).containsKeys("OpRequest", "OpResponse", "Thing"); + assertThat(shapes.get("Thing").getType()).isEqualTo(ShapeType.Model.getValue()); + // The model processor must not overwrite the request/response entries with Model shapes. + assertThat(shapes.get("OpRequest").getType()).isEqualTo(ShapeType.Request.getValue()); + assertThat(shapes.get("OpResponse").getType()).isEqualTo(ShapeType.Response.getValue()); + } +}