diff --git a/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/JsonMemberSerVisitor.java b/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/JsonMemberSerVisitor.java index fdc4b94ba970..b0ef737cdbf5 100644 --- a/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/JsonMemberSerVisitor.java +++ b/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/JsonMemberSerVisitor.java @@ -15,12 +15,21 @@ package software.amazon.smithy.aws.typescript.codegen; +import software.amazon.smithy.aws.traits.protocols.AwsQueryCompatibleTrait; import software.amazon.smithy.codegen.core.CodegenException; import software.amazon.smithy.model.shapes.BigDecimalShape; import software.amazon.smithy.model.shapes.BigIntegerShape; +import software.amazon.smithy.model.shapes.BooleanShape; +import software.amazon.smithy.model.shapes.DoubleShape; +import software.amazon.smithy.model.shapes.FloatShape; +import software.amazon.smithy.model.shapes.IntegerShape; +import software.amazon.smithy.model.shapes.LongShape; import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ShortShape; +import software.amazon.smithy.model.shapes.StringShape; import software.amazon.smithy.model.traits.TimestampFormatTrait.Format; import software.amazon.smithy.typescript.codegen.TypeScriptDependency; +import software.amazon.smithy.typescript.codegen.TypeScriptWriter; import software.amazon.smithy.typescript.codegen.integration.DocumentMemberSerVisitor; import software.amazon.smithy.typescript.codegen.integration.ProtocolGenerator.GenerationContext; import software.amazon.smithy.utils.SmithyInternalApi; @@ -33,14 +42,22 @@ */ @SmithyInternalApi final class JsonMemberSerVisitor extends DocumentMemberSerVisitor { + private final boolean isAwsQueryCompat; /** * @inheritDoc */ JsonMemberSerVisitor(GenerationContext context, String dataSource, Format defaultTimestampFormat) { super(context, dataSource, defaultTimestampFormat); - context.getWriter().addImport("_json", null, TypeScriptDependency.AWS_SMITHY_CLIENT); - this.serdeElisionEnabled = !context.getSettings().generateServerSdk(); + TypeScriptWriter writer = context.getWriter(); + writer.addImport("_json", null, TypeScriptDependency.AWS_SMITHY_CLIENT); + this.isAwsQueryCompat = context.getService().hasTrait(AwsQueryCompatibleTrait.class); + this.serdeElisionEnabled = !this.isAwsQueryCompat && !context.getSettings().generateServerSdk(); + if (isAwsQueryCompat) { + writer.addImport("_toStr", null, AwsDependency.AWS_SDK_CORE); + writer.addImport("_toNum", null, AwsDependency.AWS_SDK_CORE); + writer.addImport("_toBool", null, AwsDependency.AWS_SDK_CORE); + } } @Override @@ -55,6 +72,69 @@ public String bigIntegerShape(BigIntegerShape shape) { return unsupportedShape(shape); } + @Override + public String shortShape(ShortShape shape) { + String base = super.shortShape(shape); + if (isAwsQueryCompat) { + return "_toNum(" + base + ")"; + } + return base; + } + + @Override + public String integerShape(IntegerShape shape) { + String base = super.integerShape(shape); + if (isAwsQueryCompat) { + return "_toNum(" + base + ")"; + } + return base; + } + + @Override + public String longShape(LongShape shape) { + String base = super.longShape(shape); + if (isAwsQueryCompat) { + return "_toNum(" + base + ")"; + } + return base; + } + + @Override + public String floatShape(FloatShape shape) { + String base = super.floatShape(shape); + if (isAwsQueryCompat) { + return "_toNum(" + base + ")"; + } + return base; + } + + @Override + public String doubleShape(DoubleShape shape) { + String base = super.doubleShape(shape); + if (isAwsQueryCompat) { + return "_toNum(" + base + ")"; + } + return base; + } + + @Override + public String booleanShape(BooleanShape shape) { + String base = super.booleanShape(shape); + if (isAwsQueryCompat) { + return "_toBool(" + base + ")"; + } + return base; + } + + @Override + public String stringShape(StringShape shape) { + String base = super.stringShape(shape); + if (isAwsQueryCompat) { + return "_toStr(" + base + ")"; + } + return base; + } + private String unsupportedShape(Shape shape) { throw new CodegenException(String.format("Cannot serialize shape type %s on protocol, shape: %s.", shape.getType(), shape.getId())); diff --git a/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/JsonRpcProtocolGenerator.java b/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/JsonRpcProtocolGenerator.java index d70c5db3091b..c962225d717d 100644 --- a/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/JsonRpcProtocolGenerator.java +++ b/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/JsonRpcProtocolGenerator.java @@ -66,16 +66,28 @@ protected Format getDocumentTimestampFormat() { @Override protected void generateDocumentBodyShapeSerializers(GenerationContext context, Set shapes) { + boolean isAwsQueryCompat = context.getService().hasTrait(AwsQueryCompatibleTrait.class); + AwsProtocolUtils.generateDocumentBodyShapeSerde(context, shapes, - // AWS JSON does not support jsonName - new JsonShapeSerVisitor(context, (shape, name) -> name, enableSerdeElision())); + // AWS JSON does not support jsonName + new JsonShapeSerVisitor( + context, + (shape, name) -> name, + !isAwsQueryCompat && enableSerdeElision() + ) + ); } @Override protected void generateDocumentBodyShapeDeserializers(GenerationContext context, Set shapes) { AwsProtocolUtils.generateDocumentBodyShapeSerde(context, shapes, - // AWS JSON does not support jsonName - new JsonShapeDeserVisitor(context, (shape, name) -> name, enableSerdeElision())); + // AWS JSON does not support jsonName + new JsonShapeDeserVisitor( + context, + (shape, name) -> name, + enableSerdeElision() + ) + ); } @Override diff --git a/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/RestJsonProtocolGenerator.java b/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/RestJsonProtocolGenerator.java index 46bd39448061..f6e4b72aa653 100644 --- a/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/RestJsonProtocolGenerator.java +++ b/codegen/smithy-aws-typescript-codegen/src/main/java/software/amazon/smithy/aws/typescript/codegen/RestJsonProtocolGenerator.java @@ -17,6 +17,7 @@ import java.util.List; import java.util.Set; +import software.amazon.smithy.aws.traits.protocols.AwsQueryCompatibleTrait; import software.amazon.smithy.aws.typescript.codegen.validation.UnaryFunctionCall; import software.amazon.smithy.codegen.core.SymbolProvider; import software.amazon.smithy.model.knowledge.HttpBinding; @@ -69,8 +70,9 @@ protected TimestampFormatTrait.Format getDocumentTimestampFormat() { @Override protected void generateDocumentBodyShapeSerializers(GenerationContext context, Set shapes) { + boolean isAwsQueryCompat = context.getService().hasTrait(AwsQueryCompatibleTrait.class); AwsProtocolUtils.generateDocumentBodyShapeSerde(context, shapes, new JsonShapeSerVisitor(context, - (!context.getSettings().generateServerSdk() && enableSerdeElision()))); + (!context.getSettings().generateServerSdk() && !isAwsQueryCompat && enableSerdeElision()))); } @Override diff --git a/packages/core/src/protocols/coercing-serializers.spec.ts b/packages/core/src/protocols/coercing-serializers.spec.ts new file mode 100644 index 000000000000..05210d28c019 --- /dev/null +++ b/packages/core/src/protocols/coercing-serializers.spec.ts @@ -0,0 +1,76 @@ +import { _toBool, _toNum, _toStr } from "./coercing-serializers"; + +const consoleWarn = console.warn; + +beforeAll(() => { + console.warn = () => {}; +}); + +afterAll(() => { + console.warn = consoleWarn; +}); + +describe(_toBool.name, () => { + it("ignores nullish", () => { + expect(_toBool(null)).toBe(null); + expect(_toBool(undefined)).toBe(undefined); + }); + + it("converts strings", () => { + expect(_toBool("false")).toEqual(false); + expect(_toBool("true")).toEqual(true); + + expect(_toBool("False")).toEqual(false); + expect(_toBool("True")).toEqual(true); + + expect(_toBool("")).toEqual(false); + expect(_toBool("a")).toEqual(true); // warns + }); + + it("does not convert numbers", () => { + expect(_toBool(0)).toEqual(0); + expect(_toBool(1)).toEqual(1); + }); +}); + +describe(_toStr.name, () => { + it("ignores nullish", () => { + expect(_toStr(null)).toBe(null); + expect(_toStr(undefined)).toBe(undefined); + }); + + it("converts numbers", () => { + expect(_toStr(0)).toEqual("0"); + expect(_toStr(1)).toEqual("1"); + }); + + it("converts booleans", () => { + expect(_toStr(false)).toEqual("false"); + expect(_toStr(true)).toEqual("true"); + }); +}); + +describe(_toNum.name, () => { + it("ignores nullish", () => { + expect(_toNum(null)).toBe(null); + expect(_toNum(undefined)).toBe(undefined); + }); + + it("converts numeric strings", () => { + expect(_toNum("1234")).toEqual(1234); + expect(_toNum("1234.56")).toEqual(1234.56); + }); + + it("does not convert prefix-numeric strings", () => { + expect(_toNum("1234abc")).toEqual("1234abc"); + expect(_toNum("1234.56abc")).toEqual("1234.56abc"); + }); + + it("does not convert non-numeric strings", () => { + expect(_toNum("abcdef")).toEqual("abcdef"); + }); + it("does not convert bools", () => { + expect(_toNum(false)).toEqual(false); + expect(_toNum(true)).toEqual(true); + }); +}); diff --git a/packages/core/src/protocols/coercing-serializers.ts b/packages/core/src/protocols/coercing-serializers.ts new file mode 100644 index 000000000000..afed47299bfc --- /dev/null +++ b/packages/core/src/protocols/coercing-serializers.ts @@ -0,0 +1,72 @@ +/** + * @internal + * + * Used for awsQueryCompatibility trait. + */ +export const _toStr = (val: unknown): string | undefined => { + if (val == null) { + return val as undefined; + } + if (typeof val === "number" || typeof val === "bigint") { + const warning = new Error(`Received number ${val} where a string was expected.`); + warning.name = "Warning"; + console.warn(warning); + return String(val); + } + if (typeof val === "boolean") { + const warning = new Error(`Received boolean ${val} where a string was expected.`); + warning.name = "Warning"; + console.warn(warning); + return String(val); + } + return val as string; +}; + +/** + * @internal + * + * Used for awsQueryCompatibility trait. + */ +export const _toBool = (val: unknown): boolean | undefined => { + if (val == null) { + return val as undefined; + } + if (typeof val === "number") { + // transmit to service to be rejected. + } + if (typeof val === "string") { + const lowercase = val.toLowerCase(); + if (val !== "" && lowercase !== "false" && lowercase !== "true") { + const warning = new Error(`Received string "${val}" where a boolean was expected.`); + warning.name = "Warning"; + console.warn(warning); + } + return val !== "" && lowercase !== "false"; + } + return val as boolean; +}; + +/** + * @internal + * + * Used for awsQueryCompatibility trait. + */ +export const _toNum = (val: unknown): number | undefined => { + if (val == null) { + return val as undefined; + } + if (typeof val === "boolean") { + // transmit to service to be rejected. + } + if (typeof val === "string") { + const num = Number(val); + if (num.toString() !== val) { + const warning = new Error(`Received string "${val}" where a number was expected.`); + warning.name = "Warning"; + console.warn(warning); + return val as unknown as undefined; + } + return num; + } + return val as number; +}; diff --git a/packages/core/src/protocols/index.ts b/packages/core/src/protocols/index.ts index a7355be28f66..c3a1b7cf5d76 100644 --- a/packages/core/src/protocols/index.ts +++ b/packages/core/src/protocols/index.ts @@ -1 +1,2 @@ +export * from "./coercing-serializers"; export * from "./json/awsExpectUnion";