diff --git a/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/AddProtocols.java b/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/AddProtocols.java index dcd0b3147a76..a8d82a4fac57 100644 --- a/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/AddProtocols.java +++ b/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/AddProtocols.java @@ -28,6 +28,6 @@ public class AddProtocols implements TypeScriptIntegration { @Override public List getProtocolGenerators() { return ListUtils.of(new AwsRestJson1_1(), new AwsJsonRpc1_0(), new AwsJsonRpc1_1(), - new AwsRestXml(), new AwsQuery()); + new AwsRestXml(), new AwsQuery(), new AwsEc2()); } } diff --git a/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/AwsEc2.java b/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/AwsEc2.java new file mode 100644 index 000000000000..f421e9f5305d --- /dev/null +++ b/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/AwsEc2.java @@ -0,0 +1,150 @@ +/* + * Copyright 2019 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.smithy.aws.typescript.codegen; + +import java.util.Set; +import software.amazon.smithy.codegen.core.SymbolReference; +import software.amazon.smithy.model.shapes.OperationShape; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.StructureShape; +import software.amazon.smithy.model.traits.TimestampFormatTrait.Format; +import software.amazon.smithy.typescript.codegen.TypeScriptWriter; +import software.amazon.smithy.typescript.codegen.integration.HttpRpcProtocolGenerator; + +/** + * Handles generating the aws.ec2 protocol for services. It handles reading and + * writing from document bodies, including generating any functions needed for + * performing serde. + * + * This builds on the foundations of the {@link HttpRpcProtocolGenerator} to handle + * standard components of the HTTP requests and responses. + * + * @see Ec2ShapeSerVisitor + * @see XmlShapeDeserVisitor + * @see QueryMemberSerVisitor + * @see XmlMemberDeserVisitor + * @see AwsProtocolUtils + * @see Smithy XML traits. + * @see Smithy EC2 Query Name trait. + */ +final class AwsEc2 extends HttpRpcProtocolGenerator { + + AwsEc2() { + super(true); + } + + @Override + protected String getOperationPath(GenerationContext context, OperationShape operationShape) { + return "/"; + } + + @Override + public String getName() { + return "aws.ec2"; + } + + @Override + protected String getDocumentContentType() { + return "application/x-www-form-urlencoded"; + } + + @Override + protected void generateDocumentBodyShapeSerializers(GenerationContext context, Set shapes) { + AwsProtocolUtils.generateDocumentBodyShapeSerde(context, shapes, new Ec2ShapeSerVisitor(context)); + } + + @Override + protected void generateDocumentBodyShapeDeserializers(GenerationContext context, Set shapes) { + AwsProtocolUtils.generateDocumentBodyShapeSerde(context, shapes, new XmlShapeDeserVisitor(context)); + } + + @Override + public void generateSharedComponents(GenerationContext context) { + super.generateSharedComponents(context); + AwsProtocolUtils.generateXmlParseBody(context); + AwsProtocolUtils.generateBuildFormUrlencodedString(context); + + TypeScriptWriter writer = context.getWriter(); + + // Generate a function that handles the complex rules around deserializing + // an error code from an xml error body. + SymbolReference responseType = getApplicationProtocol().getResponseType(); + writer.openBlock("const loadEc2ErrorCode = (\n" + + " output: $T,\n" + + " data: any\n" + + "): string => {", "};", responseType, () -> { + + // Attempt to fetch the error code from the specific location, including the wrapper. + writer.openBlock("if (data.Errors.Error.Code !== undefined) {", "}", () -> { + writer.write("return data.Errors.Error.Code;"); + }); + + // Default a 404 status code to the NotFound code. + writer.openBlock("if (output.statusCode == 404) {", "}", () -> writer.write("return 'NotFound';")); + + // Default to an empty error code so an unmodeled exception is built. + writer.write("return '';"); + }); + writer.write(""); + } + + @Override + protected void writeDefaultHeaders(GenerationContext context, OperationShape operation) { + super.writeDefaultHeaders(context, operation); + AwsProtocolUtils.generateUnsignedPayloadSigV4Header(context, operation); + } + + @Override + protected void serializeInputDocument( + GenerationContext context, + OperationShape operation, + StructureShape inputStructure + ) { + TypeScriptWriter writer = context.getWriter(); + + // Gather all the explicit input entries. + writer.write("const entries = $L;", + inputStructure.accept(new QueryMemberSerVisitor(context, "input", Format.DATE_TIME))); + + // Set the form encoded string. + writer.openBlock("body = buildFormUrlencodedString({", "});", () -> { + writer.write("...entries,"); + // Set the protocol required values. + writer.write("Action: $S,", operation.getId().getName()); + writer.write("Version: $S,", context.getService().getVersion()); + }); + } + + @Override + protected void writeErrorCodeParser(GenerationContext context) { + TypeScriptWriter writer = context.getWriter(); + + // Outsource error code parsing since it's complex for this protocol. + writer.write("errorCode = loadEc2ErrorCode(output, parsedOutput.body);"); + } + + @Override + protected void deserializeOutputDocument( + GenerationContext context, + OperationShape operation, + StructureShape outputStructure + ) { + TypeScriptWriter writer = context.getWriter(); + + writer.write("contents = $L;", + outputStructure.accept(new XmlMemberDeserVisitor(context, "data", Format.DATE_TIME))); + } +} diff --git a/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/AwsProtocolUtils.java b/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/AwsProtocolUtils.java index 802cdc65833b..117ec8dbc9ea 100644 --- a/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/AwsProtocolUtils.java +++ b/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/AwsProtocolUtils.java @@ -120,6 +120,23 @@ static void generateXmlParseBody(GenerationContext context) { writer.write(""); } + /** + * Writes a form urlencoded string builder function for query based protocols. + * This will escape the keys and values, combine those with an '=', and combine + * those strings with an '&'. + * + * @param context The generation context. + */ + static void generateBuildFormUrlencodedString(GenerationContext context) { + TypeScriptWriter writer = context.getWriter(); + + // Write a single function to handle combining a map in to a valid query string. + writer.openBlock("const buildFormUrlencodedString = (entries: any): string => {", "}", () -> { + writer.openBlock("return Object.keys(entries).map(", ").join(\"&\");", () -> + writer.write("key => encodeURIComponent(key) + '=' + encodeURIComponent(entries[key])")); + }); + } + /** * Writes an attribute containing information about a Shape's optionally specified * XML namespace configuration to an attribute of the passed node name. diff --git a/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/AwsQuery.java b/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/AwsQuery.java index b1ee65f74893..6e11f7f804bb 100644 --- a/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/AwsQuery.java +++ b/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/AwsQuery.java @@ -75,6 +75,7 @@ protected void generateDocumentBodyShapeDeserializers(GenerationContext context, public void generateSharedComponents(GenerationContext context) { super.generateSharedComponents(context); AwsProtocolUtils.generateXmlParseBody(context); + AwsProtocolUtils.generateBuildFormUrlencodedString(context); TypeScriptWriter writer = context.getWriter(); @@ -94,15 +95,10 @@ public void generateSharedComponents(GenerationContext context) { // Default a 404 status code to the NotFound code. writer.openBlock("if (output.statusCode == 404) {", "}", () -> writer.write("return 'NotFound';")); - // Default to an UnknownError code. - writer.write("return 'UnknownError';"); + // Default to an empty error code so an unmodeled exception is built. + writer.write("return '';"); }); writer.write(""); - - // Write a single function to handle combining a map in to a valid query string. - writer.openBlock("const buildFormUrlencodedString = (entries: any): string => {", "}", () -> { - writer.write("return Object.keys(entries).map(key => key + '=' + entries[key]).join(\"&\");"); - }); } @Override diff --git a/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/AwsRestXml.java b/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/AwsRestXml.java index 4fe4db74f8ce..e5f2f2e59676 100644 --- a/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/AwsRestXml.java +++ b/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/AwsRestXml.java @@ -116,8 +116,8 @@ public void generateSharedComponents(GenerationContext context) { // Default a 404 status code to the NotFound code. writer.openBlock("if (output.statusCode == 404) {", "}", () -> writer.write("return 'NotFound';")); - // Default to an UnknownError code. - writer.write("return 'UnknownError';"); + // Default to an empty error code so an unmodeled exception is built. + writer.write("return '';"); }); writer.write(""); } diff --git a/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/Ec2ShapeSerVisitor.java b/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/Ec2ShapeSerVisitor.java new file mode 100644 index 000000000000..dcf5852b00e8 --- /dev/null +++ b/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/Ec2ShapeSerVisitor.java @@ -0,0 +1,80 @@ +/* + * Copyright 2019 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.smithy.aws.typescript.codegen; + +import java.util.Optional; +import software.amazon.smithy.aws.traits.Ec2QueryNameTrait; +import software.amazon.smithy.codegen.core.CodegenException; +import software.amazon.smithy.model.shapes.DocumentShape; +import software.amazon.smithy.model.shapes.MemberShape; +import software.amazon.smithy.model.traits.TimestampFormatTrait.Format; +import software.amazon.smithy.model.traits.XmlNameTrait; +import software.amazon.smithy.typescript.codegen.integration.ProtocolGenerator.GenerationContext; +import software.amazon.smithy.utils.StringUtils; + +/** + * Visitor to generate serialization functions for shapes in form-urlencoded + * based document bodies specific to aws.ec2. + * + * This class uses the implementations provided by {@code QueryShapeSerVisitor} but with + * the following protocol specific customizations for aws.ec2: + * + *
    + *
  • aws.ec2 flattens all lists, sets, and maps regardless of the {@code @xmlFlattened} trait.
  • + *
  • aws.ec2 respects the {@code @ec2QueryName} trait, then the {@code xmlName} + * trait value with the first letter capitalized.
  • + *
+ * + * Timestamps are serialized to {@link Format}.DATE_TIME by default. + * + * @see AwsEc2 + * @see QueryShapeSerVisitor + * @see Smithy EC2 Query Name trait. + */ +final class Ec2ShapeSerVisitor extends QueryShapeSerVisitor { + + Ec2ShapeSerVisitor(GenerationContext context) { + super(context); + } + + @Override + protected void serializeDocument(GenerationContext context, DocumentShape shape) { + throw new CodegenException(String.format( + "Cannot serialize Document types in the aws.ec2 protocol, shape: %s.", shape.getId())); + } + + @Override + protected String getMemberSerializedLocationName(MemberShape memberShape, String defaultValue) { + // The serialization for aws.ec2 prioritizes the @ec2QueryName trait for serialization. + Optional trait = memberShape.getTrait(Ec2QueryNameTrait.class); + if (trait.isPresent()) { + return trait.get().getValue(); + } + + // Fall back to the capitalized @xmlName trait if present on the member, + // otherwise use the capitalized default value. + return memberShape.getTrait(XmlNameTrait.class) + .map(XmlNameTrait::getValue) + .map(StringUtils::capitalize) + .orElseGet(() -> StringUtils.capitalize(defaultValue)); + } + + @Override + protected boolean isFlattenedMember(MemberShape memberShape) { + // All lists, sets, and maps are flattened in aws.ec2. + return true; + } +} diff --git a/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/QueryShapeSerVisitor.java b/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/QueryShapeSerVisitor.java index 61c3d5fc564c..64fe1abb31b7 100644 --- a/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/QueryShapeSerVisitor.java +++ b/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/QueryShapeSerVisitor.java @@ -41,7 +41,7 @@ * * Timestamps are serialized to {@link Format}.DATE_TIME by default. */ -final class QueryShapeSerVisitor extends DocumentShapeSerVisitor { +class QueryShapeSerVisitor extends DocumentShapeSerVisitor { QueryShapeSerVisitor(GenerationContext context) { super(context); @@ -58,9 +58,7 @@ protected void serializeCollection(GenerationContext context, CollectionShape sh Shape target = context.getModel().expectShape(memberShape.getTarget()); // Use the @xmlName trait if present on the member, otherwise use "member". - String locationName = memberShape.getTrait(XmlNameTrait.class) - .map(XmlNameTrait::getValue) - .orElse("member"); + String locationName = getMemberSerializedLocationName(memberShape, "member"); // Set up a location to store all of the entry pairs. writer.write("const entries: any = {};"); @@ -68,8 +66,7 @@ protected void serializeCollection(GenerationContext context, CollectionShape sh writer.write("let counter = 1;"); // Dispatch to the input value provider for any additional handling. writer.openBlock("(input || []).map(entry => {", "});", () -> { - writer.write("const loc: string = \"$L.\" + counter;", locationName); - writer.write("entries[loc] = $L;", target.accept(getMemberVisitor("entry"))); + serializeUnnamedMemberEntryList(context, target, "entry", locationName); writer.write("counter++;"); }); @@ -79,7 +76,7 @@ protected void serializeCollection(GenerationContext context, CollectionShape sh @Override protected void serializeDocument(GenerationContext context, DocumentShape shape) { throw new CodegenException(String.format( - "Cannot serialize Document types on AWS Query protocols, shape: %s.", shape.getId())); + "Cannot serialize Document types in the aws.query protocol, shape: %s.", shape.getId())); } @Override @@ -97,29 +94,42 @@ protected void serializeMap(GenerationContext context, MapShape shape) { // Use the @xmlName trait if present on the member, otherwise use "key". MemberShape keyMember = shape.getKey(); Shape keyTarget = model.expectShape(keyMember.getTarget()); - String keyName = keyMember.getTrait(XmlNameTrait.class) - .map(XmlNameTrait::getValue) - .orElse("key"); - writer.write("const keyLoc: string = \"entry.$L.\" + counter;", keyName); - writer.write("entries[keyLoc] = $L;", keyTarget.accept(getMemberVisitor("key"))); + String keyName = getMemberSerializedLocationName(keyMember, "key"); + writer.write("entries[`entry.$L.$${counter}`] = $L;", keyName, keyTarget.accept(getMemberVisitor("key"))); // Prepare the value's entry. // Use the @xmlName trait if present on the member, otherwise use "value". MemberShape valueMember = shape.getValue(); Shape valueTarget = model.expectShape(valueMember.getTarget()); - String valueName = valueMember.getTrait(XmlNameTrait.class) - .map(XmlNameTrait::getValue) - .orElse("value"); - writer.write("const valueLoc: string = \"entry.$L.\" + counter;", valueName); - writer.write("entries[valueLoc] = $L;", valueTarget.accept(getMemberVisitor("input[key]"))); - + String valueName = getMemberSerializedLocationName(valueMember, "value"); + serializeUnnamedMemberEntryList(context, valueTarget, "input[key]", "entry." + valueName); writer.write("counter++;"); }); writer.write("return entries;"); } + private void serializeUnnamedMemberEntryList( + GenerationContext context, + Shape target, + String inputLocation, + String entryWrapper + ) { + TypeScriptWriter writer = context.getWriter(); + + QueryMemberSerVisitor inputVisitor = getMemberVisitor(inputLocation); + // Map entries that supply entry lists need to have them joined properly. + if (inputVisitor.visitSuppliesEntryList(target)) { + writer.write("const memberEntries = $L;", target.accept(inputVisitor)); + // Expand each of the member entries in to the correct location. + writer.openBlock("Object.keys(memberEntries).forEach(key => {", "});", + () -> writer.write("entries[`$L.$${counter}.$${key}`] = memberEntries[key];", entryWrapper)); + } else { + writer.write("entries[`$L.$${counter}`] = $L;", entryWrapper, target.accept(inputVisitor)); + } + } + @Override protected void serializeStructure(GenerationContext context, StructureShape shape) { TypeScriptWriter writer = context.getWriter(); @@ -147,10 +157,7 @@ private void serializeNamedMember( // Grab the target shape so we can use a member serializer on it. Shape target = context.getModel().expectShape(memberShape.getTarget()); - // Use the @xmlName trait if present on the member, otherwise use the member name. - String locationName = memberShape.getTrait(XmlNameTrait.class) - .map(XmlNameTrait::getValue) - .orElse(memberName); + String locationName = getMemberSerializedLocationName(memberShape, memberName); QueryMemberSerVisitor inputVisitor = getMemberVisitor(inputLocation); if (inputVisitor.visitSuppliesEntryList(target)) { @@ -160,6 +167,21 @@ private void serializeNamedMember( } } + /** + * Retrieves the correct serialization location based on the member's + * xmlName trait or uses the default value. + * + * @param memberShape The member being serialized. + * @param defaultValue A default value for the location. + * @return The location where the member will be serialized. + */ + protected String getMemberSerializedLocationName(MemberShape memberShape, String defaultValue) { + // Use the @xmlName trait if present on the member, otherwise use the member name. + return memberShape.getTrait(XmlNameTrait.class) + .map(XmlNameTrait::getValue) + .orElse(defaultValue); + } + private void serializeNamedMemberEntryList( GenerationContext context, String locationName, @@ -169,8 +191,8 @@ private void serializeNamedMemberEntryList( ) { TypeScriptWriter writer = context.getWriter(); - // Handle @xmlFlattened for collections and maps. - boolean isFlattened = memberShape.hasTrait(XmlFlattenedTrait.class); + // Handle flattening for collections and maps. + boolean isFlattened = isFlattenedMember(memberShape); // Set up a location to store all of the entry pairs. writer.write("const memberEntries = $L;", target.accept(inputVisitor)); @@ -179,15 +201,27 @@ private void serializeNamedMemberEntryList( writer.openBlock("Object.keys(memberEntries).forEach(key => {", "});", () -> { // Remove the last segment for any flattened entities. if (isFlattened) { - writer.write("const loc = \"$L.\" + key.substring(key.indexOf('.') + 1);", locationName); + writer.write("const loc = `$L.$${key.substring(key.indexOf('.') + 1)}`;", locationName); } else { - writer.write("const loc = \"$L.\" + key;", locationName); + writer.write("const loc = `$L.$${key}`;", locationName); } writer.write("entries[loc] = memberEntries[key];"); }); } + /** + * Tells whether the contents of the member should be flattened + * when serialized. + * + * @param memberShape The member being serialized. + * @return If the member's contents should be flattened when serialized. + */ + protected boolean isFlattenedMember(MemberShape memberShape) { + // The @xmlFlattened trait determines the flattening of members in aws.query. + return memberShape.hasTrait(XmlFlattenedTrait.class); + } + @Override protected void serializeUnion(GenerationContext context, UnionShape shape) { TypeScriptWriter writer = context.getWriter();