diff --git a/packages/core/src/submodules/protocols/json/JsonShapeDeserializer.ts b/packages/core/src/submodules/protocols/json/JsonShapeDeserializer.ts index 9a2bd4144daff..1040cb700ecfb 100644 --- a/packages/core/src/submodules/protocols/json/JsonShapeDeserializer.ts +++ b/packages/core/src/submodules/protocols/json/JsonShapeDeserializer.ts @@ -1,4 +1,5 @@ -import { NormalizedSchema, SCHEMA } from "@smithy/core/schema"; +import { determineTimestampFormat } from "@smithy/core/protocols"; +import { NormalizedSchema, SCHEMA, TypeRegistry } from "@smithy/core/schema"; import { LazyJsonString, NumericValue, @@ -84,13 +85,8 @@ export class JsonShapeDeserializer extends SerdeContextConfig implements ShapeDe } } - if (ns.isTimestampSchema()) { - const options = this.settings.timestampFormat; - const format = options.useTrait - ? ns.getSchema() === SCHEMA.TIMESTAMP_DEFAULT - ? options.default - : ns.getSchema() ?? options.default - : options.default; + if (ns.isTimestampSchema() && value != null) { + const format = determineTimestampFormat(ns, this.settings); switch (format) { case SCHEMA.TIMESTAMP_DATE_TIME: return parseRfc3339DateTimeWithOffset(value); @@ -112,6 +108,10 @@ export class JsonShapeDeserializer extends SerdeContextConfig implements ShapeDe if (value instanceof NumericValue) { return value; } + const untyped = value as any; + if (untyped.type === "bigDecimal" && "string" in untyped) { + return new NumericValue(untyped.string, untyped.type); + } return new NumericValue(String(value), "bigDecimal"); } @@ -126,6 +126,22 @@ export class JsonShapeDeserializer extends SerdeContextConfig implements ShapeDe } } + if (ns.isDocumentSchema()) { + if (isObject) { + const out = Array.isArray(value) ? [] : ({} as any); + for (const [k, v] of Object.entries(value)) { + if (v instanceof NumericValue) { + out[k] = v; + } else { + out[k] = this._read(ns, v); + } + } + return out; + } else { + return structuredClone(value); + } + } + // covers string, numeric, boolean, document, bigDecimal return value; } diff --git a/packages/core/src/submodules/protocols/json/JsonShapeSerializer.ts b/packages/core/src/submodules/protocols/json/JsonShapeSerializer.ts index bd4fca7bc7bd1..4ef83d947b276 100644 --- a/packages/core/src/submodules/protocols/json/JsonShapeSerializer.ts +++ b/packages/core/src/submodules/protocols/json/JsonShapeSerializer.ts @@ -1,6 +1,8 @@ +import { determineTimestampFormat } from "@smithy/core/protocols"; import { NormalizedSchema, SCHEMA } from "@smithy/core/schema"; -import { dateToUtcString, generateIdempotencyToken, LazyJsonString } from "@smithy/core/serde"; +import { dateToUtcString, generateIdempotencyToken, LazyJsonString, NumericValue } from "@smithy/core/serde"; import { Schema, ShapeSerializer } from "@smithy/types"; +import { toBase64 } from "@smithy/util-base64"; import { SerdeContextConfig } from "../ConfigurableSerdeContext"; import { JsonSettings } from "./JsonCodec"; @@ -22,11 +24,25 @@ export class JsonShapeSerializer extends SerdeContextConfig implements ShapeSeri this.buffer = this._write(this.rootSchema, value); } + /** + * @internal + */ + public writeDiscriminatedDocument(schema: Schema, value: unknown): void { + this.write(schema, value); + if (typeof this.buffer === "object") { + this.buffer.__type = NormalizedSchema.of(schema).getName(true); + } + } + public flush(): string { - if (this.rootSchema?.isStructSchema() || this.rootSchema?.isDocumentSchema()) { + const { rootSchema } = this; + this.rootSchema = undefined; + + if (rootSchema?.isStructSchema() || rootSchema?.isDocumentSchema()) { const replacer = new JsonReplacer(); return replacer.replaceInJson(JSON.stringify(this.buffer, replacer.createReplacer(), 0)); } + // non-struct root schema indicates a blob (base64 string) or plain string payload. return this.buffer; } @@ -73,23 +89,21 @@ export class JsonShapeSerializer extends SerdeContextConfig implements ShapeSeri return void 0; } - if (ns.isBlobSchema() && (value instanceof Uint8Array || typeof value === "string")) { + if ( + (ns.isBlobSchema() && (value instanceof Uint8Array || typeof value === "string")) || + (ns.isDocumentSchema() && value instanceof Uint8Array) + ) { if (ns === this.rootSchema) { return value; } if (!this.serdeContext?.base64Encoder) { - throw new Error("Missing base64Encoder in serdeContext"); + return toBase64(value); } return this.serdeContext?.base64Encoder(value); } - if (ns.isTimestampSchema() && value instanceof Date) { - const options = this.settings.timestampFormat; - const format = options.useTrait - ? ns.getSchema() === SCHEMA.TIMESTAMP_DEFAULT - ? options.default - : ns.getSchema() ?? options.default - : options.default; + if ((ns.isTimestampSchema() || ns.isDocumentSchema()) && value instanceof Date) { + const format = determineTimestampFormat(ns, this.settings); switch (format) { case SCHEMA.TIMESTAMP_DATE_TIME: return value.toISOString().replace(".000Z", "Z"); @@ -124,6 +138,22 @@ export class JsonShapeSerializer extends SerdeContextConfig implements ShapeSeri } } + if (ns.isDocumentSchema()) { + if (isObject) { + const out = Array.isArray(value) ? [] : ({} as any); + for (const [k, v] of Object.entries(value)) { + if (v instanceof NumericValue) { + out[k] = v; + } else { + out[k] = this._write(ns, v); + } + } + return out; + } else { + return structuredClone(value); + } + } + return value; } } diff --git a/packages/core/src/submodules/protocols/schema-testing/new-document-type-test-cases.json b/packages/core/src/submodules/protocols/schema-testing/new-document-type-test-cases.json new file mode 100644 index 0000000000000..398293915a6b1 --- /dev/null +++ b/packages/core/src/submodules/protocols/schema-testing/new-document-type-test-cases.json @@ -0,0 +1,272 @@ +{ + "serdeTests": [ + { + "name": "default test case", + "subject": "smithy.example#OmniWidget", + "serialized": { + "string": "hello" + }, + "deserialized": { + "string": "hello" + }, + "codec": "json", + "settings": { + "jsonName": false + } + }, + { + "name": "jsonName", + "subject": "smithy.example#OmniWidget", + "serialized": { + "String": "hello" + }, + "deserialized": { + "string": "hello" + }, + "codec": "json", + "settings": { + "jsonName": true + } + }, + { + "name": "timestampFormat, with default epoch-seconds", + "subject": "smithy.example#OmniWidget", + "serialized": { + "timestamp": 0, + "timestampDateTime": "1970-01-01T00:00:00Z", + "timestampHttpDate": "Thu, 01 Jan 1970 00:00:00 GMT", + "timestampEpochSeconds": 0 + }, + "deserialized": { + "timestamp": "1970-01-01T00:00:00Z", + "timestampDateTime": "1970-01-01T00:00:00Z", + "timestampHttpDate": "1970-01-01T00:00:00Z", + "timestampEpochSeconds": "1970-01-01T00:00:00Z" + }, + "codec": "json", + "settings": { + "timestampFormat": { + "useTrait": true, + "default": "epoch-seconds" + } + } + }, + { + "name": "ignoring timestampFormat, with default epoch-seconds", + "subject": "smithy.example#OmniWidget", + "serialized": { + "timestamp": 0, + "timestampDateTime": 0, + "timestampHttpDate": 0, + "timestampEpochSeconds": 0 + }, + "deserialized": { + "timestamp": "1970-01-01T00:00:00Z", + "timestampDateTime": "1970-01-01T00:00:00Z", + "timestampHttpDate": "1970-01-01T00:00:00Z", + "timestampEpochSeconds": "1970-01-01T00:00:00Z" + }, + "codec": "json", + "settings": { + "timestampFormat": { + "useTrait": false, + "default": "epoch-seconds" + } + } + }, + { + "name": "default settings with populated members with initial values", + "subject": "smithy.example#OmniWidget", + "serialized": { + "blob": "YWJjZA==", + "boolean": false, + "String": "", + "byte": 0, + "short": 0, + "integer": 0, + "long": 0, + "float": 0, + "double": 0, + "bigInteger": 0, + "bigDecimal": 0, + "timestamp": 0, + "timestampDateTime": "1970-01-01T00:00:00Z", + "timestampHttpDate": "Thu, 01 Jan 1970 00:00:00 GMT", + "timestampEpochSeconds": 0, + "document": {}, + "enum": "A", + "intEnum": 0, + "list": [], + "map": {}, + "structure": {} + }, + "deserialized": { + "blob": "YWJjZA==", + "boolean": false, + "string": "", + "byte": 0, + "short": 0, + "integer": 0, + "long": 0, + "float": 0, + "double": 0, + "bigInteger": 0, + "bigDecimal": 0, + "timestamp": 0, + "timestampDateTime": "1970-01-01T00:00:00Z", + "timestampHttpDate": "Thu, 01 Jan 1970 00:00:00 GMT", + "timestampEpochSeconds": 0, + "document": {}, + "enum": "A", + "intEnum": 0, + "list": [], + "map": {}, + "structure": {} + }, + "codec": "json", + "settings": { + "defaultNamespace": "smithy.example", + "jsonName": true, + "timestampFormat": { + "useTrait": true, + "default": "epoch-seconds" + } + } + }, + { + "name": "default settings with populated members with filled values", + "subject": "smithy.example#OmniWidget", + "serialized": { + "blob": "YWJjZA==", + "boolean": true, + "String": "abcd", + "byte": -128, + "short": -32768, + "integer": -2147483648, + "long": -9007199254740991, + "float": -0.1, + "double": -0.01, + "bigInteger": -9007199254740991, + "bigDecimal": -9007199254740991.123456789, + "timestamp": 17514144000, + "timestampDateTime": "2525-01-01T00:00:00Z", + "timestampHttpDate": "Mon, 01 Jan 2525 00:00:00 GMT", + "timestampEpochSeconds": 17514144000, + "document": { + "__type": "smithy.example#OmniWidget", + "timestamp": 17514144000, + "timestampDateTime": "2525-01-01T00:00:00Z", + "timestampHttpDate": "Mon, 01 Jan 2525 00:00:00 GMT", + "timestampEpochSeconds": 17514144000, + "document": true + }, + "enum": "B", + "intEnum": 1, + "list": [ + { + "byte": -128 + }, + { + "short": -32768 + } + ], + "map": { + "a": { + "integer": -2147483648 + }, + "b": { + "long": -9007199254740991 + } + }, + "structure": { + "list": [ + { + "double": -0.01 + }, + { + "float": -0.1 + } + ], + "map": { + "a": { + "bigInteger": -9007199254740991 + }, + "b": { + "bigDecimal": -9007199254740991.123456789 + } + } + } + }, + "deserialized": { + "blob": "YWJjZA==", + "boolean": true, + "string": "abcd", + "byte": -128, + "short": -32768, + "integer": -2147483648, + "long": -9007199254740991, + "float": -0.1, + "double": -0.01, + "bigInteger": -9007199254740991, + "bigDecimal": -9007199254740991.123456789, + "timestamp": "2525-01-01T00:00:00Z", + "timestampDateTime": "2525-01-01T00:00:00Z", + "timestampHttpDate": "2525-01-01T00:00:00Z", + "timestampEpochSeconds": "2525-01-01T00:00:00Z", + "document": { + "__type": "smithy.example#OmniWidget", + "timestamp": "2525-01-01T00:00:00Z", + "timestampDateTime": "2525-01-01T00:00:00Z", + "timestampHttpDate": "2525-01-01T00:00:00Z", + "timestampEpochSeconds": "2525-01-01T00:00:00Z", + "document": true + }, + "enum": "B", + "intEnum": 1, + "list": [ + { + "byte": -128 + }, + { + "short": -32768 + } + ], + "map": { + "a": { + "integer": -2147483648 + }, + "b": { + "long": -9007199254740991 + } + }, + "structure": { + "list": [ + { + "double": -0.01 + }, + { + "float": -0.1 + } + ], + "map": { + "a": { + "bigInteger": -9007199254740991 + }, + "b": { + "bigDecimal": -9007199254740991.123456789 + } + } + } + }, + "codec": "json", + "settings": { + "defaultNamespace": "smithy.example", + "jsonName": true, + "timestampFormat": { + "useTrait": true, + "default": "epoch-seconds" + } + } + } + ] +} diff --git a/packages/core/src/submodules/protocols/schema-testing/new-document-type-test-cases.spec.ts b/packages/core/src/submodules/protocols/schema-testing/new-document-type-test-cases.spec.ts new file mode 100644 index 0000000000000..9c4da7f0c41b0 --- /dev/null +++ b/packages/core/src/submodules/protocols/schema-testing/new-document-type-test-cases.spec.ts @@ -0,0 +1,13 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, test as it } from "vitest"; + +import { jsonReviver } from "../json/jsonReviver"; + +const json = fs.readFileSync(path.join(__dirname, "./new-document-type-test-cases.json"), "utf-8"); + +export const testCases = JSON.parse(json, jsonReviver); + +describe("placeholder", () => { + it("", () => {}); +}); diff --git a/packages/core/src/submodules/protocols/schema-testing/schema-documents.spec.ts b/packages/core/src/submodules/protocols/schema-testing/schema-documents.spec.ts new file mode 100644 index 0000000000000..28a25291375a4 --- /dev/null +++ b/packages/core/src/submodules/protocols/schema-testing/schema-documents.spec.ts @@ -0,0 +1,144 @@ +import { list, map, NormalizedSchema, SCHEMA, sim, struct, TypeRegistry } from "@smithy/core/schema"; +import { DocumentType, Schema } from "@smithy/types"; +import { describe, expect, test as it } from "vitest"; + +import { JsonCodec, JsonSettings } from "../json/JsonCodec"; +import { JsonShapeDeserializer } from "../json/JsonShapeDeserializer"; +import { testCases } from "./new-document-type-test-cases.spec"; + +/* eslint no-var: 0 */ +export var OmniWidget = struct( + "smithy.example", + "OmniWidget", + 0, + [ + "blob", + "boolean", + "string", + "byte", + "short", + "integer", + "long", + "float", + "double", + "bigInteger", + "bigDecimal", + "timestamp", + "timestampDateTime", + "timestampHttpDate", + "timestampEpochSeconds", + "document", + "enum", + "intEnum", + "list", + "map", + "structure", + ], + [ + SCHEMA.BLOB, + SCHEMA.BOOLEAN, + sim("smithy.api", "String", 0, { + jsonName: "String", + xmlName: "String", + }), + sim("smithy.api", "Byte", SCHEMA.NUMERIC, 0), + sim("smithy.api", "Short", SCHEMA.NUMERIC, 0), + sim("smithy.api", "Integer", SCHEMA.NUMERIC, 0), + sim("smithy.api", "Long", SCHEMA.NUMERIC, 0), + sim("smithy.api", "Float", SCHEMA.NUMERIC, 0), + SCHEMA.NUMERIC, // double + SCHEMA.BIG_INTEGER, + SCHEMA.BIG_DECIMAL, + SCHEMA.TIMESTAMP_DEFAULT, + SCHEMA.TIMESTAMP_DATE_TIME, + SCHEMA.TIMESTAMP_HTTP_DATE, + SCHEMA.TIMESTAMP_EPOCH_SECONDS, + SCHEMA.DOCUMENT, + sim("smithy.api", "Enum", SCHEMA.STRING, 0), + sim("smithy.api", "IntEnum", SCHEMA.NUMERIC, 0), + list("smithy.example", "OmniWidgetList", 0, () => OmniWidget), + map("smithy.example", "OmniWidgetMap", 0, 0, () => OmniWidget), + () => OmniWidget, + ] +); + +function getJsonCodec(test: { settings: JsonSettings }): JsonCodec { + const { settings } = test; + const format = + { + "date-time": SCHEMA.TIMESTAMP_DATE_TIME, + "http-date": SCHEMA.TIMESTAMP_HTTP_DATE, + "epoch-seconds": SCHEMA.TIMESTAMP_EPOCH_SECONDS, + }[(settings.timestampFormat?.default as unknown as string) ?? "epoch-seconds"] ?? SCHEMA.TIMESTAMP_EPOCH_SECONDS; + return new JsonCodec({ + jsonName: settings.jsonName ?? false, + timestampFormat: { + default: format, + useTrait: settings.timestampFormat?.useTrait ?? true, + }, + httpBindings: settings.httpBindings ?? false, + }); +} + +/** + * Attempts to use the discriminator on the data instead of requiring a + * schema as input. + */ +function readDocument(deserializer: JsonShapeDeserializer, data: DocumentType): any { + if (data && typeof data === "object" && typeof (data as any).__type === "string") { + const object = data as any; + const [namespace, name] = object.__type.split("#"); + delete object.__type; + const schema = TypeRegistry.for(namespace).getSchema(name); + const ns = NormalizedSchema.of(schema); + return deserializer.readObject(ns, object); + } + return deserializer.readObject(SCHEMA.DOCUMENT, data); +} + +describe("schema conversion tests for serializations, data objects, and documents", () => { + for (const test of testCases.serdeTests) { + it(test.name, async () => { + const subjectSchema = NormalizedSchema.of(TypeRegistry.for("smithy.example").getSchema(test.subject)); + + const codec = getJsonCodec(test); + const serializer = codec.createSerializer(); + const deserializer = codec.createDeserializer(); + + serializer.write(SCHEMA.DOCUMENT, test.serialized); + const serialization = serializer.flush(); + const documentFromSerialization = await deserializer.read(SCHEMA.DOCUMENT, serialization); + const canonicalDataObject = await deserializer.read(subjectSchema, serialization); + + serializer.writeDiscriminatedDocument(subjectSchema, canonicalDataObject); + const documentFromDataObject = await deserializer.read(SCHEMA.DOCUMENT, serializer.flush()); + + // 1. data object from serialization + expect(typeof documentFromSerialization).toBe("object"); + + // 2. data object document back to data object + const dataObjectFromDocument = await deserializer.readObject(subjectSchema, documentFromDataObject); + expect(dataObjectFromDocument).toEqual(canonicalDataObject); + + // 3. data object from serialization document + const dataObjectFromSerializedDocument = await deserializer.readObject(subjectSchema, documentFromSerialization); + expect(dataObjectFromSerializedDocument).toEqual(canonicalDataObject); + + // 4. serialization from data object + serializer.write(subjectSchema, canonicalDataObject); + const serializationFromDataObject = serializer.flush(); + expect(serializationFromDataObject).toEqual(serialization); + + // 5. serialization from serialization document + serializer.write(SCHEMA.DOCUMENT, documentFromSerialization); + const serializationFromSerializedDocument = serializer.flush(); + expect(serializationFromSerializedDocument).toEqual(serialization); + + // 6. serialization from data object document + delete documentFromDataObject.__type; + serializer.write(SCHEMA.DOCUMENT, documentFromDataObject); + const serializationFromDocumentFromDataObject = serializer.flush(); + expect(serializationFromDocumentFromDataObject).toEqual(serialization); + }); + } +}); diff --git a/packages/core/src/submodules/protocols/xml/XmlShapeSerializer.ts b/packages/core/src/submodules/protocols/xml/XmlShapeSerializer.ts index ab06ba1bb7e18..b4d370e292f7b 100644 --- a/packages/core/src/submodules/protocols/xml/XmlShapeSerializer.ts +++ b/packages/core/src/submodules/protocols/xml/XmlShapeSerializer.ts @@ -1,4 +1,5 @@ import { XmlNode, XmlText } from "@aws-sdk/xml-builder"; +import { determineTimestampFormat } from "@smithy/core/protocols"; import { NormalizedSchema, SCHEMA } from "@smithy/core/schema"; import { generateIdempotencyToken, NumericValue } from "@smithy/core/serde"; import { dateToUtcString } from "@smithy/smithy-client"; @@ -259,12 +260,7 @@ export class XmlShapeSerializer extends SerdeContextConfig implements ShapeSeria if (ns.isBlobSchema()) { nodeContents = (this.serdeContext?.base64Encoder ?? toBase64)(value as string | Uint8Array); } else if (ns.isTimestampSchema() && value instanceof Date) { - const options = this.settings.timestampFormat; - const format = options.useTrait - ? ns.getSchema() === SCHEMA.TIMESTAMP_DEFAULT - ? options.default - : ns.getSchema() ?? options.default - : options.default; + const format = determineTimestampFormat(ns, this.settings); switch (format) { case SCHEMA.TIMESTAMP_DATE_TIME: nodeContents = value.toISOString().replace(".000Z", "Z");