diff --git a/packages/core/package.json b/packages/core/package.json index ce3b573bc444..60ef2de70379 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -82,6 +82,7 @@ "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "*", + "@aws-sdk/xml-builder": "*", "@smithy/core": "^3.5.1", "@smithy/node-config-provider": "^4.1.3", "@smithy/property-provider": "^4.0.4", @@ -89,7 +90,10 @@ "@smithy/signature-v4": "^5.1.2", "@smithy/smithy-client": "^4.4.1", "@smithy/types": "^4.3.1", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", "@smithy/util-middleware": "^4.0.4", + "@smithy/util-utf8": "^4.0.0", "fast-xml-parser": "4.4.1", "tslib": "^2.6.2" }, diff --git a/packages/core/src/submodules/protocols/ConfigurableSerdeContext.ts b/packages/core/src/submodules/protocols/ConfigurableSerdeContext.ts new file mode 100644 index 000000000000..af2ba0fc84a1 --- /dev/null +++ b/packages/core/src/submodules/protocols/ConfigurableSerdeContext.ts @@ -0,0 +1,12 @@ +import { ConfigurableSerdeContext, SerdeFunctions } from "@smithy/types"; + +/** + * @internal + */ +export class SerdeContextConfig implements ConfigurableSerdeContext { + protected serdeContext?: SerdeFunctions; + + public setSerdeContext(serdeContext: SerdeFunctions): void { + this.serdeContext = serdeContext; + } +} diff --git a/packages/core/src/submodules/protocols/common.ts b/packages/core/src/submodules/protocols/common.ts index d4efe45bd265..d8c978525861 100644 --- a/packages/core/src/submodules/protocols/common.ts +++ b/packages/core/src/submodules/protocols/common.ts @@ -1,5 +1,5 @@ import { collectBody } from "@smithy/smithy-client"; -import type { HttpResponse, SerdeContext } from "@smithy/types"; +import type { SerdeFunctions } from "@smithy/types"; -export const collectBodyString = (streamBody: any, context: SerdeContext): Promise => +export const collectBodyString = (streamBody: any, context: SerdeFunctions): Promise => collectBody(streamBody, context).then((body) => context.utf8Encoder(body)); diff --git a/packages/core/src/submodules/protocols/index.ts b/packages/core/src/submodules/protocols/index.ts index 09a6ac214ca0..a93942b0399e 100644 --- a/packages/core/src/submodules/protocols/index.ts +++ b/packages/core/src/submodules/protocols/index.ts @@ -1,4 +1,17 @@ export * from "./coercing-serializers"; +export * from "./json/AwsJson1_0Protocol"; +export * from "./json/AwsJson1_1Protocol"; +export * from "./json/AwsJsonRpcProtocol"; +export * from "./json/AwsRestJsonProtocol"; +export * from "./json/JsonCodec"; +export * from "./json/JsonShapeDeserializer"; +export * from "./json/JsonShapeSerializer"; export * from "./json/awsExpectUnion"; export * from "./json/parseJsonBody"; +export * from "./query/AwsEc2QueryProtocol"; +export * from "./query/AwsQueryProtocol"; +export * from "./xml/AwsRestXmlProtocol"; +export * from "./xml/XmlCodec"; +export * from "./xml/XmlShapeDeserializer"; +export * from "./xml/XmlShapeSerializer"; export * from "./xml/parseXmlBody"; diff --git a/packages/core/src/submodules/protocols/json/AwsJson1_0Protocol.spec.ts b/packages/core/src/submodules/protocols/json/AwsJson1_0Protocol.spec.ts new file mode 100644 index 000000000000..c0941edaf9e4 --- /dev/null +++ b/packages/core/src/submodules/protocols/json/AwsJson1_0Protocol.spec.ts @@ -0,0 +1,123 @@ +import { map, op, SCHEMA, sim, struct } from "@smithy/core/schema"; +import { toBase64 } from "@smithy/util-base64"; +import { toUtf8 } from "@smithy/util-utf8"; +import { describe, expect, test as it } from "vitest"; + +import { context } from "../test-schema.spec"; +import { AwsJson1_0Protocol } from "./AwsJson1_0Protocol"; + +describe(AwsJson1_0Protocol.name, () => { + const json = { + string: "string", + number: 1234, + boolean: false, + blob: "AAAAAAAAAAA=", + timestamp: 0, + }; + const schema = struct( + "ns", + "MyStruct", + 0, + [...Object.keys(json)], + [SCHEMA.STRING, SCHEMA.NUMERIC, SCHEMA.BOOLEAN, SCHEMA.BLOB, SCHEMA.TIMESTAMP_DEFAULT] + ); + const serdeContext = { + base64Encoder: toBase64, + utf8Encoder: toUtf8, + } as any; + + describe("codec", () => { + it("serializes blobs and timestamps", () => { + const protocol = new AwsJson1_0Protocol({ + defaultNamespace: "namespace", + }); + protocol.setSerdeContext(serdeContext); + const codec = protocol.getPayloadCodec(); + const serializer = codec.createSerializer(); + const data = { + string: "string", + number: 1234, + boolean: false, + blob: new Uint8Array(8), + timestamp: new Date(0), + }; + serializer.write(schema, data); + const serialized = serializer.flush(); + expect(JSON.parse(serialized)).toEqual({ + string: "string", + number: 1234, + boolean: false, + blob: "AAAAAAAAAAA=", + timestamp: 0, + }); + }); + + it("deserializes blobs and timestamps", async () => { + const protocol = new AwsJson1_0Protocol({ + defaultNamespace: "namespace", + }); + protocol.setSerdeContext(serdeContext); + const codec = protocol.getPayloadCodec(); + const deserializer = codec.createDeserializer(); + + const parsed = await deserializer.read(schema, JSON.stringify(json)); + expect(parsed).toEqual({ + string: "string", + number: 1234, + boolean: false, + blob: new Uint8Array(8), + timestamp: new Date(0), + }); + }); + + it("ignores JSON name and HTTP bindings", async () => { + const protocol = new AwsJson1_0Protocol({ + defaultNamespace: "namespace", + }); + protocol.setSerdeContext(serdeContext); + + const schema = struct( + "ns", + "MyHttpBindingStructure", + {}, + ["header", "query", "headerMap", "payload"], + [ + sim("ns", "MyHeader", SCHEMA.STRING, { httpHeader: "header", jsonName: "MyHeader" }), + sim("ns", "MyQuery", SCHEMA.STRING, { httpQuery: "query" }), + map( + "ns", + "HeaderMap", + { + httpPrefixHeaders: "", + }, + SCHEMA.STRING, + SCHEMA.NUMERIC + ), + sim("ns", "MyPayload", SCHEMA.DOCUMENT, { httpPayload: 1 }), + ] + ); + const operationSchema = op("ns", "MyOperation", {}, schema, "unit"); + + const request = await protocol.serializeRequest( + operationSchema, + { + header: "hello", + query: "world", + headerMap: { + a: 1, + b: 2, + }, + }, + context + ); + + expect(request.headers).toEqual({ + "content-length": "60", + "content-type": "application/x-amz-json-1.0", + "x-amz-target": "JsonRpc10.MyOperation", + }); + expect(request.query).toEqual({}); + expect(request.body).toEqual(`{"header":"hello","query":"world","headerMap":{"a":1,"b":2}}`); + }); + }); +}); diff --git a/packages/core/src/submodules/protocols/json/AwsJson1_0Protocol.ts b/packages/core/src/submodules/protocols/json/AwsJson1_0Protocol.ts new file mode 100644 index 000000000000..caaf7bc30bfe --- /dev/null +++ b/packages/core/src/submodules/protocols/json/AwsJson1_0Protocol.ts @@ -0,0 +1,21 @@ +import { AwsJsonRpcProtocol } from "./AwsJsonRpcProtocol"; + +/** + * @alpha + * @see https://smithy.io/2.0/aws/protocols/aws-json-1_1-protocol.html#differences-between-awsjson1-0-and-awsjson1-1 + */ +export class AwsJson1_0Protocol extends AwsJsonRpcProtocol { + public constructor({ defaultNamespace }: { defaultNamespace: string }) { + super({ + defaultNamespace, + }); + } + + public getShapeId(): string { + return "aws.protocols#awsJson1_0"; + } + + protected getJsonRpcVersion() { + return "1.0" as const; + } +} diff --git a/packages/core/src/submodules/protocols/json/AwsJson1_1Protocol.spec.ts b/packages/core/src/submodules/protocols/json/AwsJson1_1Protocol.spec.ts new file mode 100644 index 000000000000..40c9b37bc7bd --- /dev/null +++ b/packages/core/src/submodules/protocols/json/AwsJson1_1Protocol.spec.ts @@ -0,0 +1,77 @@ +import { HttpResponse } from "@smithy/protocol-http"; +import { describe, expect, test as it } from "vitest"; + +import { context, deleteObjects } from "../test-schema.spec"; +import { AwsJson1_0Protocol } from "./AwsJson1_0Protocol"; + +/** + * These tests are cursory since most coverage is provided by protocol tests. + */ +describe(AwsJson1_0Protocol, () => { + it("is 1.0", async () => { + const protocol = new AwsJson1_0Protocol({ + defaultNamespace: "", + }); + expect(protocol.getShapeId()).toEqual("aws.protocols#awsJson1_0"); + }); + + it("serializes a request", async () => { + const protocol = new AwsJson1_0Protocol({ + defaultNamespace: "", + }); + const httpRequest = await protocol.serializeRequest( + deleteObjects, + { + Delete: { + Objects: [ + { + Key: "key1", + }, + { + Key: "key2", + }, + ], + }, + }, + context + ); + + expect(httpRequest.method).toEqual("POST"); + expect(httpRequest.body).toEqual( + JSON.stringify({ + Delete: { + Objects: [ + { + Key: "key1", + }, + { + Key: "key2", + }, + ], + }, + }) + ); + }); + + it("deserializes a response", async () => { + const httpResponse = new HttpResponse({ + statusCode: 200, + headers: {}, + }); + + const protocol = new AwsJson1_0Protocol({ + defaultNamespace: "", + }); + + const output = await protocol.deserializeResponse(deleteObjects, context, httpResponse); + + expect(output).toEqual({ + $metadata: { + httpStatusCode: 200, + requestId: undefined, + extendedRequestId: undefined, + cfId: undefined, + }, + }); + }); +}); diff --git a/packages/core/src/submodules/protocols/json/AwsJson1_1Protocol.ts b/packages/core/src/submodules/protocols/json/AwsJson1_1Protocol.ts new file mode 100644 index 000000000000..8f291c696269 --- /dev/null +++ b/packages/core/src/submodules/protocols/json/AwsJson1_1Protocol.ts @@ -0,0 +1,21 @@ +import { AwsJsonRpcProtocol } from "./AwsJsonRpcProtocol"; + +/** + * @alpha + * @see https://smithy.io/2.0/aws/protocols/aws-json-1_1-protocol.html#differences-between-awsjson1-0-and-awsjson1-1 + */ +export class AwsJson1_1Protocol extends AwsJsonRpcProtocol { + public constructor({ defaultNamespace }: { defaultNamespace: string }) { + super({ + defaultNamespace, + }); + } + + public getShapeId(): string { + return "aws.protocols#awsJson1_1"; + } + + protected getJsonRpcVersion() { + return "1.1" as const; + } +} diff --git a/packages/core/src/submodules/protocols/json/AwsJsonRpcProtocol.spec.ts b/packages/core/src/submodules/protocols/json/AwsJsonRpcProtocol.spec.ts new file mode 100644 index 000000000000..31db5f743f72 --- /dev/null +++ b/packages/core/src/submodules/protocols/json/AwsJsonRpcProtocol.spec.ts @@ -0,0 +1,31 @@ +import { SCHEMA } from "@smithy/core/schema"; +import { describe, expect, test as it } from "vitest"; + +import { AwsJsonRpcProtocol } from "./AwsJsonRpcProtocol"; + +describe(AwsJsonRpcProtocol.name, () => { + it("has expected codec settings", async () => { + const protocol = new (class extends AwsJsonRpcProtocol { + constructor() { + super({ defaultNamespace: "" }); + } + + getShapeId(): string { + throw new Error("Method not implemented."); + } + + protected getJsonRpcVersion(): "1.1" | "1.0" { + throw new Error("Method not implemented."); + } + })(); + + const codec = protocol.getPayloadCodec(); + expect(codec.settings).toEqual({ + jsonName: false, + timestampFormat: { + default: SCHEMA.TIMESTAMP_EPOCH_SECONDS, + useTrait: true, + }, + }); + }); +}); diff --git a/packages/core/src/submodules/protocols/json/AwsJsonRpcProtocol.ts b/packages/core/src/submodules/protocols/json/AwsJsonRpcProtocol.ts new file mode 100644 index 000000000000..3594a3ffec8d --- /dev/null +++ b/packages/core/src/submodules/protocols/json/AwsJsonRpcProtocol.ts @@ -0,0 +1,129 @@ +import { RpcProtocol } from "@smithy/core/protocols"; +import { deref, ErrorSchema, NormalizedSchema, SCHEMA, TypeRegistry } from "@smithy/core/schema"; +import { + EndpointBearer, + HandlerExecutionContext, + HttpRequest, + HttpResponse, + OperationSchema, + ResponseMetadata, + SerdeFunctions, + ShapeDeserializer, + ShapeSerializer, +} from "@smithy/types"; +import { calculateBodyLength } from "@smithy/util-body-length-browser"; + +import { JsonCodec } from "./JsonCodec"; +import { loadRestJsonErrorCode } from "./parseJsonBody"; + +/** + * @alpha + */ +export abstract class AwsJsonRpcProtocol extends RpcProtocol { + protected serializer: ShapeSerializer; + protected deserializer: ShapeDeserializer; + private codec: JsonCodec; + + protected constructor({ defaultNamespace }: { defaultNamespace: string }) { + super({ + defaultNamespace, + }); + this.codec = new JsonCodec({ + timestampFormat: { + useTrait: true, + default: SCHEMA.TIMESTAMP_EPOCH_SECONDS, + }, + jsonName: false, + }); + this.serializer = this.codec.createSerializer(); + this.deserializer = this.codec.createDeserializer(); + } + + public async serializeRequest( + operationSchema: OperationSchema, + input: Input, + context: HandlerExecutionContext & SerdeFunctions & EndpointBearer + ): Promise { + const request = await super.serializeRequest(operationSchema, input, context); + if (!request.path.endsWith("/")) { + request.path += "/"; + } + Object.assign(request.headers, { + "content-type": `application/x-amz-json-${this.getJsonRpcVersion()}`, + "x-amz-target": + (this.getJsonRpcVersion() === "1.0" ? `JsonRpc10.` : `JsonProtocol.`) + + NormalizedSchema.of(operationSchema).getName(), + }); + if (deref(operationSchema.input) === "unit" || !request.body) { + request.body = "{}"; + } + try { + request.headers["content-length"] = String(calculateBodyLength(request.body)); + } catch (e) {} + return request; + } + + public getPayloadCodec(): JsonCodec { + return this.codec; + } + + protected abstract getJsonRpcVersion(): "1.1" | "1.0"; + + protected async handleError( + operationSchema: OperationSchema, + context: HandlerExecutionContext & SerdeFunctions, + response: HttpResponse, + dataObject: any, + metadata: ResponseMetadata + ): Promise { + // loadRestJsonErrorCode is still used in JSON RPC. + const errorIdentifier = loadRestJsonErrorCode(response, dataObject) ?? "Unknown"; + + let namespace = this.options.defaultNamespace; + let errorName = errorIdentifier; + if (errorIdentifier.includes("#")) { + [namespace, errorName] = errorIdentifier.split("#"); + } + + const registry = TypeRegistry.for(namespace); + let errorSchema: ErrorSchema; + try { + errorSchema = registry.getSchema(errorIdentifier) as ErrorSchema; + } catch (e) { + const baseExceptionSchema = TypeRegistry.for("awssdkjs.synthetic." + namespace).getBaseException(); + if (baseExceptionSchema) { + const ErrorCtor = baseExceptionSchema.ctor; + throw Object.assign(new ErrorCtor(errorName), dataObject); + } + throw new Error(errorName); + } + + const ns = NormalizedSchema.of(errorSchema); + const message = dataObject.message ?? dataObject.Message ?? "Unknown"; + const exception = new errorSchema.ctor(message); + + const headerBindings = new Set( + Object.values(NormalizedSchema.of(errorSchema).getMemberSchemas()) + .map((schema) => { + return schema.getMergedTraits().httpHeader; + }) + .filter(Boolean) as string[] + ); + await this.deserializeHttpMessage(errorSchema, context, response, headerBindings, dataObject); + const output = {} as any; + for (const [name, member] of ns.structIterator()) { + const target = member.getMergedTraits().jsonName ?? name; + output[name] = this.codec.createDeserializer().readObject(member, dataObject[target]); + } + + Object.assign(exception, { + $metadata: metadata, + $response: response, + $fault: ns.getMergedTraits().error, + message, + ...output, + }); + + throw exception; + } +} diff --git a/packages/core/src/submodules/protocols/json/AwsRestJsonProtocol.spec.ts b/packages/core/src/submodules/protocols/json/AwsRestJsonProtocol.spec.ts new file mode 100644 index 000000000000..262588cdd1f9 --- /dev/null +++ b/packages/core/src/submodules/protocols/json/AwsRestJsonProtocol.spec.ts @@ -0,0 +1,270 @@ +import { op, SCHEMA, sim, struct } from "@smithy/core/schema"; +import { HttpResponse } from "@smithy/protocol-http"; +import { toBase64 } from "@smithy/util-base64"; +import { toUtf8 } from "@smithy/util-utf8"; +import { describe, expect, test as it } from "vitest"; + +import { context } from "../test-schema.spec"; +import { AwsRestJsonProtocol } from "./AwsRestJsonProtocol"; + +describe(AwsRestJsonProtocol.name, () => { + const json = { + string: "string", + number: 1234, + boolean: false, + blob: "AAAAAAAAAAA=", + timestamp: 0, + }; + const schema = struct( + "ns", + "MyStruct", + 0, + [...Object.keys(json)], + [SCHEMA.STRING, SCHEMA.NUMERIC, SCHEMA.BOOLEAN, SCHEMA.BLOB, SCHEMA.TIMESTAMP_DEFAULT] + ); + const serdeContext = { + base64Encoder: toBase64, + utf8Encoder: toUtf8, + } as any; + + describe("codec", () => { + it("serializes blobs and timestamps", () => { + const protocol = new AwsRestJsonProtocol({ + defaultNamespace: "ns", + }); + protocol.setSerdeContext(serdeContext); + const codec = protocol.getPayloadCodec(); + const serializer = codec.createSerializer(); + const data = { + string: "string", + number: 1234, + boolean: false, + blob: new Uint8Array(8), + timestamp: new Date(0), + }; + serializer.write(schema, data); + const serialized = serializer.flush(); + expect(JSON.parse(serialized)).toEqual({ + string: "string", + number: 1234, + boolean: false, + blob: "AAAAAAAAAAA=", + timestamp: 0, + }); + }); + + it("deserializes blobs and timestamps", async () => { + const protocol = new AwsRestJsonProtocol({ + defaultNamespace: "ns", + }); + protocol.setSerdeContext(serdeContext); + const codec = protocol.getPayloadCodec(); + const deserializer = codec.createDeserializer(); + const parsed = await deserializer.read(schema, JSON.stringify(json)); + expect(parsed).toEqual({ + string: "string", + number: 1234, + boolean: false, + blob: new Uint8Array(8), + timestamp: new Date(0), + }); + }); + }); + + describe("protocol", async () => { + const protocol = new AwsRestJsonProtocol({ + defaultNamespace: "ns", + }); + protocol.setSerdeContext(serdeContext); + + const operationSchema = op( + "ns", + "MyOperation", + {}, + struct( + "ns", + "MyHttpBindingStructureRequest", + {}, + ["header", "query", "headerMap", "payload"], + [ + [SCHEMA.STRING, { httpHeader: "header" }], + [SCHEMA.STRING, { httpQuery: "query" }], + [ + SCHEMA.MAP_MODIFIER | SCHEMA.NUMERIC, + { + httpPrefixHeaders: "", + }, + ], + [ + struct( + "ns", + "PayloadStruct", + 0, + ["a", "b"], + [ + [SCHEMA.STRING, 0], + [SCHEMA.STRING, { jsonName: "JSON_NAME" }], + ] + ), + { httpPayload: 1 }, + ], + ] + ), + struct( + "ns", + "MyHttpBindingStructureResponse", + {}, + ["header", "code", "headerMap", "payload"], + [ + [SCHEMA.STRING, { httpHeader: "header" }], + [SCHEMA.NUMERIC, { httpResponseCode: 1 }], + [ + SCHEMA.MAP_MODIFIER | SCHEMA.NUMERIC, + { + httpPrefixHeaders: "x-", + }, + ], + [ + struct( + "ns", + "PayloadStruct", + { httpPayload: 1 }, + ["a", "b"], + [ + [SCHEMA.STRING, 0], + [SCHEMA.STRING, { jsonName: "JSON_NAME" }], + ] + ), + { httpPayload: 1 }, + ], + ] + ) + ); + + it("obeys jsonName and HTTP bindings during serialization", async () => { + const request = await protocol.serializeRequest( + operationSchema, + { + header: "hello", + query: "world", + headerMap: { + a: 1, + b: 2, + c: 3, + }, + payload: { + a: "a", + b: "b", + }, + }, + context + ); + + expect(request.headers).toEqual({ + "content-length": "25", + "content-type": "application/json", + header: "hello", + a: "1", + b: "2", + c: "3", + }); + expect(request.query).toEqual({ + query: "world", + }); + expect(request.body).toEqual(`{"a":"a","JSON_NAME":"b"}`); + }); + + it("obeys jsonName and HTTP bindings and deserialization", async () => { + const output = await protocol.deserializeResponse( + operationSchema, + {} as any, + new HttpResponse({ + statusCode: 200, + headers: { header: "hello", "x-a": "1", "x-b": "2", "x-c": "3" }, + body: Buffer.from( + JSON.stringify({ + a: "a", + JSON_NAME: "b", + }) + ), + }) + ); + + expect(output).toEqual({ + $metadata: { + cfId: undefined, + extendedRequestId: undefined, + httpStatusCode: 200, + requestId: undefined, + }, + header: "hello", + code: 200, + headerMap: { + a: 1, + b: 2, + c: 3, + }, + payload: { + a: "a", + b: "b", + }, + }); + }); + + it("selects the correct timestamp format based on http binding location", async () => { + const request = await protocol.serializeRequest( + op( + "ns", + "", + 0, + struct( + "ns", + "", + 0, + [ + "headerDefaultDate", + "headerMemberTraitDate", + "headerHttpDate", + "headerEpochSeconds", + "headerTargetTraitDate", + "queryDefaultDate", + "payloadDefaultDate", + ], + [ + [SCHEMA.TIMESTAMP_DEFAULT, { httpHeader: "header-default-date" }], + [SCHEMA.TIMESTAMP_DATE_TIME, { httpHeader: "header-member-trait-date" }], + [SCHEMA.TIMESTAMP_HTTP_DATE, { httpHeader: "header-http-date" }], + [SCHEMA.TIMESTAMP_EPOCH_SECONDS, { httpHeader: "header-epoch-seconds" }], + [sim("ns", "", SCHEMA.TIMESTAMP_EPOCH_SECONDS, 0), 0], + [SCHEMA.TIMESTAMP_DEFAULT, { httpQuery: "query-default-date" }], + [SCHEMA.TIMESTAMP_DEFAULT, { httpPayload: 1 }], + ] + ), + "unit" + ), + { + headerDefaultDate: new Date(0), + headerMemberTraitDate: new Date(0), + headerHttpDate: new Date(0), + headerEpochSeconds: new Date(0), + headerTargetTraitDate: new Date(0), + queryDefaultDate: new Date(0), + payloadDefaultDate: new Date(0), + }, + context + ); + + expect(request.headers).toEqual({ + "content-length": "50", + "content-type": "application/json", + "header-default-date": "Thu, 01 Jan 1970 00:00:00 GMT", + "header-member-trait-date": "1970-01-01T00:00:00Z", + "header-epoch-seconds": "0", + "header-http-date": "Thu, 01 Jan 1970 00:00:00 GMT", + }); + expect(request.query).toEqual({ + "query-default-date": "1970-01-01T00:00:00Z", + }); + }); + }); +}); diff --git a/packages/core/src/submodules/protocols/json/AwsRestJsonProtocol.ts b/packages/core/src/submodules/protocols/json/AwsRestJsonProtocol.ts new file mode 100644 index 000000000000..dde4bbef9ddc --- /dev/null +++ b/packages/core/src/submodules/protocols/json/AwsRestJsonProtocol.ts @@ -0,0 +1,167 @@ +import { + HttpBindingProtocol, + HttpInterceptingShapeDeserializer, + HttpInterceptingShapeSerializer, +} from "@smithy/core/protocols"; +import { ErrorSchema, NormalizedSchema, SCHEMA, TypeRegistry } from "@smithy/core/schema"; +import { + EndpointBearer, + HandlerExecutionContext, + HttpRequest, + HttpResponse, + OperationSchema, + ResponseMetadata, + SerdeFunctions, + ShapeDeserializer, + ShapeSerializer, +} from "@smithy/types"; +import { calculateBodyLength } from "@smithy/util-body-length-browser"; + +import { JsonCodec, JsonSettings } from "./JsonCodec"; +import { loadRestJsonErrorCode } from "./parseJsonBody"; + +/** + * @alpha + */ +export class AwsRestJsonProtocol extends HttpBindingProtocol { + protected serializer: ShapeSerializer; + protected deserializer: ShapeDeserializer; + private readonly codec: JsonCodec; + + public constructor({ defaultNamespace }: { defaultNamespace: string }) { + super({ + defaultNamespace, + }); + const settings: JsonSettings = { + timestampFormat: { + useTrait: true, + default: SCHEMA.TIMESTAMP_EPOCH_SECONDS, + }, + httpBindings: true, + jsonName: true, + }; + this.codec = new JsonCodec(settings); + this.serializer = new HttpInterceptingShapeSerializer(this.codec.createSerializer(), settings); + this.deserializer = new HttpInterceptingShapeDeserializer(this.codec.createDeserializer(), settings); + } + + public getShapeId(): string { + return "aws.protocols#restJson1"; + } + + public getPayloadCodec() { + return this.codec; + } + + public setSerdeContext(serdeContext: SerdeFunctions) { + this.codec.setSerdeContext(serdeContext); + super.setSerdeContext(serdeContext); + } + + public async serializeRequest( + operationSchema: OperationSchema, + input: Input, + context: HandlerExecutionContext & SerdeFunctions & EndpointBearer + ): Promise { + const request = await super.serializeRequest(operationSchema, input, context); + const inputSchema = NormalizedSchema.of(operationSchema.input); + const members = inputSchema.getMemberSchemas(); + + if (!request.headers["content-type"]) { + const httpPayloadMember = Object.values(members).find((m) => { + return !!m.getMergedTraits().httpPayload; + }); + + if (httpPayloadMember) { + const mediaType = httpPayloadMember.getMergedTraits().mediaType as string; + if (mediaType) { + request.headers["content-type"] = mediaType; + } else if (httpPayloadMember.isStringSchema()) { + request.headers["content-type"] = "text/plain"; + } else if (httpPayloadMember.isBlobSchema()) { + request.headers["content-type"] = "application/octet-stream"; + } else { + request.headers["content-type"] = "application/json"; + } + } else if (!inputSchema.isUnitSchema()) { + const hasBody = Object.values(members).find((m) => { + const { httpQuery, httpQueryParams, httpHeader, httpLabel, httpPrefixHeaders } = m.getMergedTraits(); + return !httpQuery && !httpQueryParams && !httpHeader && !httpLabel && httpPrefixHeaders === void 0; + }); + if (hasBody) { + request.headers["content-type"] = "application/json"; + } + } + } + + if (request.headers["content-type"] && !request.body) { + request.body = "{}"; + } + + if (request.body) { + try { + // todo(schema): use config.bodyLengthChecker or move that into serdeContext. + request.headers["content-length"] = String(calculateBodyLength(request.body)); + } catch (e) {} + } + + return request; + } + + protected async handleError( + operationSchema: OperationSchema, + context: HandlerExecutionContext & SerdeFunctions, + response: HttpResponse, + dataObject: any, + metadata: ResponseMetadata + ): Promise { + const errorIdentifier = loadRestJsonErrorCode(response, dataObject) ?? "Unknown"; + + let namespace = this.options.defaultNamespace; + let errorName = errorIdentifier; + if (errorIdentifier.includes("#")) { + [namespace, errorName] = errorIdentifier.split("#"); + } + + const registry = TypeRegistry.for(namespace); + let errorSchema: ErrorSchema; + try { + errorSchema = registry.getSchema(errorIdentifier) as ErrorSchema; + } catch (e) { + const baseExceptionSchema = TypeRegistry.for("awssdkjs.synthetic." + namespace).getBaseException(); + if (baseExceptionSchema) { + const ErrorCtor = baseExceptionSchema.ctor; + throw Object.assign(new ErrorCtor(errorName), dataObject); + } + throw new Error(errorName); + } + + const ns = NormalizedSchema.of(errorSchema); + const message = dataObject.message ?? dataObject.Message ?? "Unknown"; + const exception = new errorSchema.ctor(message); + + const headerBindings = new Set( + Object.values(NormalizedSchema.of(errorSchema).getMemberSchemas()) + .map((schema) => { + return schema.getMergedTraits().httpHeader; + }) + .filter(Boolean) as string[] + ); + await this.deserializeHttpMessage(errorSchema, context, response, headerBindings, dataObject); + const output = {} as any; + for (const [name, member] of ns.structIterator()) { + const target = member.getMergedTraits().jsonName ?? name; + output[name] = this.codec.createDeserializer().readObject(member, dataObject[target]); + } + + Object.assign(exception, { + $metadata: metadata, + $response: response, + $fault: ns.getMergedTraits().error, + message, + ...output, + }); + + throw exception; + } +} diff --git a/packages/core/src/submodules/protocols/json/JsonCodec.spec.ts b/packages/core/src/submodules/protocols/json/JsonCodec.spec.ts new file mode 100644 index 000000000000..989d95022e07 --- /dev/null +++ b/packages/core/src/submodules/protocols/json/JsonCodec.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, test as it, vi } from "vitest"; + +import { JsonCodec } from "./JsonCodec"; +import { JsonShapeDeserializer } from "./JsonShapeDeserializer"; +import { JsonShapeSerializer } from "./JsonShapeSerializer"; + +describe(JsonCodec.name, () => { + it("provides a serializer", () => { + const codec = new JsonCodec({ + jsonName: false, + timestampFormat: { default: 7, useTrait: false }, + }); + + const serializer = codec.createSerializer(); + expect(serializer.settings).toEqual(codec.settings); + }); + + it("provides a deserializer", () => { + const codec = new JsonCodec({ + jsonName: false, + timestampFormat: { default: 7, useTrait: false }, + }); + + const deserializer = codec.createDeserializer(); + expect(deserializer.settings).toEqual(codec.settings); + }); + + it("propagates serdeContext to its serde providers", () => { + const codec = new JsonCodec({ + jsonName: false, + timestampFormat: { default: 7, useTrait: false }, + }); + + vi.spyOn(JsonShapeSerializer.prototype, "setSerdeContext"); + vi.spyOn(JsonShapeDeserializer.prototype, "setSerdeContext"); + const serdeContext = {} as any; + codec.setSerdeContext(serdeContext); + codec.createSerializer(); + expect(JsonShapeSerializer.prototype.setSerdeContext).toHaveBeenCalledWith(serdeContext); + codec.createDeserializer(); + expect(JsonShapeDeserializer.prototype.setSerdeContext).toHaveBeenCalledWith(serdeContext); + }); +}); diff --git a/packages/core/src/submodules/protocols/json/JsonCodec.ts b/packages/core/src/submodules/protocols/json/JsonCodec.ts new file mode 100644 index 000000000000..9f5ae0064ffa --- /dev/null +++ b/packages/core/src/submodules/protocols/json/JsonCodec.ts @@ -0,0 +1,33 @@ +import { Codec, CodecSettings, ShapeDeserializer, ShapeSerializer } from "@smithy/types"; + +import { SerdeContextConfig } from "../ConfigurableSerdeContext"; +import { JsonShapeDeserializer } from "./JsonShapeDeserializer"; +import { JsonShapeSerializer } from "./JsonShapeSerializer"; + +/** + * @alpha + */ +export type JsonSettings = CodecSettings & { + jsonName: boolean; +}; + +/** + * @public + */ +export class JsonCodec extends SerdeContextConfig implements Codec { + public constructor(public settings: JsonSettings) { + super(); + } + + public createSerializer(): JsonShapeSerializer { + const serializer = new JsonShapeSerializer(this.settings); + serializer.setSerdeContext(this.serdeContext!); + return serializer; + } + + public createDeserializer(): JsonShapeDeserializer { + const deserializer = new JsonShapeDeserializer(this.settings); + deserializer.setSerdeContext(this.serdeContext!); + return deserializer; + } +} diff --git a/packages/core/src/submodules/protocols/json/JsonShapeDeserializer.spec.ts b/packages/core/src/submodules/protocols/json/JsonShapeDeserializer.spec.ts new file mode 100644 index 000000000000..96671d5fc1d4 --- /dev/null +++ b/packages/core/src/submodules/protocols/json/JsonShapeDeserializer.spec.ts @@ -0,0 +1,156 @@ +import { SCHEMA } from "@smithy/core/schema"; +import { NumericValue } from "@smithy/core/serde"; +import { describe, expect, test as it } from "vitest"; + +import { widget } from "../test-schema.spec"; +import { JsonShapeDeserializer } from "./JsonShapeDeserializer"; + +describe(JsonShapeDeserializer.name, () => { + let contextSourceAvailable = false; + JSON.parse(`{ "key": 1 }`, function (key, value, context?: { source?: string }) { + if (context?.source) { + contextSourceAvailable = true; + } + }); + + const deserializer = new JsonShapeDeserializer({ + jsonName: true, + timestampFormat: { default: SCHEMA.TIMESTAMP_EPOCH_SECONDS, useTrait: true }, + }); + + it("understands list sparseness", async () => { + const json = JSON.stringify({ + list: ["a", "b", null, "c"], + sparseList: ["a", "b", null, "c"], + }); + + const data = await deserializer.read(widget, json); + expect(data).toEqual({ + list: ["a", "b", "c"], + sparseList: ["a", "b", null, "c"], + }); + }); + + it("understands map sparseness", async () => { + const json = JSON.stringify({ + map: { + a: "a", + b: "b", + c: null, + }, + sparseMap: { + a: "a", + b: "b", + c: null, + }, + }); + + const data = await deserializer.read(widget, json); + expect(data).toEqual({ + map: { + a: "a", + b: "b", + }, + sparseMap: { + a: "a", + b: "b", + c: null, + }, + }); + }); + + it("deserializes base64 to blob", async () => { + expect( + await deserializer.read( + widget, + JSON.stringify({ + blob: "AAAA", + }) + ) + ).toEqual({ + blob: new Uint8Array([0, 0, 0]), + }); + }); + + it("deserializes JSON media type", async () => { + expect( + ( + await deserializer.read( + widget, + JSON.stringify({ + media: `{ "data": 1 }`, + }) + ) + ).media.deserializeJSON() + ).toEqual({ data: 1 }); + }); + + it("deserializes timestamps", async () => { + expect( + await deserializer.read( + widget, + JSON.stringify({ + timestamp: 0, + }) + ) + ).toEqual({ + timestamp: new Date(0), + }); + }); + + it("deserializes big integers from string or number", async () => { + expect( + await deserializer.read( + widget, + `{ + "bigint": "1000000000000000000000000000000000000" + }` + ) + ).toEqual({ + bigint: 1000000000000000000000000000000000000n, + }); + + const impreciseParsing = 1000000000000000042420637374017961984n; + + expect( + await deserializer.read( + widget, + `{ + "bigint": 1000000000000000000000000000000000000 + }` + ) + ).toEqual({ + bigint: contextSourceAvailable ? 1000000000000000000000000000000000000n : impreciseParsing, + }); + }); + + it("deserializes big decimals", async () => { + expect( + await deserializer.read( + widget, + `{ + "bigdecimal": "0.0000000000000000000000000000000000001" + }` + ) + ).toEqual({ + bigdecimal: new NumericValue("0.0000000000000000000000000000000000001", "bigDecimal"), + }); + + expect( + await deserializer.read( + widget, + `{ + "bigdecimal": 0.0001 + }` + ) + ).toEqual({ + bigdecimal: new NumericValue("0.0001", "bigDecimal"), + }); + }); + + it("deserializes infinite and NaN numerics", async () => { + expect(await deserializer.read(widget, JSON.stringify({ scalar: "Infinity" }))).toEqual({ scalar: Infinity }); + expect(await deserializer.read(widget, JSON.stringify({ scalar: "-Infinity" }))).toEqual({ scalar: -Infinity }); + expect(await deserializer.read(widget, JSON.stringify({ scalar: "NaN" }))).toEqual({ scalar: NaN }); + }); +}); diff --git a/packages/core/src/submodules/protocols/json/JsonShapeDeserializer.ts b/packages/core/src/submodules/protocols/json/JsonShapeDeserializer.ts new file mode 100644 index 000000000000..e75bea4be6b2 --- /dev/null +++ b/packages/core/src/submodules/protocols/json/JsonShapeDeserializer.ts @@ -0,0 +1,132 @@ +import { NormalizedSchema, SCHEMA } from "@smithy/core/schema"; +import { + LazyJsonString, + NumericValue, + parseEpochTimestamp, + parseRfc3339DateTimeWithOffset, + parseRfc7231DateTime, +} from "@smithy/core/serde"; +import { DocumentType, Schema, ShapeDeserializer } from "@smithy/types"; +import { fromBase64 } from "@smithy/util-base64"; + +import { SerdeContextConfig } from "../ConfigurableSerdeContext"; +import { JsonSettings } from "./JsonCodec"; +import { jsonReviver } from "./jsonReviver"; +import { parseJsonBody } from "./parseJsonBody"; + +/** + * @alpha + */ +export class JsonShapeDeserializer extends SerdeContextConfig implements ShapeDeserializer { + public constructor(public settings: JsonSettings) { + super(); + } + + public async read(schema: Schema, data: string | Uint8Array | unknown): Promise { + return this._read( + schema, + typeof data === "string" ? JSON.parse(data, jsonReviver) : await parseJsonBody(data, this.serdeContext!) + ); + } + + public readObject(schema: Schema, data: DocumentType): any { + return this._read(schema, data); + } + + private _read(schema: Schema, value: unknown): any { + const isObject = value !== null && typeof value === "object"; + + const ns = NormalizedSchema.of(schema); + + // === aggregate types === + if (ns.isListSchema() && Array.isArray(value)) { + const listMember = ns.getValueSchema(); + const out = [] as any[]; + const sparse = !!ns.getMergedTraits().sparse; + for (const item of value) { + if (sparse || item != null) { + out.push(this._read(listMember, item)); + } + } + return out; + } else if (ns.isMapSchema() && isObject) { + const mapMember = ns.getValueSchema(); + const out = {} as any; + const sparse = !!ns.getMergedTraits().sparse; + for (const [_k, _v] of Object.entries(value)) { + if (sparse || _v != null) { + out[_k] = this._read(mapMember, _v); + } + } + return out; + } else if (ns.isStructSchema() && isObject) { + const out = {} as any; + for (const [memberName, memberSchema] of ns.structIterator()) { + const fromKey = this.settings.jsonName ? memberSchema.getMergedTraits().jsonName ?? memberName : memberName; + const deserializedValue = this._read(memberSchema, (value as any)[fromKey]); + if (deserializedValue != null) { + out[memberName] = deserializedValue; + } + } + return out; + } + + // === simple types === + if (ns.isBlobSchema() && typeof value === "string") { + return fromBase64(value); + } + + 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.isTimestampSchema()) { + const options = this.settings.timestampFormat; + const format = options.useTrait + ? ns.getSchema() === SCHEMA.TIMESTAMP_DEFAULT + ? options.default + : ns.getSchema() ?? options.default + : options.default; + switch (format) { + case SCHEMA.TIMESTAMP_DATE_TIME: + return parseRfc3339DateTimeWithOffset(value); + case SCHEMA.TIMESTAMP_HTTP_DATE: + return parseRfc7231DateTime(value); + case SCHEMA.TIMESTAMP_EPOCH_SECONDS: + return parseEpochTimestamp(value); + default: + console.warn("Missing timestamp format, parsing value with Date constructor:", value); + return new Date(value as string | number); + } + } + + if (ns.isBigIntegerSchema() && (typeof value === "number" || typeof value === "string")) { + return BigInt(value as number | string); + } + + if (ns.isBigDecimalSchema() && value != undefined) { + if (value instanceof NumericValue) { + return value; + } + return new NumericValue(String(value), "bigDecimal"); + } + + if (ns.isNumericSchema() && typeof value === "string") { + switch (value) { + case "Infinity": + return Infinity; + case "-Infinity": + return -Infinity; + case "NaN": + return NaN; + } + } + + // covers string, numeric, boolean, document, bigDecimal + return value; + } +} diff --git a/packages/core/src/submodules/protocols/json/JsonShapeSerializer.spec.ts b/packages/core/src/submodules/protocols/json/JsonShapeSerializer.spec.ts new file mode 100644 index 000000000000..aea832e7b723 --- /dev/null +++ b/packages/core/src/submodules/protocols/json/JsonShapeSerializer.spec.ts @@ -0,0 +1,32 @@ +import { SCHEMA } from "@smithy/core/schema"; +import { NumericValue } from "@smithy/core/serde"; +import { describe, expect, test as it } from "vitest"; + +import { widget } from "../test-schema.spec"; +import { JsonShapeSerializer } from "./JsonShapeSerializer"; + +describe(JsonShapeSerializer.name, () => { + it("serializes data to JSON", async () => { + const serializer = new JsonShapeSerializer({ + jsonName: true, + timestampFormat: { default: SCHEMA.TIMESTAMP_EPOCH_SECONDS, useTrait: true }, + }); + serializer.setSerdeContext({ + base64Encoder: (input: Uint8Array) => { + return Buffer.from(input).toString("base64"); + }, + } as any); + + const data = { + timestamp: new Date(0), + bigint: 10000000000000000000000054321n, + bigdecimal: new NumericValue("0.10000000000000000000000054321", "bigDecimal"), + blob: new Uint8Array([0, 0, 0, 1]), + }; + serializer.write(widget, data); + const serialization = serializer.flush(); + expect(serialization).toEqual( + `{"blob":"AAAAAQ==","timestamp":0,"bigint":10000000000000000000000054321,"bigdecimal":0.10000000000000000000000054321}` + ); + }); +}); diff --git a/packages/core/src/submodules/protocols/json/JsonShapeSerializer.ts b/packages/core/src/submodules/protocols/json/JsonShapeSerializer.ts new file mode 100644 index 000000000000..e9e558f38bce --- /dev/null +++ b/packages/core/src/submodules/protocols/json/JsonShapeSerializer.ts @@ -0,0 +1,123 @@ +import { NormalizedSchema, SCHEMA } from "@smithy/core/schema"; +import { dateToUtcString } from "@smithy/core/serde"; +import { LazyJsonString } from "@smithy/core/serde"; +import { Schema, ShapeSerializer } from "@smithy/types"; + +import { SerdeContextConfig } from "../ConfigurableSerdeContext"; +import { JsonSettings } from "./JsonCodec"; +import { JsonReplacer } from "./jsonReplacer"; + +/** + * @alpha + */ +export class JsonShapeSerializer extends SerdeContextConfig implements ShapeSerializer { + private buffer: any; + private rootSchema: NormalizedSchema | undefined; + + public constructor(public settings: JsonSettings) { + super(); + } + + public write(schema: Schema, value: unknown): void { + this.rootSchema = NormalizedSchema.of(schema); + this.buffer = this._write(this.rootSchema, value); + } + + public flush(): string { + if (this.rootSchema?.isStructSchema() || this.rootSchema?.isDocumentSchema()) { + const replacer = new JsonReplacer(); + return replacer.replaceInJson(JSON.stringify(this.buffer, replacer.createReplacer(), 0)); + } + return this.buffer; + } + + private _write(schema: Schema, value: unknown, container?: NormalizedSchema): any { + const isObject = value !== null && typeof value === "object"; + + const ns = NormalizedSchema.of(schema); + + // === aggregate types === + if (ns.isListSchema() && Array.isArray(value)) { + const listMember = ns.getValueSchema(); + const out = [] as any[]; + const sparse = !!ns.getMergedTraits().sparse; + for (const item of value) { + if (sparse || item != null) { + out.push(this._write(listMember, item)); + } + } + return out; + } else if (ns.isMapSchema() && isObject) { + const mapMember = ns.getValueSchema(); + const out = {} as any; + const sparse = !!ns.getMergedTraits().sparse; + for (const [_k, _v] of Object.entries(value)) { + if (sparse || _v != null) { + out[_k] = this._write(mapMember, _v); + } + } + return out; + } else if (ns.isStructSchema() && isObject) { + const out = {} as any; + for (const [memberName, memberSchema] of ns.structIterator()) { + const targetKey = this.settings.jsonName ? memberSchema.getMergedTraits().jsonName ?? memberName : memberName; + const serializableValue = this._write(memberSchema, (value as any)[memberName], ns); + if (serializableValue !== undefined) { + out[targetKey] = serializableValue; + } + } + return out; + } + + // === simple types === + if (value === null && container?.isStructSchema()) { + return void 0; + } + + if (ns.isBlobSchema() && (value instanceof Uint8Array || typeof value === "string")) { + if (ns === this.rootSchema) { + return value; + } + if (!this.serdeContext?.base64Encoder) { + throw new Error("Missing base64Encoder in serdeContext"); + } + 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; + switch (format) { + case SCHEMA.TIMESTAMP_DATE_TIME: + return value.toISOString().replace(".000Z", "Z"); + case SCHEMA.TIMESTAMP_HTTP_DATE: + return dateToUtcString(value); + case SCHEMA.TIMESTAMP_EPOCH_SECONDS: + return value.getTime() / 1000; + default: + console.warn("Missing timestamp format, using epoch seconds", value); + return value.getTime() / 1000; + } + } + + if (ns.isNumericSchema() && typeof value === "number") { + if (Math.abs(value) === Infinity || isNaN(value)) { + return String(value); + } + } + + 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); + } + } + + return value; + } +} diff --git a/packages/core/src/submodules/protocols/json/jsonReplacer.spec.ts b/packages/core/src/submodules/protocols/json/jsonReplacer.spec.ts new file mode 100644 index 000000000000..753f6f2ca611 --- /dev/null +++ b/packages/core/src/submodules/protocols/json/jsonReplacer.spec.ts @@ -0,0 +1,42 @@ +import { NumericValue } from "@smithy/core/serde"; +import { describe, expect, test as it } from "vitest"; + +import { JsonReplacer } from "./jsonReplacer"; + +describe(JsonReplacer.name, () => { + it("serializes bigint to JSON number", async () => { + const jsonReplacer = new JsonReplacer(); + const data = { + bigint: 1000000000000000000000000000054321n, + }; + const serialized = jsonReplacer.replaceInJson(JSON.stringify(data, jsonReplacer.createReplacer(), 2)); + + expect(serialized).toEqual(`{ + "bigint": 1000000000000000000000000000054321 +}`); + }); + + it("serializes NumericValue to JSON number", async () => { + const jsonReplacer = new JsonReplacer(); + const data = { + numericValue: new NumericValue("0.1000000000000000000000000000054321", "bigDecimal"), + }; + const serialized = jsonReplacer.replaceInJson(JSON.stringify(data, jsonReplacer.createReplacer(), 2)); + + expect(serialized).toEqual(`{ + "numericValue": 0.1000000000000000000000000000054321 +}`); + }); + + it("has lifecycle validation", async () => { + const jsonReplacer = new JsonReplacer(); + expect(() => jsonReplacer.replaceInJson("")).toThrow(); + jsonReplacer.createReplacer(); + expect(() => jsonReplacer.createReplacer()).toThrow(); + jsonReplacer.replaceInJson(""); + expect(() => jsonReplacer.replaceInJson("")).toThrow(); + expect(() => jsonReplacer.replaceInJson("")).toThrow(); + expect(() => jsonReplacer.createReplacer()).toThrow(); + expect(() => jsonReplacer.createReplacer()).toThrow(); + }); +}); diff --git a/packages/core/src/submodules/protocols/json/jsonReplacer.ts b/packages/core/src/submodules/protocols/json/jsonReplacer.ts new file mode 100644 index 000000000000..ee9163fa6d95 --- /dev/null +++ b/packages/core/src/submodules/protocols/json/jsonReplacer.ts @@ -0,0 +1,69 @@ +import { NumericValue } from "@smithy/core/serde"; + +/** + * A rare character used as a control. + * @internal + */ +const NUMERIC_CONTROL_CHAR = String.fromCharCode(925) as "Ν"; + +/** + * Serializes BigInt and NumericValue to JSON-number. + * @internal + */ +export class JsonReplacer { + /** + * Stores placeholder key to true serialized value lookup. + */ + private readonly values = new Map(); + private counter = 0; + private stage = 0; + + /** + * Creates a jsonReplacer function that reserves big integer and big decimal values + * for later replacement. + */ + public createReplacer() { + if (this.stage === 1) { + throw new Error("@aws-sdk/core/protocols - JsonReplacer already created."); + } + if (this.stage === 2) { + throw new Error("@aws-sdk/core/protocols - JsonReplacer exhausted."); + } + this.stage = 1; + + return (key: string, value: unknown) => { + if (value instanceof NumericValue) { + const v = `${NUMERIC_CONTROL_CHAR + +"nv" + this.counter++}_` + value.string; + this.values.set(`"${v}"`, value.string); + return v; + } + if (typeof value === "bigint") { + const s = value.toString(); + const v = `${NUMERIC_CONTROL_CHAR + "b" + this.counter++}_` + s; + this.values.set(`"${v}"`, s); + return v; + } + return value; + }; + } + + /** + * Replaces placeholder keys with their true values. + */ + public replaceInJson(json: string): string { + if (this.stage === 0) { + throw new Error("@aws-sdk/core/protocols - JsonReplacer not created yet."); + } + if (this.stage === 2) { + throw new Error("@aws-sdk/core/protocols - JsonReplacer exhausted."); + } + this.stage = 2; + if (this.counter === 0) { + return json; + } + for (const [key, value] of this.values) { + json = json.replace(key, value); + } + return json; + } +} diff --git a/packages/core/src/submodules/protocols/json/jsonReviver.spec.ts b/packages/core/src/submodules/protocols/json/jsonReviver.spec.ts new file mode 100644 index 000000000000..61509afd5909 --- /dev/null +++ b/packages/core/src/submodules/protocols/json/jsonReviver.spec.ts @@ -0,0 +1,61 @@ +import { NumericValue } from "@smithy/core/serde"; +import { describe, expect, test as it } from "vitest"; + +import { jsonReviver } from "./jsonReviver"; + +describe(jsonReviver.name, () => { + let contextSourceAvailable = false; + JSON.parse(`{ "key": 1 }`, function (key, value, context?: { source?: string }) { + if (context?.source) { + contextSourceAvailable = true; + } + }); + + it("control suite without reviver", async () => { + const data = ` + { + "smallInt": 1, + "smallDecimal": 1.1, + "bigint": 1000000000000000000000000000000054321, + "bigDecimal": 0.12345000000000000000000000000000000054321 + }`; + + const parsed = JSON.parse(data); + expect(parsed.smallInt).toBe(1); + expect(parsed.smallDecimal).toBe(1.1); + expect(parsed.bigint).toBe(1e36); + expect(parsed.bigDecimal).toEqual(0.12345); + }); + + (contextSourceAvailable ? it : it.skip)("handles large numbers if context source is available", async () => { + const data = ` + { + "smallInt": 1, + "smallDecimal": 1.1, + "bigint": 1000000000000000000000000000000054321, + "bigDecimal": 0.12345000000000000000000000000000000054321 + }`; + + const parsed = JSON.parse(data, jsonReviver); + expect(parsed.smallInt).toBe(1); + expect(parsed.smallDecimal).toBe(1.1); + expect(parsed.bigint).toBe(1000000000000000000000000000000054321n); + expect(parsed.bigDecimal).toEqual(new NumericValue("0.12345000000000000000000000000000000054321", "bigDecimal")); + }); + + (contextSourceAvailable ? it.skip : it)("doesn't handle large numbers if context source is unavailable", async () => { + const data = ` + { + "smallInt": 1, + "smallDecimal": 1.1, + "bigint": 1000000000000000000000000000000054321, + "bigDecimal": 0.12345000000000000000000000000000000054321 + }`; + + const parsed = JSON.parse(data, jsonReviver); + expect(parsed.smallInt).toBe(1); + expect(parsed.smallDecimal).toBe(1.1); + expect(parsed.bigint).toBe(1e36); + expect(parsed.bigDecimal).toEqual(0.12345); + }); +}); diff --git a/packages/core/src/submodules/protocols/json/jsonReviver.ts b/packages/core/src/submodules/protocols/json/jsonReviver.ts new file mode 100644 index 000000000000..e117d593db00 --- /dev/null +++ b/packages/core/src/submodules/protocols/json/jsonReviver.ts @@ -0,0 +1,30 @@ +import { NumericValue } from "@smithy/core/serde"; + +/** + * @param key - JSON object key. + * @param value - parsed value. + * @param context - original JSON string for reference. Not available until Node.js 21 and unavailable in Safari as + * of April 2025. + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse#browser_compatibility + * + * @alpha + * + * @returns transformed value. + */ +export function jsonReviver(key: string, value: any, context?: { source?: string }) { + if (context?.source) { + const numericString = context.source; + if (typeof value === "number") { + if (value > Number.MAX_SAFE_INTEGER || value < Number.MIN_SAFE_INTEGER || numericString !== String(value)) { + const isFractional = numericString.includes("."); + if (isFractional) { + return new NumericValue(numericString, "bigDecimal"); + } else { + return BigInt(numericString); + } + } + } + } + return value; +} diff --git a/packages/core/src/submodules/protocols/json/parseJsonBody.ts b/packages/core/src/submodules/protocols/json/parseJsonBody.ts index e2a5b7b7390a..754a87eabc73 100644 --- a/packages/core/src/submodules/protocols/json/parseJsonBody.ts +++ b/packages/core/src/submodules/protocols/json/parseJsonBody.ts @@ -1,11 +1,11 @@ -import type { HttpResponse, SerdeContext } from "@smithy/types"; +import type { HttpResponse, SerdeFunctions } from "@smithy/types"; import { collectBodyString } from "../common"; /** * @internal */ -export const parseJsonBody = (streamBody: any, context: SerdeContext): any => +export const parseJsonBody = (streamBody: any, context: SerdeFunctions): any => collectBodyString(streamBody, context).then((encoded) => { if (encoded.length) { try { @@ -25,7 +25,7 @@ export const parseJsonBody = (streamBody: any, context: SerdeContext): any => /** * @internal */ -export const parseJsonErrorBody = async (errorBody: any, context: SerdeContext) => { +export const parseJsonErrorBody = async (errorBody: any, context: SerdeFunctions) => { const value = await parseJsonBody(errorBody, context); value.message = value.message ?? value.Message; return value; diff --git a/packages/core/src/submodules/protocols/query/AwsEc2QueryProtocol.ts b/packages/core/src/submodules/protocols/query/AwsEc2QueryProtocol.ts new file mode 100644 index 000000000000..aad6a5ba530d --- /dev/null +++ b/packages/core/src/submodules/protocols/query/AwsEc2QueryProtocol.ts @@ -0,0 +1,6 @@ +import { AwsQueryProtocol } from "./AwsQueryProtocol"; + +/** + * @alpha + */ +export class AwsEc2QueryProtocol extends AwsQueryProtocol {} diff --git a/packages/core/src/submodules/protocols/query/AwsQueryProtocol.ts b/packages/core/src/submodules/protocols/query/AwsQueryProtocol.ts new file mode 100644 index 000000000000..70231130c160 --- /dev/null +++ b/packages/core/src/submodules/protocols/query/AwsQueryProtocol.ts @@ -0,0 +1,190 @@ +import { collectBody, RpcProtocol } from "@smithy/core/protocols"; +import { deref, ErrorSchema, NormalizedSchema, SCHEMA, TypeRegistry } from "@smithy/core/schema"; +import { + Codec, + EndpointBearer, + HandlerExecutionContext, + HttpRequest, + MetadataBearer, + OperationSchema, + ResponseMetadata, + SerdeFunctions, +} from "@smithy/types"; +import type { HttpResponse as IHttpResponse } from "@smithy/types/dist-types/http"; +import { calculateBodyLength } from "@smithy/util-body-length-browser"; + +import { XmlShapeDeserializer } from "../xml/XmlShapeDeserializer"; +import { QueryShapeSerializer } from "./QueryShapeSerializer"; + +/** + * @alpha + */ +export class AwsQueryProtocol extends RpcProtocol { + protected serializer: QueryShapeSerializer; + protected deserializer: XmlShapeDeserializer; + + public constructor( + public options: { + defaultNamespace: string; + xmlNamespace: string; + version: string; + } + ) { + super({ + defaultNamespace: options.defaultNamespace, + }); + const settings = { + timestampFormat: { + useTrait: true, + default: SCHEMA.TIMESTAMP_DATE_TIME, + }, + httpBindings: false, + xmlNamespace: options.xmlNamespace, + serviceNamespace: options.defaultNamespace, + }; + this.serializer = new QueryShapeSerializer(settings); + this.deserializer = new XmlShapeDeserializer(settings); + } + + public getShapeId(): string { + return "aws.protocols#awsQuery"; + } + + public setSerdeContext(serdeContext: SerdeFunctions) { + this.serializer.setSerdeContext(serdeContext); + this.deserializer.setSerdeContext(serdeContext); + } + + public getPayloadCodec(): Codec { + throw new Error("AWSQuery protocol has no payload codec."); + } + + public async serializeRequest( + operationSchema: OperationSchema, + input: Input, + context: HandlerExecutionContext & SerdeFunctions & EndpointBearer + ): Promise { + const request = await super.serializeRequest(operationSchema, input, context); + if (!request.path.endsWith("/")) { + request.path += "/"; + } + Object.assign(request.headers, { + "content-type": `application/x-www-form-urlencoded`, + }); + if (deref(operationSchema.input) === "unit" || !request.body) { + request.body = ""; + } + request.body = `Action=${operationSchema.name.split("#")[1]}&Version=${this.options.version}` + request.body; + if (request.body.endsWith("&")) { + request.body = request.body.slice(-1); + } + + try { + request.headers["content-length"] = String(calculateBodyLength(request.body)); + } catch (e) {} + return request; + } + + public async deserializeResponse( + operationSchema: OperationSchema, + context: HandlerExecutionContext & SerdeFunctions, + response: IHttpResponse + ): Promise { + const deserializer = this.deserializer; + const ns = NormalizedSchema.of(operationSchema.output); + + const dataObject: any = {}; + + if (response.statusCode >= 300) { + const bytes: Uint8Array = await collectBody(response.body, context as SerdeFunctions); + if (bytes.byteLength > 0) { + Object.assign(dataObject, await deserializer.read(SCHEMA.DOCUMENT, bytes)); + } + await this.handleError(operationSchema, context, response, dataObject, this.deserializeMetadata(response)); + } + + for (const header in response.headers) { + const value = response.headers[header]; + delete response.headers[header]; + response.headers[header.toLowerCase()] = value; + } + + const awsQueryResultKey = ns.isStructSchema() ? operationSchema.name.split("#")[1] + "Result" : undefined; + const bytes: Uint8Array = await collectBody(response.body, context as SerdeFunctions); + if (bytes.byteLength > 0) { + Object.assign(dataObject, await deserializer.read(ns, bytes, awsQueryResultKey)); + } + + const output: Output = { + $metadata: this.deserializeMetadata(response), + ...dataObject, + }; + + return output; + } + + protected async handleError( + operationSchema: OperationSchema, + context: HandlerExecutionContext & SerdeFunctions, + response: IHttpResponse, + dataObject: any, + metadata: ResponseMetadata + ): Promise { + const errorIdentifier = this.loadQueryErrorCode(response, dataObject) ?? "Unknown"; + let namespace = this.options.defaultNamespace; + let errorName = errorIdentifier; + if (errorIdentifier.includes("#")) { + [namespace, errorName] = errorIdentifier.split("#"); + } + + const registry = TypeRegistry.for(namespace); + let errorSchema: ErrorSchema; + + try { + errorSchema = registry.find( + (schema) => (NormalizedSchema.of(schema).getMergedTraits().awsQueryError as any)?.[0] === errorName + ) as ErrorSchema; + if (!errorSchema) { + errorSchema = registry.getSchema(errorIdentifier) as ErrorSchema; + } + } catch (e) { + const baseExceptionSchema = TypeRegistry.for("awssdkjs.synthetic." + namespace).getBaseException(); + if (baseExceptionSchema) { + const ErrorCtor = baseExceptionSchema.ctor; + throw Object.assign(new ErrorCtor(errorName), dataObject); + } + throw new Error(errorName); + } + + const ns = NormalizedSchema.of(errorSchema); + const message = + dataObject.Error?.message ?? dataObject.Error?.Message ?? dataObject.message ?? dataObject.Message ?? "Unknown"; + const exception = new errorSchema.ctor(message); + + const output = {} as any; + for (const [name, member] of ns.structIterator()) { + const target = member.getMergedTraits().xmlName ?? name; + const value = dataObject.Error?.[target] ?? dataObject[target]; + output[name] = this.deserializer.readSchema(member, value); + } + + Object.assign(exception, { + $metadata: metadata, + $response: response, + $fault: ns.getMergedTraits().error, + message, + ...output, + }); + + throw exception; + } + + protected loadQueryErrorCode(output: IHttpResponse, data: any): string | undefined { + if (data.Error?.Code !== undefined) { + return data.Error.Code; + } + if (output.statusCode == 404) { + return "NotFound"; + } + } +} diff --git a/packages/core/src/submodules/protocols/query/QueryShapeSerializer.spec.ts b/packages/core/src/submodules/protocols/query/QueryShapeSerializer.spec.ts new file mode 100644 index 000000000000..b6510be4e105 --- /dev/null +++ b/packages/core/src/submodules/protocols/query/QueryShapeSerializer.spec.ts @@ -0,0 +1,31 @@ +import { SCHEMA } from "@smithy/core/schema"; +import { NumericValue } from "@smithy/core/serde"; +import { describe, expect, test as it } from "vitest"; + +import { widget } from "../test-schema.spec"; +import { QueryShapeSerializer } from "./QueryShapeSerializer"; + +describe(QueryShapeSerializer.name, () => { + it("serializes data to Query", async () => { + const serializer = new QueryShapeSerializer({ + timestampFormat: { default: SCHEMA.TIMESTAMP_DATE_TIME, useTrait: true }, + }); + serializer.setSerdeContext({ + base64Encoder: (input: Uint8Array) => { + return Buffer.from(input).toString("base64"); + }, + } as any); + + const data = { + timestamp: new Date(0), + bigint: 10000000000000000000000054321n, + bigdecimal: new NumericValue("0.10000000000000000000000054321", "bigDecimal"), + blob: new Uint8Array([0, 0, 0, 1]), + }; + serializer.write(widget, data); + const serialization = serializer.flush(); + expect(serialization).toEqual( + `&blob=AAAAAQ%3D%3D×tamp=0&bigint=10000000000000000000000054321&bigdecimal=0.10000000000000000000000054321` + ); + }); +}); diff --git a/packages/core/src/submodules/protocols/query/QueryShapeSerializer.ts b/packages/core/src/submodules/protocols/query/QueryShapeSerializer.ts new file mode 100644 index 000000000000..45903eb0128c --- /dev/null +++ b/packages/core/src/submodules/protocols/query/QueryShapeSerializer.ts @@ -0,0 +1,144 @@ +import { determineTimestampFormat, extendedEncodeURIComponent } from "@smithy/core/protocols"; +import { NormalizedSchema, SCHEMA } from "@smithy/core/schema"; +import { NumericValue } from "@smithy/core/serde"; +import { dateToUtcString } from "@smithy/smithy-client"; +import type { CodecSettings, Schema, ShapeSerializer } from "@smithy/types"; +import { toBase64 } from "@smithy/util-base64"; + +import { SerdeContextConfig } from "../ConfigurableSerdeContext"; + +/** + * @alpha + */ +export class QueryShapeSerializer extends SerdeContextConfig implements ShapeSerializer { + private buffer: string | undefined; + + public constructor(private settings: CodecSettings) { + super(); + } + + public write(schema: Schema, value: unknown, prefix = ""): void { + if (this.buffer === undefined) { + this.buffer = ""; + } + const ns = NormalizedSchema.of(schema); + if (prefix && !prefix.endsWith(".")) { + prefix += "."; + } + + if (ns.isBlobSchema()) { + if (typeof value === "string" || value instanceof Uint8Array) { + this.writeKey(prefix); + this.writeValue((this.serdeContext?.base64Encoder ?? toBase64)(value as Uint8Array)); + } + } else if (ns.isBooleanSchema() || ns.isNumericSchema() || ns.isStringSchema()) { + if (value != null) { + this.writeKey(prefix); + this.writeValue(String(value)); + } + } else if (ns.isBigIntegerSchema()) { + if (value != null) { + this.writeKey(prefix); + this.writeValue(String(value)); + } + } else if (ns.isBigDecimalSchema()) { + if (value != null) { + this.writeKey(prefix); + this.writeValue(value instanceof NumericValue ? value.string : String(value)); + } + } else if (ns.isTimestampSchema()) { + if (value instanceof Date) { + this.writeKey(prefix); + const format = determineTimestampFormat(ns, this.settings); + switch (format) { + case SCHEMA.TIMESTAMP_DATE_TIME: + this.writeValue(value.toISOString().replace(".000Z", "Z")); + break; + case SCHEMA.TIMESTAMP_HTTP_DATE: + this.writeValue(dateToUtcString(value)); + break; + case SCHEMA.TIMESTAMP_EPOCH_SECONDS: + this.writeValue(String(value.getTime() / 1000)); + break; + } + } + } else if (ns.isDocumentSchema()) { + throw new Error(`@aws-sdk/core/protocols - QuerySerializer unsupported document type ${ns.getName(true)}`); + } else if (ns.isListSchema()) { + if (Array.isArray(value)) { + if (value.length === 0) { + this.writeKey(prefix); + this.writeValue(""); + } else { + const member = ns.getValueSchema(); + const flat = ns.getMergedTraits().xmlFlattened; + let i = 1; + for (const item of value) { + if (item == null) { + continue; + } + const suffix = member.getMergedTraits().xmlName ?? "member"; + const key = flat ? `${prefix}${i}` : `${prefix}${suffix}.${i}`; + this.write(member, item, key); + ++i; + } + } + } + } else if (ns.isMapSchema()) { + if (value && typeof value === "object") { + const keySchema = ns.getKeySchema(); + const memberSchema = ns.getValueSchema(); + const flat = ns.getMergedTraits().xmlFlattened; + let i = 1; + for (const [k, v] of Object.entries(value)) { + if (v == null) { + continue; + } + const keySuffix = keySchema.getMergedTraits().xmlName ?? "key"; + const key = flat ? `${prefix}${i}.${keySuffix}` : `${prefix}entry.${i}.${keySuffix}`; + + const valueSuffix = memberSchema.getMergedTraits().xmlName ?? "value"; + const valueKey = flat ? `${prefix}${i}.${valueSuffix}` : `${prefix}entry.${i}.${valueSuffix}`; + + this.write(keySchema, k, key); + this.write(memberSchema, v, valueKey); + ++i; + } + } + } else if (ns.isStructSchema()) { + if (value && typeof value === "object") { + for (const [memberName, member] of ns.structIterator()) { + if ((value as any)[memberName] == null) { + continue; + } + const suffix = member.getMergedTraits().xmlName ?? memberName; + const key = `${prefix}${suffix}`; + this.write(member, (value as any)[memberName], key); + } + } + } else if (ns.isUnitSchema()) { + } else { + throw new Error(`@aws-sdk/core/protocols - QuerySerializer unrecognized schema type ${ns.getName(true)}`); + } + } + + public flush(): string | Uint8Array { + if (this.buffer === undefined) { + throw new Error("@aws-sdk/core/protocols - QuerySerializer cannot flush with nothing written to buffer."); + } + const str = this.buffer; + delete this.buffer; + return str; + } + + protected writeKey(key: string) { + if (key.endsWith(".")) { + key = key.slice(0, key.length - 1); + } + this.buffer += `&${extendedEncodeURIComponent(key)}=`; + } + + protected writeValue(value: string) { + this.buffer += extendedEncodeURIComponent(value); + } +} diff --git a/packages/core/src/submodules/protocols/test-schema.spec.ts b/packages/core/src/submodules/protocols/test-schema.spec.ts new file mode 100644 index 000000000000..85c00d645641 --- /dev/null +++ b/packages/core/src/submodules/protocols/test-schema.spec.ts @@ -0,0 +1,70 @@ +import { list, map, op, SCHEMA, sim, struct } from "@smithy/core/schema"; +import { describe, test as it } from "vitest"; + +describe("testing schema export", () => { + it("placeholder", () => {}); +}); + +export const widget = struct( + "", + "Struct", + 0, + ["list", "sparseList", "map", "sparseMap", "blob", "media", "timestamp", "bigint", "bigdecimal", "scalar"], + [ + [list("", "List", 0, 0), 0], + [list("", "List", 0, 0), { sparse: 1 }], + map("", "Map", 0, 0, 0), + [map("", "Map", 0, 0, 0), { sparse: 1 }], + SCHEMA.BLOB, + sim("", "Media", 0, { mediaType: "application/json" }), + SCHEMA.TIMESTAMP_EPOCH_SECONDS, + SCHEMA.BIG_INTEGER, + SCHEMA.BIG_DECIMAL, + SCHEMA.NUMERIC, + ] +); + +export const deleteObjects = op( + "ns", + "DeleteObjects", + { + http: ["POST", "/{Bucket}?delete", 200], + }, + struct( + "ns", + "DeleteObjectsRequest", + {}, + ["Delete"], + [ + [ + struct( + "ns", + "Delete", + 0, + ["Objects"], + [ + [ + list("ns", "ObjectIdentifierList", 0, struct("ns", "ObjectIdentifier", 0, ["Key"], [[0, 0]])), + { xmlFlattened: 1, xmlName: "Object" }, + ], + ] + ), + { + httpPayload: 1, + xmlName: "Delete", + }, + ], + ] + ), + struct("ns", "DeleteObjectsResponse", 0, [], []) +); + +export const context = { + async endpoint() { + return { + hostname: "localhost", + path: "/", + protocol: "https:", + }; + }, +} as any; diff --git a/packages/core/src/submodules/protocols/xml/AwsRestXmlProtocol.spec.ts b/packages/core/src/submodules/protocols/xml/AwsRestXmlProtocol.spec.ts new file mode 100644 index 000000000000..0d27f8e109d4 --- /dev/null +++ b/packages/core/src/submodules/protocols/xml/AwsRestXmlProtocol.spec.ts @@ -0,0 +1,111 @@ +import { HttpRequest, HttpResponse } from "@smithy/protocol-http"; +import { toUtf8 } from "@smithy/util-utf8"; +import { describe, expect, test as it } from "vitest"; + +import { context, deleteObjects } from "../test-schema.spec"; +import { AwsRestXmlProtocol } from "./AwsRestXmlProtocol"; +import { parseXmlBody } from "./parseXmlBody"; + +describe(AwsRestXmlProtocol.name, () => { + const command = { + schema: deleteObjects, + }; + + describe("serialization", () => { + const testCases = [ + { + name: "DeleteObjects", + schema: command.schema?.input, + input: { + Delete: { + Objects: [ + { + Key: "key1", + }, + { + Key: "key2", + }, + ], + }, + }, + expected: { + request: { + path: "/", + method: "POST", + headers: { + "content-type": "application/xml", + "content-length": "167", + }, + query: { + delete: "", + }, + }, + body: ` + + + key1 + + + key2 + +`, + }, + }, + ]; + + for (const testCase of testCases) { + it(`should serialize HTTP Requests: ${testCase.name}`, async () => { + const protocol = new AwsRestXmlProtocol({ + xmlNamespace: "http://s3.amazonaws.com/doc/2006-03-01/", + defaultNamespace: "com.amazonaws.s3", + }); + const httpRequest = await protocol.serializeRequest(command.schema!, testCase.input, context); + + const body = httpRequest.body; + httpRequest.body = void 0; + + expect(httpRequest).toEqual( + new HttpRequest({ + protocol: "https:", + hostname: "localhost", + ...testCase.expected.request, + headers: { + ...testCase.expected.request.headers, + }, + }) + ); + + const serdeContext = { + utf8Encoder: toUtf8, + } as any; + + expect(await parseXmlBody(Buffer.from(body), serdeContext)).toEqual( + await parseXmlBody(Buffer.from(testCase.expected.body), serdeContext) + ); + }); + } + }); + + it("deserializes http responses", async () => { + const httpResponse = new HttpResponse({ + statusCode: 200, + headers: {}, + }); + + const protocol = new AwsRestXmlProtocol({ + defaultNamespace: "", + xmlNamespace: "ns", + }); + + const output = await protocol.deserializeResponse(deleteObjects, context, httpResponse); + + expect(output).toEqual({ + $metadata: { + httpStatusCode: 200, + requestId: undefined, + extendedRequestId: undefined, + cfId: undefined, + }, + }); + }); +}); diff --git a/packages/core/src/submodules/protocols/xml/AwsRestXmlProtocol.ts b/packages/core/src/submodules/protocols/xml/AwsRestXmlProtocol.ts new file mode 100644 index 000000000000..260ea1beeabd --- /dev/null +++ b/packages/core/src/submodules/protocols/xml/AwsRestXmlProtocol.ts @@ -0,0 +1,182 @@ +import { + HttpBindingProtocol, + HttpInterceptingShapeDeserializer, + HttpInterceptingShapeSerializer, +} from "@smithy/core/protocols"; +import { ErrorSchema, NormalizedSchema, OperationSchema, SCHEMA, TypeRegistry } from "@smithy/core/schema"; +import type { + EndpointBearer, + HandlerExecutionContext, + HttpRequest as IHttpRequest, + HttpResponse as IHttpResponse, + MetadataBearer, + ResponseMetadata, + SerdeFunctions, + ShapeDeserializer, + ShapeSerializer, +} from "@smithy/types"; +import { calculateBodyLength } from "@smithy/util-body-length-browser"; + +import { loadRestXmlErrorCode } from "./parseXmlBody"; +import { XmlCodec, XmlSettings } from "./XmlCodec"; + +/** + * @alpha + */ +export class AwsRestXmlProtocol extends HttpBindingProtocol { + private readonly codec: XmlCodec; + protected serializer: ShapeSerializer; + protected deserializer: ShapeDeserializer; + + public constructor(options: { defaultNamespace: string; xmlNamespace: string }) { + super(options); + const settings: XmlSettings = { + timestampFormat: { + useTrait: true, + default: SCHEMA.TIMESTAMP_DATE_TIME, + }, + httpBindings: true, + xmlNamespace: options.xmlNamespace, + serviceNamespace: options.defaultNamespace, + }; + this.codec = new XmlCodec(settings); + this.serializer = new HttpInterceptingShapeSerializer(this.codec.createSerializer(), settings); + this.deserializer = new HttpInterceptingShapeDeserializer(this.codec.createDeserializer(), settings); + } + + public getPayloadCodec(): XmlCodec { + return this.codec; + } + + public getShapeId(): string { + return "aws.protocols#restXml"; + } + + public async serializeRequest( + operationSchema: OperationSchema, + input: Input, + context: HandlerExecutionContext & SerdeFunctions & EndpointBearer + ): Promise { + const request = await super.serializeRequest(operationSchema, input, context); + const ns = NormalizedSchema.of(operationSchema.input); + const members = ns.getMemberSchemas(); + + request.path = + String(request.path) + .split("/") + .filter((segment) => { + // for legacy reasons, + // Bucket is in the http trait but is handled by endpoints ruleset. + return segment !== "{Bucket}"; + }) + .join("/") || "/"; + + if (!request.headers["content-type"]) { + const httpPayloadMember = Object.values(members).find((m) => { + return !!m.getMergedTraits().httpPayload; + }); + + if (httpPayloadMember) { + const mediaType = httpPayloadMember.getMergedTraits().mediaType as string; + if (mediaType) { + request.headers["content-type"] = mediaType; + } else if (httpPayloadMember.isStringSchema()) { + request.headers["content-type"] = "text/plain"; + } else if (httpPayloadMember.isBlobSchema()) { + request.headers["content-type"] = "application/octet-stream"; + } else { + request.headers["content-type"] = "application/xml"; + } + } else if (!ns.isUnitSchema()) { + const hasBody = Object.values(members).find((m) => { + const { httpQuery, httpQueryParams, httpHeader, httpLabel, httpPrefixHeaders } = m.getMergedTraits(); + return !httpQuery && !httpQueryParams && !httpHeader && !httpLabel && httpPrefixHeaders === void 0; + }); + if (hasBody) { + request.headers["content-type"] = "application/xml"; + } + } + } + + if (request.headers["content-type"] === "application/xml") { + if (typeof request.body === "string") { + request.body = '' + request.body; + } + } + + if (request.body) { + try { + // todo(schema): use config.bodyLengthChecker or move that into serdeContext. + request.headers["content-length"] = String(calculateBodyLength(request.body)); + } catch (e) {} + } + + return request; + } + + public async deserializeResponse( + operationSchema: OperationSchema, + context: HandlerExecutionContext & SerdeFunctions, + response: IHttpResponse + ): Promise { + return super.deserializeResponse(operationSchema, context, response); + } + + protected async handleError( + operationSchema: OperationSchema, + context: HandlerExecutionContext & SerdeFunctions, + response: IHttpResponse, + dataObject: any, + metadata: ResponseMetadata + ): Promise { + const errorIdentifier = loadRestXmlErrorCode(response, dataObject) ?? "Unknown"; + let namespace = this.options.defaultNamespace; + let errorName = errorIdentifier; + if (errorIdentifier.includes("#")) { + [namespace, errorName] = errorIdentifier.split("#"); + } + + const registry = TypeRegistry.for(namespace); + let errorSchema: ErrorSchema; + try { + errorSchema = registry.getSchema(errorIdentifier) as ErrorSchema; + } catch (e) { + const baseExceptionSchema = TypeRegistry.for("awssdkjs.synthetic." + namespace).getBaseException(); + if (baseExceptionSchema) { + const ErrorCtor = baseExceptionSchema.ctor; + throw Object.assign(new ErrorCtor(errorName), dataObject); + } + throw new Error(errorName); + } + + const ns = NormalizedSchema.of(errorSchema); + const message = + dataObject.Error?.message ?? dataObject.Error?.Message ?? dataObject.message ?? dataObject.Message ?? "Unknown"; + const exception = new errorSchema.ctor(message); + + const headerBindings = new Set( + Object.values(NormalizedSchema.of(errorSchema).getMemberSchemas()) + .map((schema) => { + return schema.getMergedTraits().httpHeader; + }) + .filter(Boolean) as string[] + ); + await this.deserializeHttpMessage(errorSchema, context, response, headerBindings, dataObject); + const output = {} as any; + for (const [name, member] of ns.structIterator()) { + const target = member.getMergedTraits().xmlName ?? name; + const value = dataObject.Error?.[target] ?? dataObject[target]; + output[name] = this.codec.createDeserializer().readSchema(member, value); + } + + Object.assign(exception, { + $metadata: metadata, + $response: response, + $fault: ns.getMergedTraits().error, + message, + ...output, + }); + + throw exception; + } +} diff --git a/packages/core/src/submodules/protocols/xml/XmlCodec.spec.ts b/packages/core/src/submodules/protocols/xml/XmlCodec.spec.ts new file mode 100644 index 000000000000..cf7090d30634 --- /dev/null +++ b/packages/core/src/submodules/protocols/xml/XmlCodec.spec.ts @@ -0,0 +1,49 @@ +import { describe, expect, test as it, vi } from "vitest"; + +import { XmlCodec } from "./XmlCodec"; +import { XmlShapeDeserializer } from "./XmlShapeDeserializer"; +import { XmlShapeSerializer } from "./XmlShapeSerializer"; + +describe(XmlCodec.name, () => { + it("provides a serializer", () => { + const codec = new XmlCodec({ + serviceNamespace: "", + httpBindings: true, + xmlNamespace: "ns", + timestampFormat: { default: 7, useTrait: false }, + }); + + const serializer = codec.createSerializer(); + expect(serializer.settings).toEqual(codec.settings); + }); + + it("provides a deserializer", () => { + const codec = new XmlCodec({ + serviceNamespace: "", + httpBindings: true, + xmlNamespace: "ns", + timestampFormat: { default: 7, useTrait: false }, + }); + + const deserializer = codec.createDeserializer(); + expect(deserializer.settings).toEqual(codec.settings); + }); + + it("propagates serdeContext to its serde providers", () => { + const codec = new XmlCodec({ + serviceNamespace: "", + httpBindings: true, + xmlNamespace: "ns", + timestampFormat: { default: 7, useTrait: false }, + }); + + vi.spyOn(XmlShapeSerializer.prototype, "setSerdeContext"); + vi.spyOn(XmlShapeDeserializer.prototype, "setSerdeContext"); + const serdeContext = {} as any; + codec.setSerdeContext(serdeContext); + codec.createSerializer(); + expect(XmlShapeSerializer.prototype.setSerdeContext).toHaveBeenCalledWith(serdeContext); + codec.createDeserializer(); + expect(XmlShapeDeserializer.prototype.setSerdeContext).toHaveBeenCalledWith(serdeContext); + }); +}); diff --git a/packages/core/src/submodules/protocols/xml/XmlCodec.ts b/packages/core/src/submodules/protocols/xml/XmlCodec.ts new file mode 100644 index 000000000000..c1271b6907f2 --- /dev/null +++ b/packages/core/src/submodules/protocols/xml/XmlCodec.ts @@ -0,0 +1,27 @@ +import { Codec, CodecSettings, ShapeDeserializer, ShapeSerializer } from "@smithy/types"; + +import { SerdeContextConfig } from "../ConfigurableSerdeContext"; +import { XmlShapeDeserializer } from "./XmlShapeDeserializer"; +import { XmlShapeSerializer } from "./XmlShapeSerializer"; + +export type XmlSettings = CodecSettings & { + xmlNamespace: string; + serviceNamespace: string; +}; + +export class XmlCodec extends SerdeContextConfig implements Codec { + public constructor(public readonly settings: XmlSettings) { + super(); + } + + public createSerializer(): XmlShapeSerializer { + const serializer = new XmlShapeSerializer(this.settings); + serializer.setSerdeContext(this.serdeContext!); + return serializer; + } + public createDeserializer(): XmlShapeDeserializer { + const deserializer = new XmlShapeDeserializer(this.settings); + deserializer.setSerdeContext(this.serdeContext!); + return deserializer; + } +} diff --git a/packages/core/src/submodules/protocols/xml/XmlShapeDeserializer.spec.ts b/packages/core/src/submodules/protocols/xml/XmlShapeDeserializer.spec.ts new file mode 100644 index 000000000000..344a7266886c --- /dev/null +++ b/packages/core/src/submodules/protocols/xml/XmlShapeDeserializer.spec.ts @@ -0,0 +1,31 @@ +import { SCHEMA } from "@smithy/core/schema"; +import { NumericValue } from "@smithy/core/serde"; +import { describe, expect, test as it } from "vitest"; + +import { widget } from "../test-schema.spec"; +import { XmlShapeDeserializer } from "./XmlShapeDeserializer"; + +describe("", () => { + it("placeholder", async () => { + const xml = ` + QUFBQQ== + 0 + 10000000000000000000000054321 + 0.10000000000000000000000054321 +`; + const deserializer = new XmlShapeDeserializer({ + httpBindings: true, + serviceNamespace: "namespace", + timestampFormat: { default: SCHEMA.TIMESTAMP_DATE_TIME, useTrait: true }, + xmlNamespace: "namespace", + }); + + const result = await deserializer.read(widget, xml); + expect(result).toEqual({ + blob: new Uint8Array([65, 65, 65, 65]), + timestamp: new Date(0), + bigint: BigInt("10000000000000000000000054321"), + bigdecimal: new NumericValue("0.10000000000000000000000054321", "bigDecimal"), + }); + }); +}); diff --git a/packages/core/src/submodules/protocols/xml/XmlShapeDeserializer.ts b/packages/core/src/submodules/protocols/xml/XmlShapeDeserializer.ts new file mode 100644 index 000000000000..81ed265a1d1a --- /dev/null +++ b/packages/core/src/submodules/protocols/xml/XmlShapeDeserializer.ts @@ -0,0 +1,185 @@ +import { FromStringShapeDeserializer } from "@smithy/core/protocols"; +import { NormalizedSchema } from "@smithy/core/schema"; +import { getValueFromTextNode } from "@smithy/smithy-client"; +import { Schema, SerdeFunctions, ShapeDeserializer } from "@smithy/types"; +import { toUtf8 } from "@smithy/util-utf8"; +import { XMLParser } from "fast-xml-parser"; + +import { SerdeContextConfig } from "../ConfigurableSerdeContext"; +import { XmlSettings } from "./XmlCodec"; + +/** + * @alpha + */ +export class XmlShapeDeserializer extends SerdeContextConfig implements ShapeDeserializer { + private stringDeserializer: FromStringShapeDeserializer; + + public constructor(public readonly settings: XmlSettings) { + super(); + this.stringDeserializer = new FromStringShapeDeserializer(settings); + } + + public setSerdeContext(serdeContext: SerdeFunctions): void { + this.serdeContext = serdeContext; + this.stringDeserializer.setSerdeContext(serdeContext); + } + + /** + * @param schema - describing the data. + * @param bytes - serialized data. + * @param key - used by AwsQuery to step one additional depth into the object before reading it. + */ + public read(schema: Schema, bytes: Uint8Array | string, key?: string): any { + const ns = NormalizedSchema.of(schema); + const memberSchemas = ns.getMemberSchemas(); + const isEventPayload = + ns.isStructSchema() && + ns.isMemberSchema() && + !!Object.values(memberSchemas).find((memberNs) => { + return !!memberNs.getMemberTraits().eventPayload; + }); + + if (isEventPayload) { + const output = {} as any; + const memberName = Object.keys(memberSchemas)[0]; + const eventMemberSchema = memberSchemas[memberName]; + if (eventMemberSchema.isBlobSchema()) { + output[memberName] = bytes; + } else { + output[memberName] = this.read(memberSchemas[memberName], bytes); + } + return output; + } + + const xmlString = (this.serdeContext?.utf8Encoder ?? toUtf8)(bytes); + const parsedObject = this.parseXml(xmlString); + return this.readSchema(schema, key ? parsedObject[key] : parsedObject); + } + + public readSchema(_schema: Schema, value: any): any { + const ns = NormalizedSchema.of(_schema); + const traits = ns.getMergedTraits(); + const schema = ns.getSchema(); + + if (ns.isListSchema() && !Array.isArray(value)) { + // single item in what should have been a list. + return this.readSchema(schema, [value]); + } + + if (value == null) { + return value; + } + + if (typeof value === "object") { + const sparse = !!traits.sparse; + const flat = !!traits.xmlFlattened; + + if (ns.isListSchema()) { + const listValue = ns.getValueSchema(); + const buffer = [] as any[]; + + const sourceKey = listValue.getMergedTraits().xmlName ?? "member"; + const source = flat ? value : (value[0] ?? value)[sourceKey]; + const sourceArray = Array.isArray(source) ? source : [source]; + + for (const v of sourceArray) { + if (v != null || sparse) { + buffer.push(this.readSchema(listValue, v)); + } + } + return buffer; + } + + const buffer = {} as any; + if (ns.isMapSchema()) { + const keyNs = ns.getKeySchema(); + const memberNs = ns.getValueSchema(); + let entries: any[]; + if (flat) { + entries = Array.isArray(value) ? value : [value]; + } else { + entries = Array.isArray(value.entry) ? value.entry : [value.entry]; + } + const keyProperty = keyNs.getMergedTraits().xmlName ?? "key"; + const valueProperty = memberNs.getMergedTraits().xmlName ?? "value"; + for (const entry of entries) { + const key = entry[keyProperty]; + const value = entry[valueProperty]; + if (value != null || sparse) { + buffer[key] = this.readSchema(memberNs, value); + } + } + return buffer; + } + + if (ns.isStructSchema()) { + for (const [memberName, memberSchema] of ns.structIterator()) { + const memberTraits = memberSchema.getMergedTraits(); + const xmlObjectKey = !memberTraits.httpPayload + ? memberSchema.getMemberTraits().xmlName ?? memberName + : memberTraits.xmlName ?? memberSchema.getName()!; + + if (value[xmlObjectKey] != null) { + buffer[memberName] = this.readSchema(memberSchema, value[xmlObjectKey]); + } + } + return buffer; + } + + if (ns.isDocumentSchema()) { + // this should indicate an error being deserialized with no schema. + return value; + } + + throw new Error(`@aws-sdk/core/protocols - xml deserializer unhandled schema type for ${ns.getName(true)}`); + } else { + // non-object aggregate type. + if (ns.isListSchema()) { + return []; + } else if (ns.isMapSchema() || ns.isStructSchema()) { + return {} as any; + } + + // simple + return this.stringDeserializer.read(ns, value as string); + } + } + + protected parseXml(xml: string): any { + if (xml.length) { + const parser = new XMLParser({ + attributeNamePrefix: "", + htmlEntities: true, + ignoreAttributes: false, + ignoreDeclaration: true, + parseTagValue: false, + trimValues: false, + tagValueProcessor: (_: any, val: any) => (val.trim() === "" && val.includes("\n") ? "" : undefined), + }); + parser.addEntity("#xD", "\r"); + parser.addEntity("#10", "\n"); + + let parsedObj; + try { + parsedObj = parser.parse(xml, true); + } catch (e: any) { + if (e && typeof e === "object") { + Object.defineProperty(e, "$responseBodyText", { + value: xml, + }); + } + throw e; + } + + const textNodeName = "#text"; + const key = Object.keys(parsedObj)[0]; + const parsedObjToReturn = parsedObj[key]; + if (parsedObjToReturn[textNodeName]) { + parsedObjToReturn[key] = parsedObjToReturn[textNodeName]; + delete parsedObjToReturn[textNodeName]; + } + return getValueFromTextNode(parsedObjToReturn); + } + return {}; + } +} diff --git a/packages/core/src/submodules/protocols/xml/XmlShapeSerializer.spec.ts b/packages/core/src/submodules/protocols/xml/XmlShapeSerializer.spec.ts new file mode 100644 index 000000000000..013fdb37ee5d --- /dev/null +++ b/packages/core/src/submodules/protocols/xml/XmlShapeSerializer.spec.ts @@ -0,0 +1,45 @@ +import { SCHEMA } from "@smithy/core/schema"; +import { NumericValue } from "@smithy/core/serde"; +import { describe, expect, test as it } from "vitest"; + +import { widget } from "../test-schema.spec"; +import { simpleFormatXml } from "./simpleFormatXml"; +import { XmlShapeSerializer } from "./XmlShapeSerializer"; + +describe(XmlShapeSerializer.name, () => { + it("serializes data to Query", async () => { + const serializer = new XmlShapeSerializer({ + xmlNamespace: "namespace", + serviceNamespace: "namespace", + timestampFormat: { default: SCHEMA.TIMESTAMP_DATE_TIME, useTrait: true }, + }); + serializer.setSerdeContext({ + base64Encoder: (input: Uint8Array) => { + return Buffer.from(input).toString("base64"); + }, + } as any); + + const data = { + timestamp: new Date(0), + bigint: 10000000000000000000000054321n, + bigdecimal: new NumericValue("0.10000000000000000000000054321", "bigDecimal"), + blob: new Uint8Array([0, 0, 0, 1]), + }; + serializer.write(widget, data); + const serialization = serializer.flush(); + expect(simpleFormatXml(serialization as string)).toEqual(` + + AAAAAQ== + + + 0 + + + 10000000000000000000000054321 + + + 0.10000000000000000000000054321 + +`); + }); +}); diff --git a/packages/core/src/submodules/protocols/xml/XmlShapeSerializer.ts b/packages/core/src/submodules/protocols/xml/XmlShapeSerializer.ts new file mode 100644 index 000000000000..4d78ce8f3b64 --- /dev/null +++ b/packages/core/src/submodules/protocols/xml/XmlShapeSerializer.ts @@ -0,0 +1,339 @@ +import { XmlNode, XmlText } from "@aws-sdk/xml-builder"; +import { NormalizedSchema, SCHEMA } from "@smithy/core/schema"; +import { 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"; + +import { SerdeContextConfig } from "../ConfigurableSerdeContext"; +import { XmlSettings } from "./XmlCodec"; + +type XmlNamespaceAttributeValuePair = [string, string] | [undefined, undefined]; + +/** + * @alpha + */ +export class XmlShapeSerializer extends SerdeContextConfig implements ShapeSerializer { + private stringBuffer?: string; + private byteBuffer?: Uint8Array; + private buffer?: XmlNode; + + public constructor(public readonly settings: XmlSettings) { + super(); + } + + public write(schema: ISchema, value: unknown): void { + const ns = NormalizedSchema.of(schema); + if (ns.isStringSchema() && typeof value === "string") { + this.stringBuffer = value as string; + } else if (ns.isBlobSchema()) { + this.byteBuffer = + "byteLength" in (value as Uint8Array) + ? (value as Uint8Array) + : (this.serdeContext?.base64Decoder ?? fromBase64)(value as string); + } else { + this.buffer = this.writeStruct(ns, value, undefined) as XmlNode; + const traits = ns.getMergedTraits(); + if (traits.httpPayload && !traits.xmlName) { + this.buffer.withName(ns.getName()!); + } + } + } + + public flush(): string | Uint8Array { + if (this.byteBuffer !== undefined) { + const bytes = this.byteBuffer; + delete this.byteBuffer; + return bytes; + } + if (this.stringBuffer !== undefined) { + const str = this.stringBuffer; + delete this.stringBuffer; + return str; + } + const buffer = this.buffer!; + if (this.settings.xmlNamespace) { + if (!(buffer as any)?.attributes?.["xmlns"]) { + buffer.addAttribute("xmlns", this.settings.xmlNamespace); + } + } + delete this.buffer; + return buffer.toString(); + } + + private writeStruct(ns: NormalizedSchema, value: unknown, parentXmlns: string | undefined): XmlNode { + const traits = ns.getMergedTraits(); + const name = + ns.isMemberSchema() && !traits.httpPayload + ? ns.getMemberTraits().xmlName ?? ns.getMemberName() + : traits.xmlName ?? ns.getName(); + + if (!name || !ns.isStructSchema()) { + throw new Error( + `@aws-sdk/core/protocols - xml serializer, cannot write struct with empty name or non-struct, schema=${ns.getName( + true + )}.` + ); + } + const structXmlNode = XmlNode.of(name); + + const [xmlnsAttr, xmlns] = this.getXmlnsAttribute(ns, parentXmlns); + + if (xmlns) { + structXmlNode.addAttribute(xmlnsAttr as string, xmlns); + } + + for (const [memberName, memberSchema] of ns.structIterator()) { + const val = (value as any)[memberName]; + + if (val != null) { + if (memberSchema.getMergedTraits().xmlAttribute) { + structXmlNode.addAttribute( + memberSchema.getMergedTraits().xmlName ?? memberName, + this.writeSimple(memberSchema, val) + ); + continue; + } + if (memberSchema.isListSchema()) { + this.writeList(memberSchema, val, structXmlNode, xmlns); + } else if (memberSchema.isMapSchema()) { + this.writeMap(memberSchema, val, structXmlNode, xmlns); + } else if (memberSchema.isStructSchema()) { + structXmlNode.addChildNode(this.writeStruct(memberSchema, val, xmlns)); + } else { + const memberNode = XmlNode.of(memberSchema.getMergedTraits().xmlName ?? memberSchema.getMemberName()); + this.writeSimpleInto(memberSchema, val, memberNode, xmlns); + structXmlNode.addChildNode(memberNode); + } + } + } + + return structXmlNode; + } + + private writeList( + listMember: NormalizedSchema, + array: unknown[], + container: XmlNode, + parentXmlns: string | undefined + ): void { + if (!listMember.isMemberSchema()) { + throw new Error( + `@aws-sdk/core/protocols - xml serializer, cannot write non-member list: ${listMember.getName(true)}` + ); + } + const listTraits = listMember.getMergedTraits(); + const listValueSchema = listMember.getValueSchema(); + const listValueTraits = listValueSchema.getMergedTraits(); + const sparse = !!listValueTraits.sparse; + const flat = !!listTraits.xmlFlattened; + + const [xmlnsAttr, xmlns] = this.getXmlnsAttribute(listMember, parentXmlns); + + const writeItem = (container: XmlNode, value: any) => { + if (listValueSchema.isListSchema()) { + this.writeList(listValueSchema, Array.isArray(value) ? value : [value], container, xmlns); + } else if (listValueSchema.isMapSchema()) { + this.writeMap(listValueSchema, value as any, container, xmlns); + } else if (listValueSchema.isStructSchema()) { + const struct = this.writeStruct(listValueSchema, value, xmlns); + container.addChildNode( + struct.withName(flat ? listTraits.xmlName ?? listMember.getMemberName() : listValueTraits.xmlName ?? "member") + ); + } else { + const listItemNode = XmlNode.of( + flat ? listTraits.xmlName ?? listMember.getMemberName() : listValueTraits.xmlName ?? "member" + ); + this.writeSimpleInto(listValueSchema, value, listItemNode, xmlns); + container.addChildNode(listItemNode); + } + }; + + if (flat) { + for (const value of array) { + if (sparse || value != null) { + writeItem(container, value); + } + } + } else { + const listNode = XmlNode.of(listTraits.xmlName ?? listMember.getMemberName()); + if (xmlns) { + listNode.addAttribute(xmlnsAttr as string, xmlns); + } + for (const value of array) { + if (sparse || value != null) { + writeItem(listNode, value); + } + } + container.addChildNode(listNode); + } + } + + private writeMap( + mapMember: NormalizedSchema, + map: Record, + container: XmlNode, + parentXmlns: string | undefined, + containerIsMap = false + ): void { + if (!mapMember.isMemberSchema()) { + throw new Error( + `@aws-sdk/core/protocols - xml serializer, cannot write non-member map: ${mapMember.getName(true)}` + ); + } + + const mapTraits = mapMember.getMergedTraits(); + + const mapKeySchema = mapMember.getKeySchema(); + const mapKeyTraits = mapKeySchema.getMergedTraits(); + const keyTag = mapKeyTraits.xmlName ?? "key"; + + const mapValueSchema = mapMember.getValueSchema(); + const mapValueTraits = mapValueSchema.getMergedTraits(); + const valueTag = mapValueTraits.xmlName ?? "value"; + + const sparse = !!mapValueTraits.sparse; + const flat = !!mapTraits.xmlFlattened; + + const [xmlnsAttr, xmlns] = this.getXmlnsAttribute(mapMember, parentXmlns); + + const addKeyValue = (entry: XmlNode, key: string, val: any) => { + const keyNode = XmlNode.of(keyTag, key); + const [keyXmlnsAttr, keyXmlns] = this.getXmlnsAttribute(mapKeySchema, xmlns); + if (keyXmlns) { + keyNode.addAttribute(keyXmlnsAttr as string, keyXmlns); + } + + entry.addChildNode(keyNode); + let valueNode = XmlNode.of(valueTag); + + if (mapValueSchema.isListSchema()) { + this.writeList(mapValueSchema, val, valueNode, xmlns); + } else if (mapValueSchema.isMapSchema()) { + this.writeMap(mapValueSchema, val, valueNode, xmlns, true); + } else if (mapValueSchema.isStructSchema()) { + valueNode = this.writeStruct(mapValueSchema, val, xmlns); + } else { + this.writeSimpleInto(mapValueSchema, val, valueNode, xmlns); + } + + entry.addChildNode(valueNode); + }; + + if (flat) { + for (const [key, val] of Object.entries(map as object)) { + if (sparse || val != null) { + const entry = XmlNode.of(mapTraits.xmlName ?? mapMember.getMemberName()); + addKeyValue(entry, key, val); + container.addChildNode(entry); + } + } + } else { + let mapNode: XmlNode | undefined; + if (!containerIsMap) { + mapNode = XmlNode.of(mapTraits.xmlName ?? mapMember.getMemberName()); + if (xmlns) { + mapNode.addAttribute(xmlnsAttr as string, xmlns); + } + container.addChildNode(mapNode); + } + + for (const [key, val] of Object.entries(map as object)) { + if (sparse || val != null) { + const entry = XmlNode.of("entry"); + addKeyValue(entry, key, val); + (containerIsMap ? container : mapNode!).addChildNode(entry); + } + } + } + } + + private writeSimple(_schema: ISchema, value: unknown): string { + if (null === value) { + throw new Error("@aws-sdk/core/protocols - (XML serializer) cannot write null value."); + } + const ns = NormalizedSchema.of(_schema); + let nodeContents: any = null; + + if (value && typeof value === "object") { + 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; + switch (format) { + case SCHEMA.TIMESTAMP_DATE_TIME: + nodeContents = value.toISOString().replace(".000Z", "Z"); + break; + case SCHEMA.TIMESTAMP_HTTP_DATE: + nodeContents = dateToUtcString(value); + break; + case SCHEMA.TIMESTAMP_EPOCH_SECONDS: + nodeContents = String(value.getTime() / 1000); + break; + default: + console.warn("Missing timestamp format, using http date", value); + nodeContents = dateToUtcString(value); + break; + } + } else if (ns.isBigDecimalSchema() && value) { + if (value instanceof NumericValue) { + return value.string; + } + return String(value); + } else if (ns.isMapSchema() || ns.isListSchema()) { + throw new Error( + "@aws-sdk/core/protocols - xml serializer, cannot call _write() on List/Map schema, call writeList or writeMap() instead." + ); + } else { + throw new Error( + `@aws-sdk/core/protocols - xml serializer, unhandled schema type for object value and schema: ${ns.getName( + true + )}` + ); + } + } + + if ( + ns.isStringSchema() || + ns.isBooleanSchema() || + ns.isNumericSchema() || + ns.isBigIntegerSchema() || + ns.isBigDecimalSchema() + ) { + nodeContents = String(value); + } + + if (nodeContents === null) { + throw new Error(`Unhandled schema-value pair ${ns.getName(true)}=${value}`); + } + + return nodeContents; + } + + private writeSimpleInto(_schema: ISchema, value: unknown, into: XmlNode, parentXmlns: string | undefined): void { + const nodeContents = this.writeSimple(_schema, value); + const ns = NormalizedSchema.of(_schema); + + const content = new XmlText(nodeContents); + const [xmlnsAttr, xmlns] = this.getXmlnsAttribute(ns, parentXmlns); + if (xmlns) { + into.addAttribute(xmlnsAttr as string, xmlns); + } + + into.addChildNode(content); + } + + private getXmlnsAttribute(ns: NormalizedSchema, parentXmlns: string | undefined): XmlNamespaceAttributeValuePair { + const traits = ns.getMergedTraits(); + const [prefix, xmlns] = traits.xmlNamespace ?? []; + if (xmlns && xmlns !== parentXmlns) { + return [prefix ? `xmlns:${prefix}` : "xmlns", xmlns]; + } + return [void 0, void 0]; + } +} diff --git a/packages/core/src/submodules/protocols/xml/simpleFormatXml.ts b/packages/core/src/submodules/protocols/xml/simpleFormatXml.ts new file mode 100644 index 000000000000..869da6840d70 --- /dev/null +++ b/packages/core/src/submodules/protocols/xml/simpleFormatXml.ts @@ -0,0 +1,30 @@ +/** + * Formats XML, for testing only. + * @internal + * @deprecated don't use in runtime code. + */ +export function simpleFormatXml(xml: string): string { + let b = ""; + let indentation = 0; + for (let i = 0; i < xml.length; ++i) { + const c = xml[i]; + + if (c === "<") { + if (xml[i + 1] === "/") { + b += "\n" + " ".repeat(indentation - 2) + c; + indentation -= 4; + } else { + b += c; + } + } else if (c === ">") { + indentation += 2; + b += c + "\n" + " ".repeat(indentation); + } else { + b += c; + } + } + return b + .split("\n") + .filter((s) => !!s.trim()) + .join("\n"); +} diff --git a/yarn.lock b/yarn.lock index f3502505de74..cc317f108c5a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -22475,6 +22475,7 @@ __metadata: resolution: "@aws-sdk/core@workspace:packages/core" dependencies: "@aws-sdk/types": "npm:*" + "@aws-sdk/xml-builder": "npm:*" "@smithy/core": "npm:^3.5.1" "@smithy/node-config-provider": "npm:^4.1.3" "@smithy/property-provider": "npm:^4.0.4" @@ -22482,7 +22483,10 @@ __metadata: "@smithy/signature-v4": "npm:^5.1.2" "@smithy/smithy-client": "npm:^4.4.1" "@smithy/types": "npm:^4.3.1" + "@smithy/util-base64": "npm:^4.0.0" + "@smithy/util-body-length-browser": "npm:^4.0.0" "@smithy/util-middleware": "npm:^4.0.4" + "@smithy/util-utf8": "npm:^4.0.0" "@tsconfig/recommended": "npm:1.0.1" concurrently: "npm:7.0.0" downlevel-dts: "npm:0.10.1"