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..a6d2a82ce61 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 @@ -109,11 +109,16 @@ abstract Schema createDocumentSchema( MessageType messageType ); + @Deprecated + Node transformSmithyValueToProtocolValue(Node value) { + return value; + } + /** * Converts Smithy values in Node form to a data exchange format used by a protocol (e.g., XML). * Then returns the converted value as a long string (escaping where necessary). * If data exchange format is JSON (e.g., as in restJson1 protocol), - * method should return values without any modification. + * method should respect the jsonName trait, but otherwise not modify the node. * *

Used for the value property of OpenAPI example objects. * For protocols that do not use JSON as data-exchange format, @@ -122,10 +127,14 @@ abstract Schema createDocumentSchema( * E.g., for restXML protocol, values would be converted to a large String of XML value / object, * escaping where necessary. * - * @param value value to be converted. + * @param context Conversion context. + * @param shape The shape that the value represents. + * @param value value to be converted. * @return the long string (escaped where necessary) of values in a data exchange format used by a protocol. */ - abstract Node transformSmithyValueToProtocolValue(Node value); + Node transformSmithyValueToProtocolValue(Context context, Shape shape, Node value) { + return value; + } @Override public Set getProtocolRequestHeaders(Context context, OperationShape operationShape) { @@ -204,6 +213,7 @@ private List createPathParameters(Context context, Operation .in("path") .schema(schema) .examples(createExamplesForMembersWithHttpTraits( + context, operation, binding, MessageType.REQUEST, @@ -219,6 +229,7 @@ private List createPathParameters(Context context, Operation * path parameters, query parameters, header parameters, and payload. */ private Map createExamplesForMembersWithHttpTraits( + Context context, Shape operationOrError, HttpBinding binding, MessageType type, @@ -229,15 +240,17 @@ private Map createExamplesForMembersWithHttpTraits( } if (type == MessageType.ERROR) { - return createErrorExamplesForMembersWithHttpTraits(operationOrError, binding, operation); + return createErrorExamplesForMembersWithHttpTraits(context, operationOrError, binding, operation); } else { Map examples = new TreeMap<>(); // unique numbering for unique example names in OpenAPI. int uniqueNum = 1; - Optional examplesTrait = operationOrError.getTrait(ExamplesTrait.class); - for (ExamplesTrait.Example example : examplesTrait.map(ExamplesTrait::getExamples) - .orElse(Collections.emptyList())) { + List modeledExamples = operationOrError.getTrait(ExamplesTrait.class) + .map(ExamplesTrait::getExamples) + .orElse(Collections.emptyList()); + + for (ExamplesTrait.Example example : modeledExamples) { ObjectNode inputOrOutput = type == MessageType.REQUEST ? example.getInput() : example.getOutput().orElse(Node.objectNode()); String name = operationOrError.getId().getName() + "_example" + uniqueNum++; @@ -251,7 +264,7 @@ private Map createExamplesForMembersWithHttpTraits( ExampleObject.builder() .summary(example.getTitle()) .description(example.getDocumentation().orElse("")) - .value(transformSmithyValueToProtocolValue(values)) + .value(transformSmithyValueToProtocolValue(context, binding.getMember(), values)) .build() .toNode()); } @@ -264,6 +277,7 @@ private Map createExamplesForMembersWithHttpTraits( * Helper method for createExamples() method. */ private Map createErrorExamplesForMembersWithHttpTraits( + Context context, Shape error, HttpBinding binding, OperationShape operation @@ -290,7 +304,7 @@ private Map createErrorExamplesForMembersWithHttpTraits( ExampleObject.builder() .summary(example.getTitle()) .description(example.getDocumentation().orElse("")) - .value(transformSmithyValueToProtocolValue(values)) + .value(transformSmithyValueToProtocolValue(context, binding.getMember(), values)) .build() .toNode()); } @@ -302,6 +316,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 +327,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. @@ -321,18 +336,30 @@ private Map createBodyExamples( Optional examplesTrait = operationOrError.getTrait(ExamplesTrait.class); for (ExamplesTrait.Example example : examplesTrait.map(ExamplesTrait::getExamples) .orElse(Collections.emptyList())) { + + Shape structure = operationOrError; + if (operationOrError.isOperationShape()) { + OperationShape op = operationOrError.asOperationShape().get(); + if (type == MessageType.REQUEST) { + structure = context.getModel().expectShape(op.getInputShape()); + } else { + structure = context.getModel().expectShape(op.getOutputShape()); + } + } + // get members included in bindings ObjectNode values = getMembersWithHttpBindingTrait(bindings, type == MessageType.REQUEST ? example.getInput() : example.getOutput().orElse(Node.objectNode())); String name = operationOrError.getId().getName() + "_example" + uniqueNum++; + // this if condition is needed to avoid errors when converting examples of response. if (!example.getError().isPresent() || type == MessageType.REQUEST) { examples.put(name, ExampleObject.builder() .summary(example.getTitle()) .description(example.getDocumentation().orElse("")) - .value(transformSmithyValueToProtocolValue(values)) + .value(transformSmithyValueToProtocolValue(context, structure, values)) .build() .toNode()); } @@ -342,6 +369,7 @@ private Map createBodyExamples( } private Map createErrorBodyExamples( + Context context, Shape error, List bindings, OperationShape operation @@ -362,7 +390,7 @@ private Map createErrorBodyExamples( ExampleObject.builder() .summary(example.getTitle()) .description(example.getDocumentation().orElse("")) - .value(transformSmithyValueToProtocolValue(values)) + .value(transformSmithyValueToProtocolValue(context, error, values)) .build() .toNode()); } @@ -456,7 +484,8 @@ private List createQueryParameters(Context context, Operatio } param.schema(createQuerySchema(context, member, target)); - param.examples(createExamplesForMembersWithHttpTraits(operation, binding, MessageType.REQUEST, null)); + param.examples( + createExamplesForMembersWithHttpTraits(context, operation, binding, MessageType.REQUEST, null)); result.add(param.build()); } @@ -496,7 +525,8 @@ private Map createHeaderParameters( param.in(null).name(null); } - param.examples(createExamplesForMembersWithHttpTraits(operationOrError, binding, messageType, operation)); + param.examples( + createExamplesForMembersWithHttpTraits(context, operationOrError, binding, messageType, operation)); // Create the appropriate schema based on the shape type. Shape target = context.getModel().expectShape(member.getTarget()); @@ -566,6 +596,7 @@ private Optional createRequestPayload( return shapeName + "InputPayload"; }).toBuilder() .examples(createExamplesForMembersWithHttpTraits( + context, operation, binding, MessageType.REQUEST, @@ -598,7 +629,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. @@ -757,6 +788,7 @@ private void createResponsePayload( : shapeName + "ErrorPayload"; }).toBuilder() .examples(createExamplesForMembersWithHttpTraits( + context, operationOrError, binding, type, @@ -840,7 +872,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/main/java/software/amazon/smithy/openapi/fromsmithy/protocols/AwsRestJson1Protocol.java b/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/protocols/AwsRestJson1Protocol.java index bbde5e40444..3f84277acac 100644 --- a/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/protocols/AwsRestJson1Protocol.java +++ b/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/protocols/AwsRestJson1Protocol.java @@ -166,7 +166,7 @@ private boolean hasSingleUnionMember(StructureShape shape, Model model) { } @Override - Node transformSmithyValueToProtocolValue(Node value) { - return value; + Node transformSmithyValueToProtocolValue(Context context, Shape shape, Node value) { + return value.accept(new JsonValueNodeTransformer(context, shape)); } } diff --git a/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/protocols/JsonValueNodeTransformer.java b/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/protocols/JsonValueNodeTransformer.java new file mode 100644 index 00000000000..62f4df56ed2 --- /dev/null +++ b/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/protocols/JsonValueNodeTransformer.java @@ -0,0 +1,121 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.openapi.fromsmithy.protocols; + +import java.util.Map; +import software.amazon.smithy.model.node.ArrayNode; +import software.amazon.smithy.model.node.BooleanNode; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.NodeVisitor; +import software.amazon.smithy.model.node.NullNode; +import software.amazon.smithy.model.node.NumberNode; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.node.StringNode; +import software.amazon.smithy.model.shapes.MapShape; +import software.amazon.smithy.model.shapes.MemberShape; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.traits.JsonNameTrait; +import software.amazon.smithy.openapi.fromsmithy.Context; + +/** + * Applies the jsonName trait to a node value if applicable. + */ +public class JsonValueNodeTransformer implements NodeVisitor { + private final Context context; + private final Shape shape; + + /** + * Construct a JsonValueNodeTransformer. + * + * @param context Conversion context. Used to determine if jsonName should be used. + * @param shape The shape of the node being converted. + */ + public JsonValueNodeTransformer(Context context, Shape shape) { + this.context = context; + this.shape = shape; + } + + @Override + public Node booleanNode(BooleanNode node) { + return node; + } + + @Override + public Node nullNode(NullNode node) { + return node; + } + + @Override + public Node numberNode(NumberNode node) { + return node; + } + + @Override + public Node stringNode(StringNode node) { + return node; + } + + @Override + public Node arrayNode(ArrayNode node) { + ArrayNode.Builder resultBuilder = ArrayNode.builder(); + Shape listShape = shape.asMemberShape() + .map(m -> context.getModel().expectShape(m.getTarget())) + .orElse(shape); + + Shape target = context.getModel().expectShape(listShape.asListShape().get().getMember().getTarget()); + JsonValueNodeTransformer elementTransformer = new JsonValueNodeTransformer(context, target); + for (Node element : node.getElements()) { + resultBuilder.withValue(element.accept(elementTransformer)); + } + return resultBuilder.build(); + } + + @Override + public Node objectNode(ObjectNode node) { + Shape actual = shape.asMemberShape() + .map(m -> context.getModel().expectShape(m.getTarget())) + .orElse(shape); + + if (shape.isMapShape()) { + return mapNode(actual.asMapShape().get(), node); + } + return structuredNode(actual, node); + } + + private Node structuredNode(Shape structure, ObjectNode node) { + ObjectNode.Builder resultBuilder = ObjectNode.builder(); + for (Map.Entry entry : node.getMembers().entrySet()) { + String key = entry.getKey().getValue(); + if (structure.getMember(key).isPresent()) { + MemberShape member = structure.getMember(key).get(); + Shape target = context.getModel().expectShape(member.getTarget()); + JsonValueNodeTransformer entryTransformer = new JsonValueNodeTransformer(context, target); + resultBuilder.withMember(getKey(member), entry.getValue().accept(entryTransformer)); + } else { + resultBuilder.withMember(key, entry.getValue()); + } + } + return resultBuilder.build(); + } + + private String getKey(MemberShape member) { + if (!context.getJsonSchemaConverter().getConfig().getUseJsonName()) { + return member.getMemberName(); + } + return member.getTrait(JsonNameTrait.class) + .map(JsonNameTrait::getValue) + .orElse(member.getMemberName()); + } + + private Node mapNode(MapShape map, ObjectNode node) { + ObjectNode.Builder resultBuilder = ObjectNode.builder(); + Shape target = context.getModel().expectShape(map.getValue().getTarget()); + JsonValueNodeTransformer entryTransformer = new JsonValueNodeTransformer(context, target); + for (Map.Entry entry : node.getMembers().entrySet()) { + resultBuilder.withMember(entry.getKey(), entry.getValue().accept(entryTransformer)); + } + return resultBuilder.build(); + } +} diff --git a/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/protocols/examples-test.openapi.json b/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/protocols/examples-test.openapi.json index 4e2350e584e..9d4d7413fac 100644 --- a/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/protocols/examples-test.openapi.json +++ b/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/protocols/examples-test.openapi.json @@ -149,7 +149,7 @@ "description": "withdrawTestDoc", "value": { "location": "Denver", - "bankName": "Chase", + "bank": "Chase", "atmRecording": "dGVzdHZpZGVv" } } @@ -470,7 +470,7 @@ "location": { "type": "string" }, - "bankName": { + "bank": { "type": "string" }, "atmRecording": { diff --git a/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/protocols/examples-test.smithy b/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/protocols/examples-test.smithy index b32459f4b5c..5bd61b88a56 100644 --- a/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/protocols/examples-test.smithy +++ b/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/protocols/examples-test.smithy @@ -1,59 +1,92 @@ +$version: "2" + namespace smithy.examplestrait use aws.protocols#restJson1 @restJson1 service Banking { - version: "2022-06-26", - operations: [Deposit, Withdraw] + version: "2022-06-26" + operations: [ + Deposit + Withdraw + ] } @idempotent @http(method: "PUT", uri: "/account/{username}", code: 200) operation Deposit { - input: DepositInput, - output: DepositOutput, - errors: [InvalidUsername, InvalidAmount] + input := { + @httpHeader("accountNumber") + accountNumber: String + + @required + @httpLabel + username: String + + @httpQuery("accountHistory") + accountHistory: ExampleList + + @httpPayload + depositAmount: String + } + + output := { + @httpHeader("username") + username: String + + @httpHeader("authenticationResult") + authenticationResult: ExampleList + + textMessage: String + + emailMessage: String + } + + errors: [ + InvalidUsername + InvalidAmount + ] } @idempotent @http(method: "PATCH", uri: "/account/withdraw", code: 200) operation Withdraw { - input: WithdrawInput, - output: WithdrawOutput, - errors: [InvalidUsername] -} + input := { + @httpHeader("accountNumber") + accountNumber: String -@input -structure DepositInput { - @httpHeader("accountNumber") - accountNumber: String, + @httpHeader("username") + username: String - @required - @httpLabel - username: String, + @httpQueryParams + withdrawParams: ExampleMap - @httpQuery("accountHistory") - accountHistory: ExampleList, + time: date - @httpPayload - depositAmount: String -} + withdrawAmount: String -@input -structure WithdrawInput { - @httpHeader("accountNumber") - accountNumber: String, + withdrawOption: String + } - @httpHeader("username") - username: String, + output := { + @httpHeader("branch") + branch: String - @httpQueryParams() - withdrawParams: ExampleMap, + @httpHeader("result") + accountHistory: ExampleList - time: date, - withdrawAmount: String, - withdrawOption: String + location: String + + @jsonName("bank") + bankName: String + + atmRecording: exampleVideo + } + + errors: [ + InvalidUsername + ] } list ExampleList { @@ -61,7 +94,7 @@ list ExampleList { } map ExampleMap { - key: String, + key: String value: String } @@ -71,35 +104,10 @@ blob exampleVideo @timestampFormat("http-date") timestamp date -@output -structure DepositOutput { - @httpHeader("username") - username: String, - - @httpHeader("authenticationResult") - authenticationResult: ExampleList, - - textMessage: String, - emailMessage: String -} - -@output -structure WithdrawOutput { - @httpHeader("branch") - branch: String, - - @httpHeader("result") - accountHistory: ExampleList, - - location: String, - bankName: String, - atmRecording: exampleVideo -} - @error("client") structure InvalidUsername { @httpHeader("internalErrorCode") - internalErrorCode: String, + internalErrorCode: String @httpPayload errorMessage: String @@ -107,107 +115,90 @@ structure InvalidUsername { @error("server") structure InvalidAmount { - errorMessage1: String, - errorMessage2: String, + errorMessage1: String + errorMessage2: String errorMessage3: String } -apply Deposit @examples( - [ - { - title: "Deposit valid example", - documentation: "depositTestDoc", - input: { - accountNumber: "102935", - username: "sichanyoo", - accountHistory: ["10", "-25", "50"], - depositAmount: "200" - }, - output: { - username: "sichanyoo", - authenticationResult: ["pass1", "pass2", "pass3"], - textMessage: "You deposited 200-text", - emailMessage: "You deposited 200-email" - }, - }, - - { - title: "Deposit invalid username example", - documentation: "depositTestDoc2", - input: { - username: "sichanyoo", - accountHistory: ["-200", "200", "10"], - depositAmount: "-200" - }, - error: { - shapeId: InvalidUsername, - content: { - internalErrorCode: "4gsw2-34", - errorMessage: "ERROR: Invalid username." - } - }, - }, - - { - title: "Deposit invalid amount example", - documentation: "depositTestDoc3", - input: { - accountNumber: "203952", - username: "obidos", - accountHistory: ["2000", "50000", "100"], - depositAmount: "-100" - }, - error: { - shapeId: InvalidAmount, - content: { - errorMessage1: "ERROR: Invalid amount.", - errorMessage2: "2gdx4-34", - errorMessage3: "2gcbe-98" - } - }, +apply Deposit @examples([ + { + title: "Deposit valid example" + documentation: "depositTestDoc" + input: { + accountNumber: "102935" + username: "sichanyoo" + accountHistory: ["10", "-25", "50"] + depositAmount: "200" } - ] -) - -apply Withdraw @examples( - [ - { - title: "Withdraw valid example", - documentation: "withdrawTestDoc", - input: { - accountNumber: "124634", - username: "amazon", - withdrawParams: {"location" : "Denver", "bankName" : "Chase"}, - time: "Tue, 29 Apr 2014 18:30:38 GMT", - withdrawAmount: "-35", - withdrawOption: "ATM" - }, - output: { - branch: "Denver-203", - accountHistory: ["34", "5", "-250"], - location: "Denver", - bankName: "Chase", - atmRecording: "dGVzdHZpZGVv" - }, - }, - - { - title: "Withdraw invalid username example", - documentation: "withdrawTestDoc2", - input: { - accountNumber: "231565", - username: "peccy", - withdrawParams: {"location" : "Seoul", "bankName" : "Chase"}, - withdrawAmount: "-450", - withdrawOption: "Venmo" - }, - error: { - shapeId: InvalidUsername, - content: { - internalErrorCode: "8dfws-21", - errorMessage: "ERROR: Invalid username." - } - }, + output: { + username: "sichanyoo" + authenticationResult: ["pass1", "pass2", "pass3"] + textMessage: "You deposited 200-text" + emailMessage: "You deposited 200-email" } - ] -) + } + { + title: "Deposit invalid username example" + documentation: "depositTestDoc2" + input: { + username: "sichanyoo" + accountHistory: ["-200", "200", "10"] + depositAmount: "-200" + } + error: { + shapeId: InvalidUsername + content: { internalErrorCode: "4gsw2-34", errorMessage: "ERROR: Invalid username." } + } + } + { + title: "Deposit invalid amount example" + documentation: "depositTestDoc3" + input: { + accountNumber: "203952" + username: "obidos" + accountHistory: ["2000", "50000", "100"] + depositAmount: "-100" + } + error: { + shapeId: InvalidAmount + content: { errorMessage1: "ERROR: Invalid amount.", errorMessage2: "2gdx4-34", errorMessage3: "2gcbe-98" } + } + } +]) + +apply Withdraw @examples([ + { + title: "Withdraw valid example" + documentation: "withdrawTestDoc" + input: { + accountNumber: "124634" + username: "amazon" + withdrawParams: { location: "Denver", bankName: "Chase" } + time: "Tue, 29 Apr 2014 18:30:38 GMT" + withdrawAmount: "-35" + withdrawOption: "ATM" + } + output: { + branch: "Denver-203" + accountHistory: ["34", "5", "-250"] + location: "Denver" + bankName: "Chase" + atmRecording: "dGVzdHZpZGVv" + } + } + { + title: "Withdraw invalid username example" + documentation: "withdrawTestDoc2" + input: { + accountNumber: "231565" + username: "peccy" + withdrawParams: { location: "Seoul", bankName: "Chase" } + withdrawAmount: "-450" + withdrawOption: "Venmo" + } + error: { + shapeId: InvalidUsername + content: { internalErrorCode: "8dfws-21", errorMessage: "ERROR: Invalid username." } + } + } +])