diff --git a/packages/core/src/submodules/protocols/idempotencyToken.spec.ts b/packages/core/src/submodules/protocols/idempotencyToken.spec.ts new file mode 100644 index 000000000000..f9ef6233b8a2 --- /dev/null +++ b/packages/core/src/submodules/protocols/idempotencyToken.spec.ts @@ -0,0 +1,69 @@ +import { CborShapeSerializer } from "@smithy/core/cbor"; +import { sim, struct } from "@smithy/core/schema"; +import { describe, expect, test as it } from "vitest"; + +import { JsonShapeSerializer } from "./json/JsonShapeSerializer"; +import { QueryShapeSerializer } from "./query/QueryShapeSerializer"; +import { XmlShapeSerializer } from "./xml/XmlShapeSerializer"; + +describe("idempotencyToken", () => { + const structureSchema = struct( + "ns", + "StructureWithIdempotencyToken", + 0, + ["idempotencyToken", "plain"], + [sim("ns", "IdempotencyTokenString", 0, 0b0100), sim("ns", "PlainString", 0, 0b0000)] + ); + + it("all ShapeSerializer implementations should generate an idempotency token if no input was provided by the caller", () => { + const serializers = [ + new JsonShapeSerializer({ + timestampFormat: { default: 7, useTrait: true }, + jsonName: true, + }), + new QueryShapeSerializer({ + timestampFormat: { default: 7, useTrait: true }, + }), + new XmlShapeSerializer({ + serviceNamespace: "ServiceNamespace", + timestampFormat: { default: 7, useTrait: true }, + xmlNamespace: "XmlNamespace", + }), + new CborShapeSerializer(), + ]; + + const expectedSerializations = [ + /{"idempotencyToken":"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}","plain":"potatoes"}/, + /&idempotencyToken=[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}&plain=potatoes/, + /([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})<\/idempotencyToken>potatoes<\/plain><\/StructureWithIdempotencyToken>/, + /�pidempotencyTokenx\$([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})eplainhpotatoes/, + ]; + + for (let i = 0; i < expectedSerializations.length; i++) { + const serializer = serializers[i]; + const expectedSerialization = expectedSerializations[i]; + + // automatic token + { + serializer.write(structureSchema, { + idempotencyToken: undefined, + plain: "potatoes", + }); + const data = serializer.flush(); + const serialization = Buffer.from(data).toString("utf8"); + expect(serialization).toMatch(expectedSerialization); + } + + // manual token + { + serializer.write(structureSchema, { + idempotencyToken: "00000000-0000-4000-9000-000000000000", + plain: "potatoes", + }); + const data = serializer.flush(); + const serialization = Buffer.from(data).toString("utf8"); + expect(serialization).toMatch(expectedSerialization); + } + } + }); +}); diff --git a/packages/core/src/submodules/protocols/json/JsonShapeSerializer.ts b/packages/core/src/submodules/protocols/json/JsonShapeSerializer.ts index a7c2316a4815..bd4fca7bc7bd 100644 --- a/packages/core/src/submodules/protocols/json/JsonShapeSerializer.ts +++ b/packages/core/src/submodules/protocols/json/JsonShapeSerializer.ts @@ -1,6 +1,5 @@ import { NormalizedSchema, SCHEMA } from "@smithy/core/schema"; -import { dateToUtcString } from "@smithy/core/serde"; -import { LazyJsonString } from "@smithy/core/serde"; +import { dateToUtcString, generateIdempotencyToken, LazyJsonString } from "@smithy/core/serde"; import { Schema, ShapeSerializer } from "@smithy/types"; import { SerdeContextConfig } from "../ConfigurableSerdeContext"; @@ -110,11 +109,18 @@ export class JsonShapeSerializer extends SerdeContextConfig implements ShapeSeri } } - const mediaType = ns.getMergedTraits().mediaType; - if (ns.isStringSchema() && typeof value === "string" && mediaType) { - const isJson = mediaType === "application/json" || mediaType.endsWith("+json"); - if (isJson) { - return LazyJsonString.from(value); + if (ns.isStringSchema()) { + if (typeof value === "undefined" && ns.isIdempotencyToken()) { + return generateIdempotencyToken(); + } + + const mediaType = ns.getMergedTraits().mediaType; + + if (typeof value === "string" && mediaType) { + const isJson = mediaType === "application/json" || mediaType.endsWith("+json"); + if (isJson) { + return LazyJsonString.from(value); + } } } diff --git a/packages/core/src/submodules/protocols/json/jsonReplacer.ts b/packages/core/src/submodules/protocols/json/jsonReplacer.ts index ee9163fa6d95..92703f490cfb 100644 --- a/packages/core/src/submodules/protocols/json/jsonReplacer.ts +++ b/packages/core/src/submodules/protocols/json/jsonReplacer.ts @@ -33,7 +33,7 @@ export class JsonReplacer { return (key: string, value: unknown) => { if (value instanceof NumericValue) { - const v = `${NUMERIC_CONTROL_CHAR + +"nv" + this.counter++}_` + value.string; + const v = `${NUMERIC_CONTROL_CHAR + "nv" + this.counter++}_` + value.string; this.values.set(`"${v}"`, value.string); return v; } diff --git a/packages/core/src/submodules/protocols/query/QueryShapeSerializer.ts b/packages/core/src/submodules/protocols/query/QueryShapeSerializer.ts index 2ad004f23fe3..4076bf885463 100644 --- a/packages/core/src/submodules/protocols/query/QueryShapeSerializer.ts +++ b/packages/core/src/submodules/protocols/query/QueryShapeSerializer.ts @@ -1,6 +1,6 @@ import { determineTimestampFormat, extendedEncodeURIComponent } from "@smithy/core/protocols"; import { NormalizedSchema, SCHEMA } from "@smithy/core/schema"; -import { NumericValue } from "@smithy/core/serde"; +import { generateIdempotencyToken, NumericValue } from "@smithy/core/serde"; import { dateToUtcString } from "@smithy/smithy-client"; import type { Schema, ShapeSerializer } from "@smithy/types"; import { toBase64 } from "@smithy/util-base64"; @@ -36,6 +36,9 @@ export class QueryShapeSerializer extends SerdeContextConfig implements ShapeSer if (value != null) { this.writeKey(prefix); this.writeValue(String(value)); + } else if (ns.isIdempotencyToken()) { + this.writeKey(prefix); + this.writeValue(generateIdempotencyToken()); } } else if (ns.isBigIntegerSchema()) { if (value != null) { @@ -111,7 +114,7 @@ export class QueryShapeSerializer extends SerdeContextConfig implements ShapeSer } else if (ns.isStructSchema()) { if (value && typeof value === "object") { for (const [memberName, member] of ns.structIterator()) { - if ((value as any)[memberName] == null) { + if ((value as any)[memberName] == null && !member.isIdempotencyToken()) { continue; } const suffix = this.getKey(memberName, member.getMergedTraits().xmlName); diff --git a/packages/core/src/submodules/protocols/xml/XmlShapeSerializer.ts b/packages/core/src/submodules/protocols/xml/XmlShapeSerializer.ts index 4d78ce8f3b64..ef66c11dd711 100644 --- a/packages/core/src/submodules/protocols/xml/XmlShapeSerializer.ts +++ b/packages/core/src/submodules/protocols/xml/XmlShapeSerializer.ts @@ -1,6 +1,6 @@ import { XmlNode, XmlText } from "@aws-sdk/xml-builder"; import { NormalizedSchema, SCHEMA } from "@smithy/core/schema"; -import { NumericValue } from "@smithy/core/serde"; +import { generateIdempotencyToken, NumericValue } from "@smithy/core/serde"; import { dateToUtcString } from "@smithy/smithy-client"; import type { Schema as ISchema, ShapeSerializer } from "@smithy/types"; import { fromBase64, toBase64 } from "@smithy/util-base64"; @@ -86,7 +86,7 @@ export class XmlShapeSerializer extends SerdeContextConfig implements ShapeSeria for (const [memberName, memberSchema] of ns.structIterator()) { const val = (value as any)[memberName]; - if (val != null) { + if (val != null || memberSchema.isIdempotencyToken()) { if (memberSchema.getMergedTraits().xmlAttribute) { structXmlNode.addAttribute( memberSchema.getMergedTraits().xmlName ?? memberName, @@ -298,16 +298,18 @@ export class XmlShapeSerializer extends SerdeContextConfig implements ShapeSeria } } - if ( - ns.isStringSchema() || - ns.isBooleanSchema() || - ns.isNumericSchema() || - ns.isBigIntegerSchema() || - ns.isBigDecimalSchema() - ) { + if (ns.isBooleanSchema() || ns.isNumericSchema() || ns.isBigIntegerSchema() || ns.isBigDecimalSchema()) { nodeContents = String(value); } + if (ns.isStringSchema()) { + if (value === undefined && ns.isIdempotencyToken()) { + nodeContents = generateIdempotencyToken(); + } else { + nodeContents = String(value); + } + } + if (nodeContents === null) { throw new Error(`Unhandled schema-value pair ${ns.getName(true)}=${value}`); }