Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -302,6 +304,7 @@ private Map<String, Node> createErrorExamplesForMembersWithHttpTraits(
* This method is used for converting the Smithy examples to OpenAPI examples for non-payload HTTP message body.
*/
private Map<String, Node> createBodyExamples(
Context<T> context,
Shape operationOrError,
List<HttpBinding> bindings,
MessageType type,
Expand All @@ -312,7 +315,7 @@ private Map<String, Node> createBodyExamples(
}

if (type == MessageType.ERROR) {
return createErrorBodyExamples(operationOrError, bindings, operation);
return createErrorBodyExamples(context, operationOrError, bindings, operation);
} else {
Map<String, Node> examples = new TreeMap<>();
// unique numbering for unique example names in OpenAPI.
Expand Down Expand Up @@ -342,6 +345,7 @@ private Map<String, Node> createBodyExamples(
}

private Map<String, Node> createErrorBodyExamples(
Context<T> context,
Shape error,
List<HttpBinding> bindings,
OperationShape operation
Expand All @@ -350,14 +354,17 @@ private Map<String, Node> createErrorBodyExamples(
// unique numbering for unique example names in OpenAPI.
int uniqueNum = 1;
Optional<ExamplesTrait> examplesTrait = operation.getTrait(ExamplesTrait.class);

Set<ShapeId> 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())
Expand All @@ -370,6 +377,27 @@ private Map<String, Node> createErrorBodyExamples(
return examples;
}

private Set<ShapeId> getErrorShapeIdsForMatching(Context<T> context, Shape error, OperationShape operation) {
// Handle synthesized error unions when deconflicting is enabled
Set<ShapeId> 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].
*/
Expand Down Expand Up @@ -598,7 +626,7 @@ private Optional<RequestBodyObject> 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.
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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
}
])
Loading