diff --git a/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/protocols/AbstractRestProtocol.java b/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/protocols/AbstractRestProtocol.java index dcd550617ad..28d484789be 100644 --- a/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/protocols/AbstractRestProtocol.java +++ b/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/protocols/AbstractRestProtocol.java @@ -7,6 +7,7 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -33,6 +34,7 @@ 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.ExamplesTrait; @@ -302,6 +304,7 @@ private Map createErrorExamplesForMembersWithHttpTraits( * This method is used for converting the Smithy examples to OpenAPI examples for non-payload HTTP message body. */ private Map createBodyExamples( + Context context, Shape operationOrError, List bindings, MessageType type, @@ -312,7 +315,7 @@ private Map createBodyExamples( } if (type == MessageType.ERROR) { - return createErrorBodyExamples(operationOrError, bindings, operation); + return createErrorBodyExamples(context, operationOrError, bindings, operation); } else { Map examples = new TreeMap<>(); // unique numbering for unique example names in OpenAPI. @@ -342,6 +345,7 @@ private Map createBodyExamples( } private Map createErrorBodyExamples( + Context context, Shape error, List bindings, OperationShape operation @@ -350,14 +354,17 @@ private Map createErrorBodyExamples( // unique numbering for unique example names in OpenAPI. int uniqueNum = 1; Optional examplesTrait = operation.getTrait(ExamplesTrait.class); + + Set errorShapeIds = getErrorShapeIdsForMatching(context, error, operation); + for (ExamplesTrait.Example example : examplesTrait.map(ExamplesTrait::getExamples) .orElse(Collections.emptyList())) { String name = operation.getId().getName() + "_example" + uniqueNum++; // this has to be checked because an operation can have more than one error linked to it. if (example.getError().isPresent() - && example.getError().get().getShapeId() == error.toShapeId()) { + && errorShapeIds.contains(example.getError().get().getShapeId())) { // get members included in bindings - ObjectNode values = getMembersWithHttpBindingTrait(bindings, example.getError().get().getContent()); + ObjectNode values = example.getError().get().getContent(); examples.put(name, ExampleObject.builder() .summary(example.getTitle()) @@ -370,6 +377,27 @@ private Map createErrorBodyExamples( return examples; } + private Set getErrorShapeIdsForMatching(Context context, Shape error, OperationShape operation) { + // Handle synthesized error unions when deconflicting is enabled + Set errorShapeIds = new HashSet<>(); + if (error.getMember("errorUnion").isPresent()) { + ShapeId unionShapeId = error.getMember("errorUnion").get().getTarget(); + Shape unionShape = context.getModel().getShape(unionShapeId).orElse(null); + + if (unionShape != null && unionShape.isUnionShape()) { + for (MemberShape unionMember : unionShape.getAllMembers().values()) { + errorShapeIds.add(unionMember.getTarget()); + } + } else { + errorShapeIds.add(error.toShapeId()); + } + } else { + // If not deconflicted, then the error passed is an actual error (not synthesized) + errorShapeIds.add(error.toShapeId()); + } + return errorShapeIds; + } + /* * Returns a modified copy of [inputOrOutput] only containing members bound to a HttpBinding trait in [bindings]. */ @@ -598,7 +626,7 @@ private Optional createRequestDocument( String pointer = context.putSynthesizedSchema(synthesizedName, schema); MediaTypeObject mediaTypeObject = MediaTypeObject.builder() .schema(Schema.builder().ref(pointer).build()) - .examples(createBodyExamples(operation, bindings, MessageType.REQUEST, null)) + .examples(createBodyExamples(context, operation, bindings, MessageType.REQUEST, null)) .build(); // If any of the top level bindings are required, then the body itself must be required. @@ -840,7 +868,7 @@ private void createResponseDocumentIfNeeded( String pointer = context.putSynthesizedSchema(synthesizedName, schema); MediaTypeObject mediaTypeObject = MediaTypeObject.builder() .schema(Schema.builder().ref(pointer).build()) - .examples(createBodyExamples(operationOrError, bindings, messageType, operation)) + .examples(createBodyExamples(context, operationOrError, bindings, messageType, operation)) .build(); responseBuilder.putContent(mediaType, mediaTypeObject); diff --git a/smithy-openapi/src/test/java/software/amazon/smithy/openapi/fromsmithy/ErrorDeconflictingExamplesTest.java b/smithy-openapi/src/test/java/software/amazon/smithy/openapi/fromsmithy/ErrorDeconflictingExamplesTest.java new file mode 100644 index 00000000000..dbd13c71b32 --- /dev/null +++ b/smithy-openapi/src/test/java/software/amazon/smithy/openapi/fromsmithy/ErrorDeconflictingExamplesTest.java @@ -0,0 +1,44 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.openapi.fromsmithy; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.InputStream; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.openapi.OpenApiConfig; +import software.amazon.smithy.utils.IoUtils; + +/** + * Tests that all error examples are included in the openapi when error deconflicting (`onErrorStatusConflict`) is set to `oneOf`. + */ +public class ErrorDeconflictingExamplesTest { + + @Test + public void serviceLevelErrorsShouldNotOverrideOperationSpecificExamples() { + Model model = Model.assembler() + .discoverModels() + .addImport(getClass().getResource("error-deconflicting-test.smithy")) + .assemble() + .unwrap(); + + OpenApiConfig config = new OpenApiConfig(); + config.setService(ShapeId.from("test.service.error#TestService")); + config.setOnErrorStatusConflict(OpenApiConfig.ErrorStatusConflictHandlingStrategy.ONE_OF); + + Node result = OpenApiConverter.create().config(config).convertToNode(model); + + InputStream expectedStream = getClass().getResourceAsStream("error-deconflicting-test.openapi.json"); + if (expectedStream == null) { + fail("Expected OpenAPI output file not found: error-deconflicting-test.openapi.json"); + } + + Node expected = Node.parse(IoUtils.toUtf8String(expectedStream)); + Node.assertEquals(result, expected); + } +} diff --git a/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/error-deconflicting-test.openapi.json b/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/error-deconflicting-test.openapi.json new file mode 100644 index 00000000000..6b93cefdd10 --- /dev/null +++ b/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/error-deconflicting-test.openapi.json @@ -0,0 +1,147 @@ +{ + "openapi": "3.0.2", + "info": { + "title": "TestService", + "version": "1.0.0" + }, + "paths": { + "/test": { + "post": { + "operationId": "TestOp", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestOpRequestContent" + }, + "examples": { + "TestOp_example1": { + "summary": "Success example", + "description": "", + "value": { + "value": "good" + } + }, + "TestOp_example2": { + "summary": "Client error (defined on the service) example", + "description": "", + "value": { + "value": "client bad" + } + }, + "TestOp_example3": { + "summary": "Custom error example", + "description": "", + "value": { + "value": "bad" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "TestOp 200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestOpResponseContent" + }, + "examples": { + "TestOp_example1": { + "summary": "Success example", + "description": "", + "value": { + "result": "success" + } + } + } + } + } + }, + "400": { + "description": "TestOp400Error 400 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TestOp400ErrorResponseContent" + }, + "examples": { + "TestOp_example2": { + "summary": "Client error (defined on the service) example", + "description": "", + "value": { + "message": "Client error occurred" + } + }, + "TestOp_example3": { + "summary": "Custom error example", + "description": "", + "value": { + "message": "Custom error occurred", + "code": "CUSTOM_ERROR", + "details": "This should appear in 400 response examples" + } + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "ClientError": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + } + }, + "CustomError": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "code": { + "type": "string" + }, + "details": { + "type": "string" + } + } + }, + "TestOp400ErrorResponseContent": { + "oneOf": [ + { + "$ref": "#/components/schemas/CustomError" + }, + { + "$ref": "#/components/schemas/ClientError" + } + ] + }, + "TestOpRequestContent": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + } + }, + "TestOpResponseContent": { + "type": "object", + "properties": { + "result": { + "type": "string" + } + } + } + } + } +} \ No newline at end of file diff --git a/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/error-deconflicting-test.smithy b/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/error-deconflicting-test.smithy new file mode 100644 index 00000000000..e5bbd7e982b --- /dev/null +++ b/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/error-deconflicting-test.smithy @@ -0,0 +1,68 @@ +$version: "2.0" +namespace test.service.error +use aws.protocols#restJson1 + +@restJson1 +service TestService { + version: "1.0.0", + operations: [TestOp] + errors: [ClientError] +} + +@http(method: "POST", uri: "/test") +operation TestOp { + input: TestInput, + output: TestOutput, + errors: [CustomError] // Operation-specific 400 error +} + +structure TestInput { value: String } +structure TestOutput { result: String } + + +@error("client") +@httpError(400) +structure ClientError { + message: String +} + +// Operation-specific 400 error - should have examples generated +@error("client") +@httpError(400) +structure CustomError { + message: String, + code: String, + details: String +} + +apply TestOp @examples([ + { + title: "Success example" + input: { value: "good" } + output: { result: "success" } + }, + { + title: "Client error (defined on the service) example" + input: { value: "client bad" } + error: { + shapeId: ClientError + content: { + message: "Client error occurred" + } + } + allowConstraintErrors: true + }, + { + title: "Custom error example" + input: { value: "bad" } + error: { + shapeId: CustomError + content: { + message: "Custom error occurred" + code: "CUSTOM_ERROR" + details: "This should appear in 400 response examples" + } + } + allowConstraintErrors: true + } +])