From 6f81a0e4e1cbc7074139fe55e0d46d0c488c1d00 Mon Sep 17 00:00:00 2001 From: George Fu Date: Wed, 5 Apr 2023 21:26:08 +0000 Subject: [PATCH] use serde helper function & serde shortcuts --- .../DocumentMemberDeserVisitor.java | 9 + .../integration/DocumentMemberSerVisitor.java | 9 + .../DocumentShapeDeserVisitor.java | 21 +- .../integration/DocumentShapeSerVisitor.java | 19 +- .../integration/EventStreamGenerator.java | 31 ++- .../HttpBindingProtocolGenerator.java | 39 +++- .../HttpProtocolGeneratorUtils.java | 8 +- .../integration/HttpRpcProtocolGenerator.java | 30 ++- .../codegen/validation/SerdeElision.java | 208 ++++++++++++++++++ .../codegen/validation/UnaryFunctionCall.java | 61 +++++ .../codegen/validation/SerdeElisionTest.java | 130 +++++++++++ .../validation/UnaryFunctionCallTest.java | 36 +++ 12 files changed, 571 insertions(+), 30 deletions(-) create mode 100644 smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/validation/SerdeElision.java create mode 100644 smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/validation/UnaryFunctionCall.java create mode 100644 smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/validation/SerdeElisionTest.java create mode 100644 smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/validation/UnaryFunctionCallTest.java diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/DocumentMemberDeserVisitor.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/DocumentMemberDeserVisitor.java index 26e2adaeffc..75ab5a96b97 100644 --- a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/DocumentMemberDeserVisitor.java +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/DocumentMemberDeserVisitor.java @@ -45,6 +45,7 @@ import software.amazon.smithy.model.shapes.UnionShape; import software.amazon.smithy.model.traits.TimestampFormatTrait.Format; import software.amazon.smithy.typescript.codegen.integration.ProtocolGenerator.GenerationContext; +import software.amazon.smithy.typescript.codegen.validation.SerdeElision; import software.amazon.smithy.utils.SmithyUnstableApi; /** @@ -66,6 +67,7 @@ */ @SmithyUnstableApi public class DocumentMemberDeserVisitor implements ShapeVisitor { + protected final SerdeElision serdeElision; private final GenerationContext context; private final String dataSource; private final Format defaultTimestampFormat; @@ -87,6 +89,8 @@ public DocumentMemberDeserVisitor( this.context = context; this.dataSource = dataSource; this.defaultTimestampFormat = defaultTimestampFormat; + this.serdeElision = SerdeElision.forModel(context.getModel()) + .setEnabledForModel(false); } /** @@ -283,6 +287,11 @@ private String getDelegateDeserializer(Shape shape) { private String getDelegateDeserializer(Shape shape, String customDataSource) { // Use the shape for the function name. Symbol symbol = context.getSymbolProvider().toSymbol(shape); + + if (serdeElision.mayElide(shape)) { + return "_json(" + customDataSource + ")"; + } + return ProtocolGenerator.getDeserFunctionShortName(symbol) + "(" + customDataSource + ", context)"; } diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/DocumentMemberSerVisitor.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/DocumentMemberSerVisitor.java index a131712c1bd..25c38f6fbd8 100644 --- a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/DocumentMemberSerVisitor.java +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/DocumentMemberSerVisitor.java @@ -45,6 +45,7 @@ import software.amazon.smithy.model.shapes.UnionShape; import software.amazon.smithy.model.traits.TimestampFormatTrait.Format; import software.amazon.smithy.typescript.codegen.integration.ProtocolGenerator.GenerationContext; +import software.amazon.smithy.typescript.codegen.validation.SerdeElision; import software.amazon.smithy.utils.SmithyUnstableApi; /** @@ -65,6 +66,7 @@ */ @SmithyUnstableApi public class DocumentMemberSerVisitor implements ShapeVisitor { + protected final SerdeElision serdeElision; private final GenerationContext context; private final String dataSource; private final Format defaultTimestampFormat; @@ -86,6 +88,8 @@ public DocumentMemberSerVisitor( this.context = context; this.dataSource = dataSource; this.defaultTimestampFormat = defaultTimestampFormat; + this.serdeElision = SerdeElision.forModel(context.getModel()) + .setEnabledForModel(false); } /** @@ -252,6 +256,11 @@ public final String unionShape(UnionShape shape) { private String getDelegateSerializer(Shape shape) { // Use the shape for the function name. Symbol symbol = context.getSymbolProvider().toSymbol(shape); + + if (serdeElision.mayElide(shape)) { + return "_json(" + dataSource + ")"; + } + return ProtocolGenerator.getSerFunctionShortName(symbol) + "(" + dataSource + ", context)"; } diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/DocumentShapeDeserVisitor.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/DocumentShapeDeserVisitor.java index f89f7b03465..9a1fe824a97 100644 --- a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/DocumentShapeDeserVisitor.java +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/DocumentShapeDeserVisitor.java @@ -34,6 +34,7 @@ import software.amazon.smithy.model.shapes.UnionShape; import software.amazon.smithy.typescript.codegen.TypeScriptWriter; import software.amazon.smithy.typescript.codegen.integration.ProtocolGenerator.GenerationContext; +import software.amazon.smithy.typescript.codegen.validation.SerdeElision; import software.amazon.smithy.utils.SmithyUnstableApi; /** @@ -305,13 +306,19 @@ protected final void generateDeserFunction( String methodLongName = ProtocolGenerator.getDeserFunctionName(symbol, context.getProtocolName()); - writer.addImport(symbol, symbol.getName()); - writer.writeDocs(methodLongName); - writer.openBlock("const $L = (\n" - + " output: any,\n" - + " context: __SerdeContext\n" - + "): $T => {", "}", methodName, symbol, () -> functionBody.accept(context, shape)); - writer.write(""); + boolean mayElide = SerdeElision.forModel(context.getModel()).mayElide(shape); + if (mayElide) { + writer.write("// " + methodName + " omitted."); + writer.write(""); + } else { + writer.addImport(symbol, symbol.getName()); + writer.writeDocs(methodLongName); + writer.openBlock("const $L = (\n" + + " output: any,\n" + + " context: __SerdeContext\n" + + "): $T => {", "}", methodName, symbol, () -> functionBody.accept(context, shape)); + writer.write(""); + } } @Override diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/DocumentShapeSerVisitor.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/DocumentShapeSerVisitor.java index 2c8b5b9bcd4..7bb41859834 100644 --- a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/DocumentShapeSerVisitor.java +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/DocumentShapeSerVisitor.java @@ -34,6 +34,7 @@ import software.amazon.smithy.model.shapes.UnionShape; import software.amazon.smithy.typescript.codegen.TypeScriptWriter; import software.amazon.smithy.typescript.codegen.integration.ProtocolGenerator.GenerationContext; +import software.amazon.smithy.typescript.codegen.validation.SerdeElision; import software.amazon.smithy.utils.SmithyUnstableApi; /** @@ -302,12 +303,18 @@ private void generateSerFunction( writer.addImport(symbol, symbol.getName()); - writer.writeDocs(methodLongName); - writer.openBlock("const $L = (\n" - + " input: $T,\n" - + " context: __SerdeContext\n" - + "): any => {", "}", methodName, symbol, () -> functionBody.accept(context, shape)); - writer.write(""); + boolean mayElide = SerdeElision.forModel(context.getModel()).mayElide(shape); + if (mayElide) { + writer.write("// " + methodName + " omitted."); + writer.write(""); + } else { + writer.writeDocs(methodLongName); + writer.openBlock("const $L = (\n" + + " input: $T,\n" + + " context: __SerdeContext\n" + + "): any => {", "}", methodName, symbol, () -> functionBody.accept(context, shape)); + writer.write(""); + } } @Override diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/EventStreamGenerator.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/EventStreamGenerator.java index e4c919855e2..3d68d225149 100644 --- a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/EventStreamGenerator.java +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/EventStreamGenerator.java @@ -42,6 +42,7 @@ import software.amazon.smithy.typescript.codegen.TypeScriptDependency; import software.amazon.smithy.typescript.codegen.TypeScriptWriter; import software.amazon.smithy.typescript.codegen.integration.ProtocolGenerator.GenerationContext; +import software.amazon.smithy.typescript.codegen.validation.SerdeElision; import software.amazon.smithy.utils.SmithyUnstableApi; /** @@ -351,8 +352,13 @@ private void writeEventBody( } else if (payloadShape instanceof BlobShape || payloadShape instanceof StringShape) { Symbol symbol = getSymbol(context, payloadShape); String serFunctionName = ProtocolGenerator.getSerFunctionShortName(symbol); + boolean mayElide = SerdeElision.forModel(context.getModel()).mayElide(payloadShape); documentShapesToSerialize.add(payloadShape); - writer.write("body = $L(input.$L, context);", payloadMemberName, serFunctionName); + if (mayElide) { + writer.write("body = $L(input.$L);", "_json", payloadMemberName); + } else { + writer.write("body = $L(input.$L, context);", serFunctionName, payloadMemberName); + } serializeInputEventDocumentPayload.run(); } else { throw new CodegenException(String.format("Unexpected shape type bound to event payload: `%s`", @@ -369,7 +375,12 @@ private void writeEventBody( Symbol symbol = getSymbol(context, event); String serFunctionName = ProtocolGenerator.getSerFunctionShortName(symbol); documentShapesToSerialize.add(event); - writer.write("body = $L(input, context);", serFunctionName); + boolean mayElide = SerdeElision.forModel(context.getModel()).mayElide(event); + if (mayElide) { + writer.write("body = $L(input);", "_json"); + } else { + writer.write("body = $L(input, context);", serFunctionName); + } serializeInputEventDocumentPayload.run(); } } @@ -496,14 +507,26 @@ private void readEventBody( writer.write("const data: any = await parseBody(output.body, context);"); Symbol symbol = getSymbol(context, payloadShape); String deserFunctionName = ProtocolGenerator.getDeserFunctionShortName(symbol); - writer.write("contents.$L = $L(data, context);", payloadMemberName, deserFunctionName); + boolean mayElide = SerdeElision.forModel(context.getModel()).mayElide(payloadShape); + if (mayElide) { + writer.addImport("_json", null, "@aws-sdk/smithy-client"); + writer.write("contents.$L = $L(data);", payloadMemberName, "_json"); + } else { + writer.write("contents.$L = $L(data, context);", payloadMemberName, deserFunctionName); + } eventShapesToDeserialize.add(payloadShape); } } else { writer.write("const data: any = await parseBody(output.body, context);"); Symbol symbol = getSymbol(context, event); String deserFunctionName = ProtocolGenerator.getDeserFunctionShortName(symbol); - writer.write("Object.assign(contents, $L(data, context));", deserFunctionName); + boolean mayElide = SerdeElision.forModel(context.getModel()).mayElide(event); + if (mayElide) { + writer.addImport("_json", null, "@aws-sdk/smithy-client"); + writer.write("Object.assign(contents, $L(data));", "_json"); + } else { + writer.write("Object.assign(contents, $L(data, context));", deserFunctionName); + } eventShapesToDeserialize.add(event); } } diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/HttpBindingProtocolGenerator.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/HttpBindingProtocolGenerator.java index 8a0371a53bf..a80d3ad5f20 100644 --- a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/HttpBindingProtocolGenerator.java +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/HttpBindingProtocolGenerator.java @@ -75,6 +75,7 @@ import software.amazon.smithy.typescript.codegen.TypeScriptDependency; import software.amazon.smithy.typescript.codegen.TypeScriptWriter; import software.amazon.smithy.typescript.codegen.endpointsV2.RuleSetParameterFinder; +import software.amazon.smithy.typescript.codegen.validation.SerdeElision; import software.amazon.smithy.utils.ListUtils; import software.amazon.smithy.utils.OptionalUtils; import software.amazon.smithy.utils.SetUtils; @@ -162,8 +163,13 @@ public final ApplicationProtocol getApplicationProtocol() { @Override public void generateSharedComponents(GenerationContext context) { TypeScriptWriter writer = context.getWriter(); - writer.addImport("map", "__map", "@aws-sdk/smithy-client"); - writer.write("const map = __map"); + writer.addImport("map", null, "@aws-sdk/smithy-client"); + + if (context.getSettings().generateClient()) { + writer.addImport("withBaseException", null, "@aws-sdk/smithy-client"); + SymbolReference exception = HttpProtocolGeneratorUtils.getClientBaseException(context); + writer.write("const throwDefaultError = withBaseException($T);", exception); + } deserializingErrorShapes.forEach(error -> generateErrorDeserializer(context, error)); serializingErrorShapes.forEach(error -> generateErrorSerializer(context, error)); @@ -1348,6 +1354,15 @@ private String getNamedMembersInputParam( switch (bindingType) { case PAYLOAD: Symbol symbol = context.getSymbolProvider().toSymbol(target); + + boolean mayElideInput = SerdeElision.forModel(context.getModel()) + .setEnabledForModel(enableSerdeElision() && !context.getSettings().generateServerSdk()) + .mayElide(target); + + if (mayElideInput) { + return "_json(" + dataSource + ")"; + } + return ProtocolGenerator.getSerFunctionShortName(symbol) + "(" + dataSource + ", context)"; default: @@ -2088,7 +2103,6 @@ private void generateOperationResponseDeserializer( }); List documentBindings = readResponseBody(context, operation, bindingIndex); - // Track all shapes bound to the document so their deserializers may be generated. documentBindings.forEach(binding -> { Shape target = model.expectShape(binding.getMember().getTarget()); @@ -2674,6 +2688,15 @@ private String getNamedMembersOutputParam( case PAYLOAD: // Redirect to a deserialization function. Symbol symbol = context.getSymbolProvider().toSymbol(target); + + boolean mayElideOutput = SerdeElision.forModel(context.getModel()) + .setEnabledForModel(enableSerdeElision() && !context.getSettings().generateServerSdk()) + .mayElide(target); + + if (mayElideOutput) { + return "_json(" + dataSource + ")"; + } + return ProtocolGenerator.getDeserFunctionShortName(symbol) + "(" + dataSource + ", context)"; default: @@ -2858,4 +2881,14 @@ protected abstract void deserializeErrorDocumentBody( * @return true if this protocol disallows string epoch timestamps in payloads. */ protected abstract boolean requiresNumericEpochSecondsInPayload(); + + /** + * Implement a return true if the protocol allows elision of serde functions + * as defined in {@link SerdeElision}. + * + * @return whether protocol implementation is compatible with serde elision. + */ + protected boolean enableSerdeElision() { + return false; + } } diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/HttpProtocolGeneratorUtils.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/HttpProtocolGeneratorUtils.java index f8acd98ecd9..e0b28aa31fa 100644 --- a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/HttpProtocolGeneratorUtils.java +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/HttpProtocolGeneratorUtils.java @@ -364,7 +364,6 @@ static Set generateErrorDispatcher( } // Error responses must be at least BaseException interface - SymbolReference baseExceptionReference = getClientBaseException(context); errorCodeGenerator.accept(context); Runnable defaultErrorHandler = () -> { @@ -376,18 +375,15 @@ static Set generateErrorDispatcher( writer.write("const parsedBody = await parseBody(output.body, context);"); } - writer.addImport("throwDefaultError", "throwDefaultError", "@aws-sdk/smithy-client"); - // Get the protocol specific error location for retrieving contents. String errorLocation = bodyErrorLocationModifier.apply(context, "parsedBody"); - writer.openBlock("throwDefaultError({", "})", () -> { + writer.openBlock("return throwDefaultError({", "})", () -> { writer.write("output,"); if (errorLocation.equals("parsedBody")) { writer.write("parsedBody,"); } else { writer.write("parsedBody: $L,", errorLocation); } - writer.write("exceptionCtor: $T,", baseExceptionReference); writer.write("errorCode"); }); }; @@ -465,7 +461,7 @@ static void writeHostPrefix(GenerationContext context, OperationShape operation) /** * Construct a symbol reference of client's base exception class. */ - private static SymbolReference getClientBaseException(GenerationContext context) { + public static SymbolReference getClientBaseException(GenerationContext context) { ServiceShape service = context.getService(); SymbolProvider symbolProvider = context.getSymbolProvider(); String serviceExceptionName = symbolProvider.toSymbol(service).getName() diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/HttpRpcProtocolGenerator.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/HttpRpcProtocolGenerator.java index dd0cdea5cba..62268432e6d 100644 --- a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/HttpRpcProtocolGenerator.java +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/HttpRpcProtocolGenerator.java @@ -33,6 +33,7 @@ import software.amazon.smithy.typescript.codegen.CodegenUtils; import software.amazon.smithy.typescript.codegen.TypeScriptDependency; import software.amazon.smithy.typescript.codegen.TypeScriptWriter; +import software.amazon.smithy.typescript.codegen.validation.SerdeElision; import software.amazon.smithy.utils.OptionalUtils; import software.amazon.smithy.utils.SmithyUnstableApi; @@ -129,6 +130,12 @@ public void generateSharedComponents(GenerationContext context) { TypeScriptWriter writer = context.getWriter(); + if (context.getSettings().generateClient()) { + writer.addImport("withBaseException", null, "@aws-sdk/smithy-client"); + SymbolReference exception = HttpProtocolGeneratorUtils.getClientBaseException(context); + writer.write("const throwDefaultError = withBaseException($T);", exception); + } + // Write a function to generate HTTP requests since they're so similar. SymbolReference requestType = getApplicationProtocol().getRequestType(); writer.addUseImports(requestType); @@ -449,7 +456,7 @@ private void generateOperationDeserializer(GenerationContext context, OperationS writer.write("...contents,"); }); }); - writer.write("return Promise.resolve(response);"); + writer.write("return response;"); }); writer.write(""); @@ -487,9 +494,15 @@ private void generateErrorDeserializer(GenerationContext context, StructureShape // the error shape here. writer.write("const body = parseBody($L.body, context);", outputReference); } - writer.write("const deserialized: any = $L($L, context);", - ProtocolGenerator.getDeserFunctionShortName(errorSymbol), - getErrorBodyLocation(context, "body")); + + if (SerdeElision.forModel(context.getModel()).mayElide(error)) { + writer.write("const deserialized: any = _json($L);", + getErrorBodyLocation(context, "body")); + } else { + writer.write("const deserialized: any = $L($L, context);", + ProtocolGenerator.getDeserFunctionShortName(errorSymbol), + getErrorBodyLocation(context, "body")); + } // Then load it into the object with additional error and response properties. writer.openBlock("const exception = new $T({", "});", errorSymbol, () -> { @@ -598,4 +611,13 @@ protected abstract void deserializeOutputDocument( OperationShape operation, StructureShape outputStructure ); + + /** + * See {@link software.amazon.smithy.typescript.codegen.validation.SerdeElision}. + * + * @return whether protocol implementation is compatible with serde elision. + */ + protected boolean enableSerdeElision() { + return false; + } } diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/validation/SerdeElision.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/validation/SerdeElision.java new file mode 100644 index 00000000000..3cdb4c1ce1e --- /dev/null +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/validation/SerdeElision.java @@ -0,0 +1,208 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.typescript.codegen.validation; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.selector.Selector; +import software.amazon.smithy.model.shapes.ListShape; +import software.amazon.smithy.model.shapes.MapShape; +import software.amazon.smithy.model.shapes.MemberShape; +import software.amazon.smithy.model.shapes.SetShape; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ShapeType; +import software.amazon.smithy.model.shapes.StructureShape; +import software.amazon.smithy.model.shapes.UnionShape; +import software.amazon.smithy.model.traits.IdempotencyTokenTrait; +import software.amazon.smithy.model.traits.JsonNameTrait; +import software.amazon.smithy.model.traits.MediaTypeTrait; +import software.amazon.smithy.model.traits.SparseTrait; +import software.amazon.smithy.model.traits.StreamingTrait; +import software.amazon.smithy.model.traits.TimestampFormatTrait; + +/** + * For determining whether a serde function for a shape may be omitted. + */ +public final class SerdeElision { + private static final Map INSTANCES = new ConcurrentHashMap<>(); + private static final SerdeElision NULL_INSTANCE = new SerdeElision(null); + private final Model model; + private final Map cache = new ConcurrentHashMap<>(); + private boolean enabledForModel = false; + + private SerdeElision(Model model) { + this.model = model; + } + + /** + * @param model - cache key. + * @return cached instance for the given model. + */ + public static SerdeElision forModel(Model model) { + if (model == null) { + return NULL_INSTANCE; + } + if (!INSTANCES.containsKey(model)) { + INSTANCES.put(model, new SerdeElision(model)); + } + return INSTANCES.get(model); + } + + /** + * @param shape - to be examined. + * @return whether the shape's serializer/deserializer may be elided. + * To qualify, the shape must contain only booleans, strings, numbers + * and containers thereof, and not have any JsonName replacements or + * other mutation parsing effects like timestamps. + * The protocol context must be JSON (not checked in this method). + */ + public boolean mayElide(Shape shape) { + if (!enabledForModel) { + return false; + } + boolean mayElide = check(shape); + cache.put(shape, mayElide); + return mayElide; + } + + /** + * This method allows the protocol and its serde implementation + * to enable this feature selectively. + * @param enabled - Gate for {@link #mayElide(Shape)}. + * @return this for chaining. + */ + public SerdeElision setEnabledForModel(boolean enabled) { + enabledForModel = enabled; + return this; + } + + /** + * Check for incompatible types and incompatible traits. + * In both cases there exist special serde functions that make + * omission of the serde function impossible without additional + * handling. + */ + private boolean check(Shape shape) { + if (cache.containsKey(shape)) { + return cache.get(shape); + } + + if (isTraitDownstream(shape, JsonNameTrait.class, "jsonName") + || isTraitDownstream(shape, StreamingTrait.class, "streaming") + || isTraitDownstream(shape, MediaTypeTrait.class, "mediaType") + || isTraitDownstream(shape, SparseTrait.class, "sparse") + || isTraitDownstream(shape, TimestampFormatTrait.class, "timestampFormat") + || isTraitDownstream(shape, IdempotencyTokenTrait.class, "idempotencyToken")) { + cache.put(shape, false); + return false; + } + + if (hasIncompatibleTypes(shape)) { + cache.put(shape, false); + return false; + } + + cache.put(shape, true); + return true; + } + + private boolean hasIncompatibleTypes(Shape shape) { + return hasIncompatibleTypes(shape, new HashSet<>(), 0); + } + + /** + * Checks whether incompatible types exist downstream of the shape. + * Incompatible types refers to types that need special serde mapping + * functions, like timestamps. + */ + private boolean hasIncompatibleTypes(Shape shape, Set types, int depth) { + if (depth > 10) { + return true; // bailout for recursive types. + } + + Shape target; + if (shape instanceof MemberShape) { + target = model.getShape(((MemberShape) shape).getTarget()).get(); + } else { + target = shape; + } + + switch (target.getType()) { + case LIST: + ListShape list = (ListShape) target; + return hasIncompatibleTypes(list.getMember(), types, depth + 1); + case SET: + SetShape set = (SetShape) target; + return hasIncompatibleTypes(set.getMember(), types, depth + 1); + case STRUCTURE: + StructureShape structure = (StructureShape) target; + return structure.getAllMembers().values().stream().anyMatch( + s -> hasIncompatibleTypes(s, types, depth + 1) + ); + case UNION: + UnionShape union = (UnionShape) target; + return union.getAllMembers().values().stream().anyMatch( + s -> hasIncompatibleTypes(s, types, depth + 1) + ); + case MAP: + MapShape map = (MapShape) target; + return hasIncompatibleTypes( + model.getShape(map.getValue().getTarget()).get(), + types, + depth + 1 + ); + case BIG_DECIMAL: + case BIG_INTEGER: + case BLOB: + case DOCUMENT: + case TIMESTAMP: + case DOUBLE: // possible call to parseFloatString or serializeFloat. + case FLOAT: // possible call to parseFloatString or serializeFloat. + // types that generate parsers. + return true; + case MEMBER: + case OPERATION: + case RESOURCE: + case SERVICE: + // non-applicable types. + return false; + case BOOLEAN: + case BYTE: + case ENUM: + case INTEGER: + case INT_ENUM: + case LONG: + case SHORT: + case STRING: + default: + // compatible types with no special parser. + return false; + } + } + + private boolean isTraitDownstream(Shape shape, Class trait, String traitName) { + if (shape.hasTrait(trait)) { + return true; + } + + if (shape.getMemberTrait(model, trait).isPresent()) { + return true; + } + + Selector selector = Selector.parse("[id = '" + shape.getId() + "']" + " ~> [trait|" + traitName + "]"); + Set matches = selector.select(model); + boolean found = !matches.isEmpty(); + + if (found) { + return true; + } + + return false; + } +} diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/validation/UnaryFunctionCall.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/validation/UnaryFunctionCall.java new file mode 100644 index 00000000000..47e55f79982 --- /dev/null +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/validation/UnaryFunctionCall.java @@ -0,0 +1,61 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.typescript.codegen.validation; + +/** + * For handling expressions that may be unary function calls. + */ +public abstract class UnaryFunctionCall { + /** + * @param expression - to be examined. + * @return whether the expression is a single-depth function call with a single parameter. + */ + public static boolean check(String expression) { + if (expression.equals("_")) { + // not a call per se, but this indicates a pass-through function. + return true; + } + return maxCallDepth(expression) == 1 + && expression.matches(".+\\(.*\\)$") + && !expression.contains("new ") + && !expression.contains(","); + } + + /** + * @param callExpression - the call expression to be converted. Check that + * the expression is a unary call first. + * @return the unary function call converted to a function reference. + */ + public static String toRef(String callExpression) { + return callExpression.replaceAll("(.*)\\(.*\\)$", "$1"); + } + + /** + * Estimates the call depth of a function call expression. + * + * @example + * call() == 1 + * call(call()) == 2 + */ + private static int maxCallDepth(String expression) { + int depth = 0; + int maxDepth = 0; + for (int i = 0; i < expression.length(); ++i) { + char c = expression.charAt(i); + if (c == '(') { + depth += 1; + if (depth > maxDepth) { + maxDepth = depth; + } + continue; + } + if (c == ')') { + depth -= 1; + } + } + return maxDepth; + } +} diff --git a/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/validation/SerdeElisionTest.java b/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/validation/SerdeElisionTest.java new file mode 100644 index 00000000000..a580728294a --- /dev/null +++ b/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/validation/SerdeElisionTest.java @@ -0,0 +1,130 @@ +package software.amazon.smithy.typescript.codegen.validation; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import java.util.Collections; +import java.util.List; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.FloatShape; +import software.amazon.smithy.model.shapes.ListShape; +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.shapes.ShapeId; +import software.amazon.smithy.model.shapes.StringShape; +import software.amazon.smithy.model.shapes.StructureShape; +import software.amazon.smithy.model.traits.IdempotencyTokenTrait; + +public class SerdeElisionTest { + StringShape string = StringShape.builder() + .id("foo.bar#string_a") + .build(); + + StringShape stringWithTrait = StringShape.builder() + .id("foo.bar#stringWithTrait") + .traits(Collections.singleton(new IdempotencyTokenTrait())) + .build(); + + FloatShape floaty = FloatShape.builder() + .id("foo.bar#float") + .build(); + + @Test + public void mayElide_simpleObjects() { + Model model = getModel(string); + SerdeElision serdeElision = SerdeElision.forModel(getModel(string)).setEnabledForModel(true); + + assertThat(serdeElision.mayElide(model.getShape(ShapeId.from("foo.bar#string")).get()), equalTo(true)); + assertThat(serdeElision.mayElide(model.getShape(ShapeId.from("foo.bar#list")).get()), equalTo(true)); + assertThat(serdeElision.mayElide(model.getShape(ShapeId.from("foo.bar#map")).get()), equalTo(true)); + assertThat(serdeElision.mayElide(model.getShape(ShapeId.from("foo.bar#structure")).get()), equalTo(true)); + } + + @Test + public void mayElide_hasBooleanGate() { + Model model = getModel(stringWithTrait); + SerdeElision serdeElision = SerdeElision.forModel(model).setEnabledForModel(false); + + assertThat(serdeElision.mayElide(model.getShape(ShapeId.from("foo.bar#string")).get()), equalTo(false)); + assertThat(serdeElision.mayElide(model.getShape(ShapeId.from("foo.bar#list")).get()), equalTo(false)); + assertThat(serdeElision.mayElide(model.getShape(ShapeId.from("foo.bar#map")).get()), equalTo(false)); + assertThat(serdeElision.mayElide(model.getShape(ShapeId.from("foo.bar#structure")).get()), equalTo(false)); + } + + @Test + public void mayElide_bailsOnTypes() { + Model model = getModel(floaty); + SerdeElision serdeElision = SerdeElision.forModel(model).setEnabledForModel(true); + + // string doesn't include float. + assertThat(serdeElision.mayElide(model.getShape(ShapeId.from("foo.bar#string")).get()), equalTo(true)); + + // others contain float and cannot qualify. + assertThat(serdeElision.mayElide(model.getShape(ShapeId.from("foo.bar#list")).get()), equalTo(false)); + assertThat(serdeElision.mayElide(model.getShape(ShapeId.from("foo.bar#map")).get()), equalTo(false)); + assertThat(serdeElision.mayElide(model.getShape(ShapeId.from("foo.bar#structure")).get()), equalTo(false)); + } + + private Model getModel(Shape buildingBlock) { + StringShape string = StringShape.builder() + .id("foo.bar#string") + .build(); + + ListShape list = ListShape.builder() + .id("foo.bar#list") + .member(buildingBlock.getId()) + .build(); + + MapShape map = MapShape.builder() + .id("foo.bar#map") + .key(MemberShape.builder() + .id("foo.bar#map$member") + .target(string.getId()) + .build()) + .value(MemberShape.builder() + .id("foo.bar#map$member") + .target(buildingBlock.getId()) + .build()) + .build(); + + MemberShape memberForString = MemberShape.builder() + .id("foo.bar#structure$string") + .target(string.getId()) + .build(); + + MemberShape memberForList = MemberShape.builder() + .id("foo.bar#structure$list") + .target(list.getId()) + .build(); + + MemberShape memberForMap = MemberShape.builder() + .id("foo.bar#structure$map") + .target(map.getId()) + .build(); + + MemberShape memberForBuildingBlock = MemberShape.builder() + .id("foo.bar#structure$buildingBlock") + .target(buildingBlock.getId()) + .build(); + + StructureShape structure = StructureShape.builder() + .id("foo.bar#structure") + .members( + List.of( + memberForString, memberForList, memberForMap, + memberForBuildingBlock + ) + ) + .build(); + + Model model = Model.builder() + .addShapes( + string, list, map, structure, buildingBlock, + memberForString, memberForList, memberForMap, memberForBuildingBlock + ) + .build(); + + return model; + } +} diff --git a/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/validation/UnaryFunctionCallTest.java b/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/validation/UnaryFunctionCallTest.java new file mode 100644 index 00000000000..61d510e9ef4 --- /dev/null +++ b/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/validation/UnaryFunctionCallTest.java @@ -0,0 +1,36 @@ +package software.amazon.smithy.typescript.codegen.validation; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class UnaryFunctionCallTest { + @Test + void check() { + assertEquals(false, UnaryFunctionCall.check("")); + assertEquals(false, UnaryFunctionCall.check("5")); + assertEquals(false, UnaryFunctionCall.check("(param)")); + assertEquals(false, UnaryFunctionCall.check("x[5]")); + assertEquals(false, UnaryFunctionCall.check("new Date(timestamp)")); + assertEquals(false, UnaryFunctionCall.check("x(y(_))")); + assertEquals(false, UnaryFunctionCall.check("call(param).prop")); + assertEquals(false, UnaryFunctionCall.check("call(param, param2)")); + + assertEquals(true, UnaryFunctionCall.check("_")); + assertEquals(true, UnaryFunctionCall.check("x()")); + assertEquals(true, UnaryFunctionCall.check("x(_)")); + assertEquals(true, UnaryFunctionCall.check("long_function_name(long_parameter_name)")); + assertEquals(true, UnaryFunctionCall.check("container.function(param)")); + assertEquals(true, UnaryFunctionCall.check("factory(param)(param2)")); + } + + @Test + void toRef() { + assertEquals("_", UnaryFunctionCall.toRef("_")); + assertEquals("x", UnaryFunctionCall.toRef("x()")); + assertEquals("x", UnaryFunctionCall.toRef("x(_)")); + assertEquals("long_function_name", UnaryFunctionCall.toRef("long_function_name(long_parameter_name)")); + assertEquals("container.function", UnaryFunctionCall.toRef("container.function(param)")); + assertEquals("factory(param)", UnaryFunctionCall.toRef("factory(param)(param2)")); + } +} \ No newline at end of file