diff --git a/.changeset/nice-deers-shake.md b/.changeset/nice-deers-shake.md new file mode 100644 index 00000000000..343045f66ee --- /dev/null +++ b/.changeset/nice-deers-shake.md @@ -0,0 +1,7 @@ +--- +"@smithy/smithy-client": minor +"@smithy/types": minor +"@smithy/core": minor +--- + +implement schema framework diff --git a/packages/core/package.json b/packages/core/package.json index 6313c6a8627..1110a756144 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -46,6 +46,13 @@ "require": "./dist-cjs/submodules/protocols/index.js", "types": "./dist-types/submodules/protocols/index.d.ts" }, + "./schema": { + "module": "./dist-es/submodules/schema/index.js", + "node": "./dist-cjs/submodules/schema/index.js", + "import": "./dist-es/submodules/schema/index.js", + "require": "./dist-cjs/submodules/schema/index.js", + "types": "./dist-types/submodules/schema/index.d.ts" + }, "./serde": { "module": "./dist-es/submodules/serde/index.js", "node": "./dist-cjs/submodules/serde/index.js", @@ -64,6 +71,7 @@ "@smithy/middleware-serde": "workspace:^", "@smithy/protocol-http": "workspace:^", "@smithy/types": "workspace:^", + "@smithy/util-base64": "workspace:^", "@smithy/util-body-length-browser": "workspace:^", "@smithy/util-middleware": "workspace:^", "@smithy/util-stream": "workspace:^", @@ -85,6 +93,8 @@ "./cbor.js", "./protocols.d.ts", "./protocols.js", + "./schema.d.ts", + "./schema.js", "./serde.d.ts", "./serde.js", "dist-*/**" diff --git a/packages/core/schema.d.ts b/packages/core/schema.d.ts new file mode 100644 index 00000000000..e29b358237b --- /dev/null +++ b/packages/core/schema.d.ts @@ -0,0 +1,7 @@ +/** + * Do not edit: + * This is a compatibility redirect for contexts that do not understand package.json exports field. + */ +declare module "@smithy/core/schema" { + export * from "@smithy/core/dist-types/submodules/schema/index.d"; +} diff --git a/packages/core/schema.js b/packages/core/schema.js new file mode 100644 index 00000000000..a5035ded81e --- /dev/null +++ b/packages/core/schema.js @@ -0,0 +1,6 @@ + +/** + * Do not edit: + * This is a compatibility redirect for contexts that do not understand package.json exports field. + */ +module.exports = require("./dist-cjs/submodules/schema/index.js"); diff --git a/packages/core/src/submodules/cbor/CborCodec.ts b/packages/core/src/submodules/cbor/CborCodec.ts new file mode 100644 index 00000000000..c033cc0f5a4 --- /dev/null +++ b/packages/core/src/submodules/cbor/CborCodec.ts @@ -0,0 +1,164 @@ +import { NormalizedSchema } from "@smithy/core/schema"; +import { copyDocumentWithTransform, parseEpochTimestamp } from "@smithy/core/serde"; +import { Codec, Schema, SchemaRef, SerdeContext, ShapeDeserializer, ShapeSerializer } from "@smithy/types"; + +import { cbor } from "./cbor"; +import { dateToTag } from "./parseCborBody"; + +export class CborCodec implements Codec { + private serdeContext?: SerdeContext; + + public createSerializer(): CborShapeSerializer { + const serializer = new CborShapeSerializer(); + serializer.setSerdeContext(this.serdeContext!); + return serializer; + } + + public createDeserializer(): CborShapeDeserializer { + const deserializer = new CborShapeDeserializer(); + deserializer.setSerdeContext(this.serdeContext!); + return deserializer; + } + + public setSerdeContext(serdeContext: SerdeContext): void { + this.serdeContext = serdeContext; + } +} + +export class CborShapeSerializer implements ShapeSerializer { + private serdeContext?: SerdeContext; + private value: unknown; + + public setSerdeContext(serdeContext: SerdeContext) { + this.serdeContext = serdeContext; + } + + public write(schema: Schema, value: unknown): void { + this.value = copyDocumentWithTransform(value, schema, (_: any, schemaRef: SchemaRef) => { + if (_ instanceof Date) { + return dateToTag(_); + } + if (_ instanceof Uint8Array) { + return _; + } + + const ns = NormalizedSchema.of(schemaRef); + const sparse = !!ns.getMergedTraits().sparse; + + if (Array.isArray(_)) { + if (!sparse) { + return _.filter((item) => item != null); + } + } else if (_ && typeof _ === "object") { + if (!sparse || ns.isStructSchema()) { + for (const [k, v] of Object.entries(_)) { + if (v == null) { + delete _[k]; + } + } + return _; + } + } + + return _; + }); + } + + public flush(): Uint8Array { + const buffer = cbor.serialize(this.value); + this.value = undefined; + return buffer as Uint8Array; + } +} + +export class CborShapeDeserializer implements ShapeDeserializer { + private serdeContext?: SerdeContext; + + public setSerdeContext(serdeContext: SerdeContext) { + this.serdeContext = serdeContext; + } + + public read(schema: Schema, bytes: Uint8Array): any { + const data: any = cbor.deserialize(bytes); + return this.readValue(schema, data); + } + + private readValue(_schema: Schema, value: any): any { + const ns = NormalizedSchema.of(_schema); + const schema = ns.getSchema(); + + if (typeof schema === "number") { + if (ns.isTimestampSchema()) { + // format is ignored. + return parseEpochTimestamp(value); + } + if (ns.isBlobSchema()) { + return value; + } + } + + switch (typeof value) { + case "undefined": + case "boolean": + case "number": + case "string": + case "bigint": + case "symbol": + return value; + case "function": + case "object": + if (value === null) { + return null; + } + if ("byteLength" in (value as Uint8Array)) { + return value; + } + if (value instanceof Date) { + return value; + } + if (ns.isDocumentSchema()) { + return value; + } + + if (ns.isListSchema()) { + const newArray = []; + const memberSchema = ns.getValueSchema(); + const sparse = ns.isListSchema() && !!ns.getMergedTraits().sparse; + + for (const item of value) { + newArray.push(this.readValue(memberSchema, item)); + if (!sparse && newArray[newArray.length - 1] == null) { + newArray.pop(); + } + } + return newArray; + } + + const newObject = {} as any; + + if (ns.isMapSchema()) { + const sparse = ns.getMergedTraits().sparse; + const targetSchema = ns.getValueSchema(); + + for (const key of Object.keys(value)) { + newObject[key] = this.readValue(targetSchema, value[key]); + + if (newObject[key] == null && !sparse) { + delete newObject[key]; + } + } + } else if (ns.isStructSchema()) { + for (const key of Object.keys(value)) { + const targetSchema = ns.getMemberSchema(key); + if (targetSchema === undefined) { + continue; + } + newObject[key] = this.readValue(targetSchema, value[key]); + } + } + return newObject; + default: + return value; + } + } +} diff --git a/packages/core/src/submodules/cbor/SmithyRpcV2CborProtocol.spec.ts b/packages/core/src/submodules/cbor/SmithyRpcV2CborProtocol.spec.ts new file mode 100644 index 00000000000..a88c32cf747 --- /dev/null +++ b/packages/core/src/submodules/cbor/SmithyRpcV2CborProtocol.spec.ts @@ -0,0 +1,270 @@ +import { list, map, SCHEMA, struct } from "@smithy/core/schema"; +import { HttpRequest, HttpResponse } from "@smithy/protocol-http"; +import { SchemaRef } from "@smithy/types"; +import { describe, expect, test as it } from "vitest"; + +import { cbor } from "./cbor"; +import { dateToTag } from "./parseCborBody"; +import { SmithyRpcV2CborProtocol } from "./SmithyRpcV2CborProtocol"; + +describe(SmithyRpcV2CborProtocol.name, () => { + const bytes = (arr: number[]) => Buffer.from(arr); + + describe("serialization", () => { + const testCases: Array<{ + name: string; + schema: SchemaRef; + input: any; + expected: { + request: any; + body: any; + }; + }> = [ + { + name: "document with timestamp and blob", + schema: struct( + "", + "MyExtendedDocument", + {}, + ["timestamp", "blob"], + [ + [SCHEMA.TIMESTAMP_DEFAULT, 0], + [SCHEMA.BLOB, 0], + ] + ), + input: { + bool: true, + int: 5, + float: -3.001, + timestamp: new Date(1_000_000), + blob: bytes([97, 98, 99, 100]), + }, + expected: { + request: {}, + body: { + timestamp: dateToTag(new Date(1_000_000)), + blob: bytes([97, 98, 99, 100]), + }, + }, + }, + { + name: "do not write to header or query", + schema: struct( + "", + "MyExtendedDocument", + {}, + ["bool", "timestamp", "blob", "prefixHeaders", "searchParams"], + [ + [SCHEMA.BOOLEAN, { httpQuery: "bool" }], + [SCHEMA.TIMESTAMP_DEFAULT, { httpHeader: "timestamp" }], + [SCHEMA.BLOB, { httpHeader: "blob" }], + [SCHEMA.MAP_MODIFIER | SCHEMA.STRING, { httpPrefixHeaders: "anti-" }], + [SCHEMA.MAP_MODIFIER | SCHEMA.STRING, { httpQueryParams: 1 }], + ] + ), + input: { + bool: true, + timestamp: new Date(1_000_000), + blob: bytes([97, 98, 99, 100]), + prefixHeaders: { + pasto: "cheese dodecahedron", + clockwise: "left", + }, + searchParams: { + a: 1, + b: 2, + }, + }, + expected: { + request: { + headers: {}, + query: {}, + }, + body: { + bool: true, + timestamp: dateToTag(new Date(1_000_000)), + blob: bytes([97, 98, 99, 100]), + prefixHeaders: { + pasto: "cheese dodecahedron", + clockwise: "left", + }, + searchParams: { + a: 1, + b: 2, + }, + }, + }, + }, + { + name: "sparse list and map", + schema: struct( + "", + "MyShape", + 0, + ["mySparseList", "myRegularList", "mySparseMap", "myRegularMap"], + [ + [() => list("", "MyList", { sparse: 1 }, SCHEMA.NUMERIC), {}], + [() => list("", "MyList", {}, SCHEMA.NUMERIC), {}], + [() => map("", "MyMap", { sparse: 1 }, SCHEMA.NUMERIC), {}], + [() => map("", "MyMap", {}, SCHEMA.NUMERIC), {}], + ] + ), + input: { + mySparseList: [null, 1, null, 2, null], + myRegularList: [null, 1, null, 2, null], + mySparseMap: { + 0: null, + 1: 1, + 2: null, + 3: 3, + 4: null, + }, + myRegularMap: { + 0: null, + 1: 1, + 2: null, + 3: 3, + 4: null, + }, + }, + expected: { + request: {}, + body: { + mySparseList: [null, 1, null, 2, null], + myRegularList: [1, 2], + mySparseMap: { + 0: null, + 1: 1, + 2: null, + 3: 3, + 4: null, + }, + myRegularMap: { + 1: 1, + 3: 3, + }, + }, + }, + }, + ]; + + for (const testCase of testCases) { + it(`should serialize HTTP Requests: ${testCase.name}`, async () => { + const protocol = new SmithyRpcV2CborProtocol(); + const httpRequest = await protocol.serializeRequest( + { + name: "dummy", + input: testCase.schema, + output: void 0, + traits: {}, + }, + testCase.input, + { + endpointV2: { + url: new URL("https://example.com/"), + }, + } + ); + + const body = httpRequest.body; + httpRequest.body = void 0; + + expect(httpRequest).toEqual( + new HttpRequest({ + protocol: "https:", + hostname: "example.com", + method: "POST", + path: "/service/undefined/operation/undefined", + ...testCase.expected.request, + headers: { + accept: "application/cbor", + "content-type": "application/cbor", + "smithy-protocol": "rpc-v2-cbor", + "content-length": String(body.byteLength), + ...testCase.expected.request.headers, + }, + }) + ); + + expect(cbor.deserialize(body)).toEqual(testCase.expected.body); + }); + } + }); + + describe("deserialization", () => { + const testCases = [ + { + name: "sparse list and map", + schema: struct( + "", + "MyShape", + 0, + ["mySparseList", "myRegularList", "mySparseMap", "myRegularMap"], + [ + [() => list("", "MyList", { sparse: 1 }, SCHEMA.NUMERIC), {}], + [() => list("", "MyList", {}, SCHEMA.NUMERIC), {}], + [() => map("", "MyMap", { sparse: 1 }, SCHEMA.NUMERIC), {}], + [() => map("", "MyMap", {}, SCHEMA.NUMERIC), {}], + ] + ), + mockOutput: { + mySparseList: [null, 1, null, 2, null], + myRegularList: [null, 1, null, 2, null], + mySparseMap: { + 0: null, + 1: 1, + 2: null, + 3: 3, + 4: null, + }, + myRegularMap: { + 0: null, + 1: 1, + 2: null, + 3: 3, + 4: null, + }, + }, + expected: { + output: { + mySparseList: [null, 1, null, 2, null], + myRegularList: [1, 2], + mySparseMap: { + 0: null, + 1: 1, + 2: null, + 3: 3, + 4: null, + }, + myRegularMap: { + 1: 1, + 3: 3, + }, + }, + }, + }, + ]; + + for (const testCase of testCases) { + it(`should deserialize HTTP Responses: ${testCase.name}`, async () => { + const protocol = new SmithyRpcV2CborProtocol(); + const output = await protocol.deserializeResponse( + { + name: "dummy", + input: void 0, + output: testCase.schema, + traits: {}, + }, + {}, + new HttpResponse({ + statusCode: 200, + body: cbor.serialize(testCase.mockOutput), + }) + ); + + delete (output as Partial).$metadata; + expect(output).toEqual(testCase.expected.output); + }); + } + }); +}); diff --git a/packages/core/src/submodules/cbor/SmithyRpcV2CborProtocol.ts b/packages/core/src/submodules/cbor/SmithyRpcV2CborProtocol.ts new file mode 100644 index 00000000000..0c0735d473d --- /dev/null +++ b/packages/core/src/submodules/cbor/SmithyRpcV2CborProtocol.ts @@ -0,0 +1,110 @@ +import { RpcProtocol } from "@smithy/core/protocols"; +import { deref, ErrorSchema, OperationSchema, TypeRegistry } from "@smithy/core/schema"; +import type { + HandlerExecutionContext, + HttpRequest as IHttpRequest, + HttpResponse as IHttpResponse, + MetadataBearer, + ResponseMetadata, + SerdeContext, +} from "@smithy/types"; +import { getSmithyContext } from "@smithy/util-middleware"; + +import { CborCodec } from "./CborCodec"; +import { loadSmithyRpcV2CborErrorCode } from "./parseCborBody"; + +export class SmithyRpcV2CborProtocol extends RpcProtocol { + private codec = new CborCodec(); + protected serializer = this.codec.createSerializer(); + protected deserializer = this.codec.createDeserializer(); + + public constructor({ defaultNamespace }: { defaultNamespace: string }) { + super({ defaultNamespace }); + } + + public getShapeId(): string { + return "smithy.protocols#rpcv2Cbor"; + } + + public getPayloadCodec(): CborCodec { + return this.codec; + } + + public async serializeRequest( + operationSchema: OperationSchema, + input: Input, + context: HandlerExecutionContext & SerdeContext + ): Promise { + const request = await super.serializeRequest(operationSchema, input, context); + Object.assign(request.headers, { + "content-type": "application/cbor", + "smithy-protocol": "rpc-v2-cbor", + accept: "application/cbor", + }); + if (deref(operationSchema.input) === "unit") { + delete request.body; + delete request.headers["content-type"]; + } else { + if (!request.body) { + this.serializer.write(15, {}); + request.body = this.serializer.flush(); + } + try { + request.headers["content-length"] = String((request.body as Uint8Array).byteLength); + } catch (e) {} + } + const { service, operation } = getSmithyContext(context) as { + service: string; + operation: string; + }; + const path = `/service/${service}/operation/${operation}`; + if (request.path.endsWith("/")) { + request.path += path.slice(1); + } else { + request.path += path; + } + return request; + } + + public async deserializeResponse( + operationSchema: OperationSchema, + context: HandlerExecutionContext & SerdeContext, + response: IHttpResponse + ): Promise { + return super.deserializeResponse(operationSchema, context, response); + } + + protected async handleError( + operationSchema: OperationSchema, + context: HandlerExecutionContext & SerdeContext, + response: IHttpResponse, + dataObject: any, + metadata: ResponseMetadata + ): Promise { + const error = loadSmithyRpcV2CborErrorCode(response, dataObject) ?? "Unknown"; + + let namespace = this.options.defaultNamespace; + if (error.includes("#")) { + [namespace] = error.split("#"); + } + + const registry = TypeRegistry.for(namespace); + const errorSchema: ErrorSchema = registry.getSchema(error) as ErrorSchema; + + if (!errorSchema) { + // TODO(schema) throw client base exception using the dataObject. + throw new Error("schema not found for " + error); + } + + const message = dataObject.message ?? dataObject.Message ?? "Unknown"; + const exception = new errorSchema.ctor(message); + Object.assign(exception, { + $metadata: metadata, + $response: response, + message, + ...dataObject, + }); + + throw exception; + } +} diff --git a/packages/core/src/submodules/cbor/index.ts b/packages/core/src/submodules/cbor/index.ts index 0910d274e31..c53524e3a48 100644 --- a/packages/core/src/submodules/cbor/index.ts +++ b/packages/core/src/submodules/cbor/index.ts @@ -1,3 +1,5 @@ export { cbor } from "./cbor"; +export { tag, tagSymbol } from "./cbor-types"; export * from "./parseCborBody"; -export { tagSymbol, tag } from "./cbor-types"; +export * from "./SmithyRpcV2CborProtocol"; +export * from "./CborCodec"; diff --git a/packages/core/src/submodules/protocols/HttpBindingProtocol.spec.ts b/packages/core/src/submodules/protocols/HttpBindingProtocol.spec.ts new file mode 100644 index 00000000000..2b8b9561bfc --- /dev/null +++ b/packages/core/src/submodules/protocols/HttpBindingProtocol.spec.ts @@ -0,0 +1,157 @@ +import { op, SCHEMA, struct } from "@smithy/core/schema"; +import { HttpResponse } from "@smithy/protocol-http"; +import { + Codec, + CodecSettings, + HandlerExecutionContext, + HttpResponse as IHttpResponse, + OperationSchema, + ResponseMetadata, + ShapeDeserializer, + ShapeSerializer, +} from "@smithy/types"; +import { parseUrl } from "@smithy/url-parser/src"; +import { describe, expect, test as it } from "vitest"; + +import { HttpBindingProtocol } from "./HttpBindingProtocol"; +import { FromStringShapeDeserializer } from "./serde/FromStringShapeDeserializer"; +import { ToStringShapeSerializer } from "./serde/ToStringShapeSerializer"; + +describe(HttpBindingProtocol.name, () => { + class StringRestProtocol extends HttpBindingProtocol { + protected serializer: ShapeSerializer; + protected deserializer: ShapeDeserializer; + + public constructor() { + super(); + const settings: CodecSettings = { + timestampFormat: { + useTrait: true, + default: SCHEMA.TIMESTAMP_EPOCH_SECONDS, + }, + httpBindings: true, + }; + this.serializer = new ToStringShapeSerializer(settings); + this.deserializer = new FromStringShapeDeserializer(settings); + } + + public getShapeId(): string { + throw new Error("Method not implemented."); + } + public getPayloadCodec(): Codec { + throw new Error("Method not implemented."); + } + protected handleError( + operationSchema: OperationSchema, + context: HandlerExecutionContext, + response: IHttpResponse, + dataObject: any, + metadata: ResponseMetadata + ): Promise { + void [operationSchema, context, response, dataObject, metadata]; + throw new Error("Method not implemented."); + } + } + + it("should deserialize timestamp list with unescaped commas", async () => { + const response = new HttpResponse({ + statusCode: 200, + headers: { + "x-timestamplist": "Mon, 16 Dec 2019 23:48:18 GMT, Mon, 16 Dec 2019 23:48:18 GMT", + }, + }); + + const protocol = new StringRestProtocol(); + const output = await protocol.deserializeResponse( + op( + "", + "", + 0, + "unit", + struct( + "", + "", + 0, + ["timestampList"], + [ + [ + SCHEMA.LIST_MODIFIER | SCHEMA.TIMESTAMP_DEFAULT, + { + httpHeader: "x-timestamplist", + }, + ], + ] + ) + ), + {}, + response + ); + delete output.$metadata; + expect(output).toEqual({ + timestampList: [new Date("2019-12-16T23:48:18.000Z"), new Date("2019-12-16T23:48:18.000Z")], + }); + }); + + it("should deserialize all headers when httpPrefixHeaders value is empty string", async () => { + const response = new HttpResponse({ + statusCode: 200, + headers: { + "x-tents": "tents", + hello: "Hello", + }, + }); + + const protocol = new StringRestProtocol(); + const output = await protocol.deserializeResponse( + op( + "", + "", + 0, + "unit", + struct( + "", + "", + 0, + ["httpPrefixHeaders"], + [ + [ + SCHEMA.MAP_MODIFIER | SCHEMA.STRING, + { + httpPrefixHeaders: "", + }, + ], + ] + ) + ), + {}, + response + ); + delete output.$metadata; + expect(output).toEqual({ + httpPrefixHeaders: { + "x-tents": "tents", + hello: "Hello", + }, + }); + }); + + it("should serialize custom paths in context-provided endpoint", async () => { + const protocol = new StringRestProtocol(); + const request = await protocol.serializeRequest( + op( + "", + "", + { + http: ["GET", "/Operation", 200], + }, + "unit", + "unit" + ), + {}, + { + endpoint: async () => parseUrl("https://localhost/custom"), + } as any + ); + expect(request.path).toEqual("/custom/Operation"); + }); +}); diff --git a/packages/core/src/submodules/protocols/HttpBindingProtocol.ts b/packages/core/src/submodules/protocols/HttpBindingProtocol.ts new file mode 100644 index 00000000000..d19416a5fe4 --- /dev/null +++ b/packages/core/src/submodules/protocols/HttpBindingProtocol.ts @@ -0,0 +1,233 @@ +import { NormalizedSchema, SCHEMA } from "@smithy/core/schema"; +import { HttpRequest } from "@smithy/protocol-http"; +import type { + Endpoint, + HandlerExecutionContext, + HttpRequest as IHttpRequest, + HttpResponse as IHttpResponse, + MetadataBearer, + OperationSchema, + SerdeContext, +} from "@smithy/types"; + +import { collectBody } from "./collect-stream-body"; +import { extendedEncodeURIComponent } from "./extended-encode-uri-component"; +import { HttpProtocol } from "./HttpProtocol"; + +/** + * @public + */ +export abstract class HttpBindingProtocol extends HttpProtocol { + public async serializeRequest( + operationSchema: OperationSchema, + input: Input, + context: HandlerExecutionContext & SerdeContext + ): Promise { + const serializer = this.serializer; + const query = {} as Record; + const headers = {} as Record; + const endpoint: Endpoint = await context.endpoint(); + + const ns = NormalizedSchema.of(operationSchema?.input); + const schema = ns.getSchema(); + + let hasNonHttpBindingMember = false; + let payload: any; + + const request = new HttpRequest({ + protocol: "", + hostname: "", + port: undefined, + path: "", + fragment: undefined, + query: query, + headers: headers, + body: undefined, + }); + + if (endpoint) { + this.updateServiceEndpoint(request, endpoint); + this.setHostPrefix(request, operationSchema, input); + const opTraits = NormalizedSchema.translateTraits(operationSchema.traits); + if (opTraits.http) { + request.method = opTraits.http[0]; + const [path, search] = opTraits.http[1].split("?"); + if (request.path == "/") { + request.path = path; + } else { + request.path += path; + } + const traitSearchParams = new URLSearchParams(search ?? ""); + Object.assign(query, Object.fromEntries(traitSearchParams)); + } + } + + const _input: any = { + ...input, + }; + + for (const memberName of Object.keys(_input)) { + const memberNs = ns.getMemberSchema(memberName); + if (memberNs === undefined) { + continue; + } + const memberTraits = memberNs.getMergedTraits(); + const inputMember = (_input as any)[memberName] as any; + + if (memberTraits.httpPayload) { + const isStreaming = memberNs.isStreaming(); + if (isStreaming) { + const isEventStream = memberNs.isStructSchema(); + if (isEventStream) { + // todo(schema) + throw new Error("serialization of event streams is not yet implemented"); + } else { + // streaming blob body + payload = inputMember; + } + } else { + // structural/document body + serializer.write(memberNs, inputMember); + payload = serializer.flush(); + } + } else if (memberTraits.httpLabel) { + serializer.write(memberNs, inputMember); + const replacement = serializer.flush() as string; + if (request.path.includes(`{${memberName}+}`)) { + request.path = request.path.replace( + `{${memberName}+}`, + replacement.split("/").map(extendedEncodeURIComponent).join("/") + ); + } else if (request.path.includes(`{${memberName}}`)) { + request.path = request.path.replace(`{${memberName}}`, extendedEncodeURIComponent(replacement)); + } + delete _input[memberName]; + } else if (memberTraits.httpHeader) { + serializer.write(memberNs, inputMember); + headers[memberTraits.httpHeader.toLowerCase() as string] = String(serializer.flush()); + delete _input[memberName]; + } else if (typeof memberTraits.httpPrefixHeaders === "string") { + for (const [key, val] of Object.entries(inputMember)) { + const amalgam = memberTraits.httpPrefixHeaders + key; + serializer.write([memberNs.getValueSchema(), { httpHeader: amalgam }], val); + headers[amalgam.toLowerCase()] = serializer.flush() as string; + } + delete _input[memberName]; + } else if (memberTraits.httpQuery || memberTraits.httpQueryParams) { + this.serializeQuery(memberNs, inputMember, query); + delete _input[memberName]; + } else { + hasNonHttpBindingMember = true; + } + } + + if (hasNonHttpBindingMember && input) { + serializer.write(schema, _input); + payload = serializer.flush() as Uint8Array; + } + + request.headers = headers; + request.query = query; + request.body = payload; + + return request; + } + + protected serializeQuery(ns: NormalizedSchema, data: any, query: HttpRequest["query"]) { + const serializer = this.serializer; + const traits = ns.getMergedTraits(); + + if (traits.httpQueryParams) { + for (const [key, val] of Object.entries(data)) { + if (!(key in query)) { + this.serializeQuery( + NormalizedSchema.of([ + ns.getValueSchema(), + { + // We pass on the traits to the sub-schema + // because we are still in the process of serializing the map itself. + ...traits, + httpQuery: key, + httpQueryParams: undefined, + }, + ]), + val, + query + ); + } + } + return; + } + + if (ns.isListSchema()) { + const sparse = !!ns.getMergedTraits().sparse; + const buffer = []; + for (const item of data) { + // We pass on the traits to the sub-schema + // because we are still in the process of serializing the list itself. + serializer.write([ns.getValueSchema(), traits], item); + const serializable = serializer.flush() as string; + if (sparse || serializable !== undefined) { + buffer.push(serializable); + } + } + query[traits.httpQuery as string] = buffer; + } else { + serializer.write([ns, traits], data); + query[traits.httpQuery as string] = serializer.flush() as string; + } + } + + public async deserializeResponse( + operationSchema: OperationSchema, + context: HandlerExecutionContext & SerdeContext, + 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); + if (bytes.byteLength > 0) { + Object.assign(dataObject, await deserializer.read(SCHEMA.DOCUMENT, bytes)); + } + await this.handleError(operationSchema, context, response, dataObject, this.deserializeMetadata(response)); + throw new Error("@smithy/core/protocols - HTTP Protocol error handler failed to throw."); + } + + for (const header in response.headers) { + const value = response.headers[header]; + delete response.headers[header]; + response.headers[header.toLowerCase()] = value; + } + + const headerBindings = new Set( + Object.values(ns.getMemberSchemas()) + .map((schema) => { + return schema.getMergedTraits().httpHeader; + }) + .filter(Boolean) as string[] + ); + + const nonHttpBindingMembers = await this.deserializeHttpMessage(ns, context, response, headerBindings, dataObject); + + if (nonHttpBindingMembers.length) { + const bytes: Uint8Array = await collectBody(response.body, context as SerdeContext); + if (bytes.byteLength > 0) { + const dataFromBody = await deserializer.read(ns, bytes); + for (const member of nonHttpBindingMembers) { + dataObject[member] = dataFromBody[member]; + } + } + } + + const output: Output = { + $metadata: this.deserializeMetadata(response), + ...dataObject, + }; + + return output; + } +} diff --git a/packages/core/src/submodules/protocols/HttpProtocol.ts b/packages/core/src/submodules/protocols/HttpProtocol.ts new file mode 100644 index 00000000000..cbb0622993e --- /dev/null +++ b/packages/core/src/submodules/protocols/HttpProtocol.ts @@ -0,0 +1,237 @@ +import { NormalizedSchema, SCHEMA } from "@smithy/core/schema"; +import { splitEvery, splitHeader } from "@smithy/core/serde"; +import { HttpRequest, HttpResponse } from "@smithy/protocol-http"; +import type { + Codec, + Endpoint, + EndpointV2, + EventStreamSerdeContext, + HandlerExecutionContext, + HttpRequest as IHttpRequest, + HttpResponse as IHttpResponse, + MetadataBearer, + OperationSchema, + Protocol, + ResponseMetadata, + Schema, + SerdeContext, + ShapeDeserializer, + ShapeSerializer, +} from "@smithy/types"; +import { sdkStreamMixin } from "@smithy/util-stream"; + +import { collectBody } from "./collect-stream-body"; + +/** + * @public + */ +export abstract class HttpProtocol implements Protocol { + protected abstract serializer: ShapeSerializer; + protected abstract deserializer: ShapeDeserializer; + protected serdeContext?: SerdeContext; + + protected constructor( + public readonly options: { + defaultNamespace: string; + } + ) {} + + public abstract getShapeId(): string; + + public abstract getPayloadCodec(): Codec; + + public getRequestType(): new (...args: any[]) => IHttpRequest { + return HttpRequest; + } + + public getResponseType(): new (...args: any[]) => IHttpResponse { + return HttpResponse; + } + + public setSerdeContext(serdeContext: SerdeContext): void { + this.serdeContext = serdeContext; + this.serializer.setSerdeContext(serdeContext); + this.deserializer.setSerdeContext(serdeContext); + if (this.getPayloadCodec()) { + this.getPayloadCodec().setSerdeContext(serdeContext); + } + } + + public abstract serializeRequest( + operationSchema: OperationSchema, + input: Input, + context: HandlerExecutionContext & SerdeContext + ): Promise; + + public updateServiceEndpoint(request: IHttpRequest, endpoint: EndpointV2 | Endpoint) { + if ("url" in endpoint) { + request.protocol = endpoint.url.protocol; + request.hostname = endpoint.url.hostname; + request.port = endpoint.url.port ? Number(endpoint.url.port) : undefined; + request.path = endpoint.url.pathname; + request.fragment = endpoint.url.hash || void 0; + request.username = endpoint.url.username || void 0; + request.password = endpoint.url.password || void 0; + for (const [k, v] of endpoint.url.searchParams.entries()) { + if (!request.query) { + request.query = {}; + } + request.query[k] = v; + } + return request; + } else { + request.protocol = endpoint.protocol; + request.hostname = endpoint.hostname; + request.port = endpoint.port ? Number(endpoint.port) : undefined; + request.path = endpoint.path; + request.query = { + ...endpoint.query, + }; + return request; + } + } + + public abstract deserializeResponse( + operationSchema: OperationSchema, + context: HandlerExecutionContext & SerdeContext, + response: IHttpResponse + ): Promise; + + protected setHostPrefix( + request: IHttpRequest, + operationSchema: OperationSchema, + input: Input + ): void { + const operationNs = NormalizedSchema.of(operationSchema); + const inputNs = NormalizedSchema.of(operationSchema.input); + if (operationNs.getMergedTraits().endpoint) { + let hostPrefix = operationNs.getMergedTraits().endpoint?.[0]; + if (typeof hostPrefix === "string") { + const hostLabelInputs = Object.entries(inputNs.getMemberSchemas()).filter( + ([, member]) => member.getMergedTraits().hostLabel + ); + for (const [name] of hostLabelInputs) { + const replacement = input[name as keyof typeof input]; + if (typeof replacement !== "string") { + throw new Error(`@smithy/core/schema - ${name} in input must be a string as hostLabel.`); + } + hostPrefix = hostPrefix.replace(`{${name}}`, replacement); + } + request.hostname = hostPrefix + request.hostname; + } + } + } + + protected abstract handleError( + operationSchema: OperationSchema, + context: HandlerExecutionContext & SerdeContext, + response: IHttpResponse, + dataObject: any, + metadata: ResponseMetadata + ): Promise; + + protected deserializeMetadata(output: IHttpResponse): ResponseMetadata { + return { + httpStatusCode: output.statusCode, + requestId: + output.headers["x-amzn-requestid"] ?? output.headers["x-amzn-request-id"] ?? output.headers["x-amz-request-id"], + extendedRequestId: output.headers["x-amz-id-2"], + cfId: output.headers["x-amz-cf-id"], + }; + } + + protected async deserializeHttpMessage( + schema: Schema, + context: HandlerExecutionContext & SerdeContext, + response: IHttpResponse, + headerBindings: Set, + dataObject: any + ): Promise { + const deserializer = this.deserializer; + const ns = NormalizedSchema.of(schema); + const nonHttpBindingMembers = [] as string[]; + + for (const [memberName, memberSchema] of Object.entries(ns.getMemberSchemas())) { + const memberTraits = memberSchema.getMemberTraits(); + + if (memberTraits.httpPayload) { + const isStreaming = memberSchema.isStreaming(); + if (isStreaming) { + const isEventStream = memberSchema.isStructSchema(); + if (isEventStream) { + // streaming event stream (union) + const context = this.serdeContext as unknown as EventStreamSerdeContext; + if (!context.eventStreamMarshaller) { + throw new Error("@smithy/core - HttpProtocol: eventStreamMarshaller missing in serdeContext."); + } + const memberSchemas = memberSchema.getMemberSchemas(); + dataObject[memberName] = context.eventStreamMarshaller.deserialize(response.body, async (event) => { + const unionMember = + Object.keys(event).find((key) => { + return key !== "__type"; + }) ?? ""; + if (unionMember in memberSchemas) { + const eventStreamSchema = memberSchemas[unionMember]; + return { + [unionMember]: await deserializer.read(eventStreamSchema, event[unionMember].body), + }; + } else { + // this union convention is ignored by the event stream marshaller. + return { + $unknown: event, + }; + } + }); + } else { + // streaming blob body + dataObject[memberName] = sdkStreamMixin(response.body); + } + } else if (response.body) { + const bytes: Uint8Array = await collectBody(response.body, context as SerdeContext); + if (bytes.byteLength > 0) { + dataObject[memberName] = await deserializer.read(memberSchema, bytes); + } + } + } else if (memberTraits.httpHeader) { + const key = String(memberTraits.httpHeader).toLowerCase(); + const value = response.headers[key]; + if (null != value) { + if (memberSchema.isListSchema()) { + const headerListValueSchema = memberSchema.getValueSchema(); + let sections: string[]; + if ( + headerListValueSchema.isTimestampSchema() && + headerListValueSchema.getSchema() === SCHEMA.TIMESTAMP_DEFAULT + ) { + sections = splitEvery(value, ",", 2); + } else { + sections = splitHeader(value); + } + const list = []; + for (const section of sections) { + list.push(await deserializer.read([headerListValueSchema, { httpHeader: key }], section.trim())); + } + dataObject[memberName] = list; + } else { + dataObject[memberName] = await deserializer.read(memberSchema, value); + } + } + } else if (memberTraits.httpPrefixHeaders !== undefined) { + dataObject[memberName] = {}; + for (const [header, value] of Object.entries(response.headers)) { + if (!headerBindings.has(header) && header.startsWith(memberTraits.httpPrefixHeaders)) { + dataObject[memberName][header.slice(memberTraits.httpPrefixHeaders.length)] = await deserializer.read( + [memberSchema.getValueSchema(), { httpHeader: header }], + value + ); + } + } + } else if (memberTraits.httpResponseCode) { + dataObject[memberName] = response.statusCode; + } else { + nonHttpBindingMembers.push(memberName); + } + } + return nonHttpBindingMembers; + } +} diff --git a/packages/core/src/submodules/protocols/HttpTransport.ts b/packages/core/src/submodules/protocols/HttpTransport.ts new file mode 100644 index 00000000000..35e1c940a2c --- /dev/null +++ b/packages/core/src/submodules/protocols/HttpTransport.ts @@ -0,0 +1,15 @@ +import { HttpRequest, HttpResponse } from "@smithy/protocol-http"; +import { HandlerExecutionContext, Transport } from "@smithy/types"; + +export class HttpTransport implements Transport { + getRequestType(): new (...args: any[]) => HttpRequest { + return HttpRequest; + } + getResponseType(): new (...args: any[]) => HttpResponse { + return HttpResponse; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + send(context: HandlerExecutionContext, request: HttpRequest): Promise { + throw new Error("Method not implemented."); + } +} diff --git a/packages/core/src/submodules/protocols/RpcProtocol.ts b/packages/core/src/submodules/protocols/RpcProtocol.ts new file mode 100644 index 00000000000..bbf102a864d --- /dev/null +++ b/packages/core/src/submodules/protocols/RpcProtocol.ts @@ -0,0 +1,105 @@ +import { NormalizedSchema, SCHEMA } from "@smithy/core/schema"; +import { HttpRequest } from "@smithy/protocol-http"; +import type { + Endpoint, + HandlerExecutionContext, + HttpRequest as IHttpRequest, + HttpResponse as IHttpResponse, + MetadataBearer, + OperationSchema, + SerdeContext, +} from "@smithy/types"; + +import { collectBody } from "./collect-stream-body"; +import { HttpProtocol } from "./HttpProtocol"; + +/** + * @public + */ +export abstract class RpcProtocol extends HttpProtocol { + public async serializeRequest( + operationSchema: OperationSchema, + input: Input, + context: HandlerExecutionContext & SerdeContext + ): Promise { + const serializer = this.serializer; + const query = {} as Record; + const headers = {} as Record; + const endpoint: Endpoint = await context.endpoint(); + + const ns = NormalizedSchema.of(operationSchema?.input); + const schema = ns.getSchema(); + + let payload: any; + + const request = new HttpRequest({ + protocol: "", + hostname: "", + port: undefined, + path: "/", + fragment: undefined, + query: query, + headers: headers, + body: undefined, + }); + + if (endpoint) { + this.updateServiceEndpoint(request, endpoint); + this.setHostPrefix(request, operationSchema, input); + } + + const _input: any = { + ...input, + }; + + if (input) { + serializer.write(schema, _input); + payload = serializer.flush() as Uint8Array; + } + + request.headers = headers; + request.query = query; + request.body = payload; + request.method = "POST"; + + return request; + } + + public async deserializeResponse( + operationSchema: OperationSchema, + context: HandlerExecutionContext & SerdeContext, + 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 SerdeContext); + if (bytes.byteLength > 0) { + Object.assign(dataObject, await deserializer.read(SCHEMA.DOCUMENT, bytes)); + } + await this.handleError(operationSchema, context, response, dataObject, this.deserializeMetadata(response)); + throw new Error("@smithy/core/protocols - RPC Protocol error handler failed to throw."); + } + + for (const header in response.headers) { + const value = response.headers[header]; + delete response.headers[header]; + response.headers[header.toLowerCase()] = value; + } + + const bytes: Uint8Array = await collectBody(response.body, context as SerdeContext); + if (bytes.byteLength > 0) { + Object.assign(dataObject, await deserializer.read(ns, bytes)); + } + + const output: Output = { + $metadata: this.deserializeMetadata(response), + ...dataObject, + }; + + return output; + } +} diff --git a/packages/core/src/submodules/protocols/index.ts b/packages/core/src/submodules/protocols/index.ts index a5de22f1a4a..c147a806a87 100644 --- a/packages/core/src/submodules/protocols/index.ts +++ b/packages/core/src/submodules/protocols/index.ts @@ -1,4 +1,12 @@ export * from "./collect-stream-body"; export * from "./extended-encode-uri-component"; +export * from "./HttpBindingProtocol"; +export * from "./HttpTransport"; +export * from "./RpcProtocol"; export * from "./requestBuilder"; export * from "./resolve-path"; +export * from "./serde/FromStringShapeDeserializer"; +export * from "./serde/HttpInterceptingShapeDeserializer"; +export * from "./serde/HttpInterceptingShapeSerializer"; +export * from "./serde/ToStringShapeSerializer"; +export * from "./serde/determineTimestampFormat"; diff --git a/packages/core/src/submodules/protocols/serde/FromStringShapeDeserializer.ts b/packages/core/src/submodules/protocols/serde/FromStringShapeDeserializer.ts new file mode 100644 index 00000000000..19712dde784 --- /dev/null +++ b/packages/core/src/submodules/protocols/serde/FromStringShapeDeserializer.ts @@ -0,0 +1,79 @@ +import { NormalizedSchema, SCHEMA } from "@smithy/core/schema"; +import { + LazyJsonString, + NumericValue, + parseEpochTimestamp, + parseRfc3339DateTimeWithOffset, + parseRfc7231DateTime, + splitHeader, +} from "@smithy/core/serde"; +import { CodecSettings, Schema, SerdeContext, ShapeDeserializer } from "@smithy/types"; +import { fromBase64 } from "@smithy/util-base64"; +import { toUtf8 } from "@smithy/util-utf8"; + +import { determineTimestampFormat } from "./determineTimestampFormat"; + +export class FromStringShapeDeserializer implements ShapeDeserializer { + private serdeContext: SerdeContext | undefined; + + public constructor(private settings: CodecSettings) {} + + public setSerdeContext(serdeContext: SerdeContext): void { + this.serdeContext = serdeContext; + } + + public read(_schema: Schema, data: string): any { + const ns = NormalizedSchema.of(_schema); + if (ns.isListSchema()) { + return splitHeader(data).map((item) => this.read(ns.getValueSchema(), item)); + } + if (ns.isBlobSchema()) { + return (this.serdeContext?.base64Decoder ?? fromBase64)(data); + } + if (ns.isTimestampSchema()) { + const format = determineTimestampFormat(ns, this.settings); + switch (format) { + case SCHEMA.TIMESTAMP_DATE_TIME: + return parseRfc3339DateTimeWithOffset(data); + case SCHEMA.TIMESTAMP_HTTP_DATE: + return parseRfc7231DateTime(data); + case SCHEMA.TIMESTAMP_EPOCH_SECONDS: + return parseEpochTimestamp(data); + default: + console.warn("Missing timestamp format, parsing value with Date constructor:", data); + return new Date(data as string | number); + } + } + + if (ns.isStringSchema()) { + const mediaType = ns.getMergedTraits().mediaType; + let intermediateValue: string | LazyJsonString = data; + if (mediaType) { + if (ns.getMergedTraits().httpHeader) { + intermediateValue = this.base64ToUtf8(intermediateValue); + } + const isJson = mediaType === "application/json" || mediaType.endsWith("+json"); + if (isJson) { + intermediateValue = LazyJsonString.from(intermediateValue); + } + return intermediateValue; + } + } + + switch (true) { + case ns.isNumericSchema(): + return Number(data); + case ns.isBigIntegerSchema(): + return BigInt(data); + case ns.isBigDecimalSchema(): + return new NumericValue(data, "bigDecimal"); + case ns.isBooleanSchema(): + return String(data).toLowerCase() === "true"; + } + return data; + } + + private base64ToUtf8(base64String: string): any { + return (this.serdeContext?.utf8Encoder ?? toUtf8)((this.serdeContext?.base64Decoder ?? fromBase64)(base64String)); + } +} diff --git a/packages/core/src/submodules/protocols/serde/HttpInterceptingShapeDeserializer.ts b/packages/core/src/submodules/protocols/serde/HttpInterceptingShapeDeserializer.ts new file mode 100644 index 00000000000..e7cca160800 --- /dev/null +++ b/packages/core/src/submodules/protocols/serde/HttpInterceptingShapeDeserializer.ts @@ -0,0 +1,50 @@ +import { NormalizedSchema } from "@smithy/core/schema"; +import { CodecSettings, Schema, SerdeContext, ShapeDeserializer } from "@smithy/types"; +import { fromUtf8, toUtf8 } from "@smithy/util-utf8"; + +import { FromStringShapeDeserializer } from "./FromStringShapeDeserializer"; + +export class HttpInterceptingShapeDeserializer> + implements ShapeDeserializer +{ + private stringDeserializer: FromStringShapeDeserializer; + private serdeContext: SerdeContext | undefined; + + public constructor( + private codecDeserializer: CodecShapeDeserializer, + codecSettings: CodecSettings + ) { + this.stringDeserializer = new FromStringShapeDeserializer(codecSettings); + } + + public setSerdeContext(serdeContext: SerdeContext): void { + this.stringDeserializer.setSerdeContext(serdeContext); + this.codecDeserializer.setSerdeContext(serdeContext); + this.serdeContext = serdeContext; + } + + public read(schema: Schema, data: string | Uint8Array): any | Promise { + const ns = NormalizedSchema.of(schema); + const traits = ns.getMergedTraits(); + const toString = this.serdeContext?.utf8Encoder ?? toUtf8; + + if (traits.httpHeader || traits.httpResponseCode) { + return this.stringDeserializer.read(ns, toString(data)); + } + if (traits.httpPayload) { + if (ns.isBlobSchema()) { + const toBytes = this.serdeContext?.utf8Decoder ?? fromUtf8; + if (typeof data === "string") { + return toBytes(data); + } + return data; + } else if (ns.isStringSchema()) { + if ("byteLength" in (data as Uint8Array)) { + return toString(data); + } + return data; + } + } + return this.codecDeserializer.read(ns, data); + } +} diff --git a/packages/core/src/submodules/protocols/serde/HttpInterceptingShapeSerializer.ts b/packages/core/src/submodules/protocols/serde/HttpInterceptingShapeSerializer.ts new file mode 100644 index 00000000000..7ef15f8a605 --- /dev/null +++ b/packages/core/src/submodules/protocols/serde/HttpInterceptingShapeSerializer.ts @@ -0,0 +1,41 @@ +import { NormalizedSchema } from "@smithy/core/schema"; +import { CodecSettings, Schema as ISchema, SerdeContext, ShapeSerializer } from "@smithy/types"; + +import { ToStringShapeSerializer } from "./ToStringShapeSerializer"; + +export class HttpInterceptingShapeSerializer> + implements ShapeSerializer +{ + private buffer: string | undefined; + + public constructor( + private codecSerializer: CodecShapeSerializer, + codecSettings: CodecSettings, + private stringSerializer = new ToStringShapeSerializer(codecSettings) + ) {} + + public setSerdeContext(serdeContext: SerdeContext): void { + this.codecSerializer.setSerdeContext(serdeContext); + this.stringSerializer.setSerdeContext(serdeContext); + } + + public write(schema: ISchema, value: unknown): void { + const ns = NormalizedSchema.of(schema); + const traits = ns.getMergedTraits(); + if (traits.httpHeader || traits.httpLabel || traits.httpQuery) { + this.stringSerializer.write(ns, value); + this.buffer = this.stringSerializer.flush(); + return; + } + return this.codecSerializer.write(ns, value); + } + + public flush(): string | Uint8Array { + if (this.buffer !== undefined) { + const buffer = this.buffer; + this.buffer = undefined; + return buffer; + } + return this.codecSerializer.flush(); + } +} diff --git a/packages/core/src/submodules/protocols/serde/ToStringShapeSerializer.ts b/packages/core/src/submodules/protocols/serde/ToStringShapeSerializer.ts new file mode 100644 index 00000000000..ad8088d8949 --- /dev/null +++ b/packages/core/src/submodules/protocols/serde/ToStringShapeSerializer.ts @@ -0,0 +1,97 @@ +import { NormalizedSchema, SCHEMA } from "@smithy/core/schema"; +import { dateToUtcString, LazyJsonString, quoteHeader } from "@smithy/core/serde"; +import { CodecSettings, Schema, SerdeContext, ShapeSerializer } from "@smithy/types"; +import { toBase64 } from "@smithy/util-base64"; + +import { determineTimestampFormat } from "./determineTimestampFormat"; + +/** + * Serializes a shape to string. + */ +export class ToStringShapeSerializer implements ShapeSerializer { + private stringBuffer = ""; + private serdeContext: SerdeContext | undefined = undefined; + + public constructor(private settings: CodecSettings) {} + + public setSerdeContext(serdeContext: SerdeContext): void { + this.serdeContext = serdeContext; + } + + public write(schema: Schema, value: unknown): void { + const ns = NormalizedSchema.of(schema); + switch (typeof value) { + case "object": + if (value === null) { + this.stringBuffer = "null"; + return; + } + if (ns.isTimestampSchema()) { + if (!(value instanceof Date)) { + throw new Error( + `@smithy/core/protocols - received non-Date value ${value} when schema expected Date in ${ns.getName(true)}` + ); + } + const format = determineTimestampFormat(ns, this.settings); + switch (format) { + case SCHEMA.TIMESTAMP_DATE_TIME: + this.stringBuffer = value.toISOString().replace(".000Z", "Z"); + break; + case SCHEMA.TIMESTAMP_HTTP_DATE: + this.stringBuffer = dateToUtcString(value); + break; + case SCHEMA.TIMESTAMP_EPOCH_SECONDS: + this.stringBuffer = String(value.getTime() / 1000); + break; + default: + console.warn("Missing timestamp format, using epoch seconds", value); + this.stringBuffer = String(value.getTime() / 1000); + } + return; + } + if (ns.isBlobSchema() && "byteLength" in (value as Uint8Array)) { + this.stringBuffer = (this.serdeContext?.base64Encoder ?? toBase64)(value as Uint8Array); + return; + } + if (ns.isListSchema() && Array.isArray(value)) { + let buffer = ""; + for (const item of value) { + this.write([ns.getValueSchema(), ns.getMergedTraits()], item); + const headerItem = this.flush(); + const serialized = ns.getValueSchema().isTimestampSchema() ? headerItem : quoteHeader(headerItem); + if (buffer !== "") { + buffer += ", "; + } + buffer += serialized; + } + this.stringBuffer = buffer; + return; + } + this.stringBuffer = JSON.stringify(value, null, 2); + break; + case "string": + const mediaType = ns.getMergedTraits().mediaType; + let intermediateValue: string | LazyJsonString = value; + if (mediaType) { + const isJson = mediaType === "application/json" || mediaType.endsWith("+json"); + if (isJson) { + intermediateValue = LazyJsonString.from(intermediateValue); + } + if (ns.getMergedTraits().httpHeader) { + this.stringBuffer = (this.serdeContext?.base64Encoder ?? toBase64)(intermediateValue.toString()); + return; + } + } + this.stringBuffer = value; + break; + default: + this.stringBuffer = String(value); + } + } + + public flush(): string { + const buffer = this.stringBuffer; + this.stringBuffer = ""; + return buffer; + } +} diff --git a/packages/core/src/submodules/protocols/serde/determineTimestampFormat.ts b/packages/core/src/submodules/protocols/serde/determineTimestampFormat.ts new file mode 100644 index 00000000000..d11c5040148 --- /dev/null +++ b/packages/core/src/submodules/protocols/serde/determineTimestampFormat.ts @@ -0,0 +1,34 @@ +import { NormalizedSchema, SCHEMA } from "@smithy/core/schema"; +import { + type TimestampDateTimeSchema, + type TimestampEpochSecondsSchema, + type TimestampHttpDateSchema, + CodecSettings, +} from "@smithy/types"; + +export function determineTimestampFormat( + ns: NormalizedSchema, + settings: CodecSettings +): TimestampDateTimeSchema | TimestampHttpDateSchema | TimestampEpochSecondsSchema { + if (settings.timestampFormat.useTrait) { + if ( + ns.isTimestampSchema() && + (ns.getSchema() === SCHEMA.TIMESTAMP_DATE_TIME || + ns.getSchema() === SCHEMA.TIMESTAMP_HTTP_DATE || + ns.getSchema() === SCHEMA.TIMESTAMP_EPOCH_SECONDS) + ) { + return ns.getSchema() as TimestampDateTimeSchema | TimestampHttpDateSchema | TimestampEpochSecondsSchema; + } + } + + const { httpLabel, httpPrefixHeaders, httpHeader, httpQuery } = ns.getMergedTraits(); + const bindingFormat = settings.httpBindings + ? typeof httpPrefixHeaders === "string" || Boolean(httpHeader) + ? SCHEMA.TIMESTAMP_HTTP_DATE + : Boolean(httpQuery) || Boolean(httpLabel) + ? SCHEMA.TIMESTAMP_DATE_TIME + : undefined + : undefined; + + return bindingFormat ?? settings.timestampFormat.default; +} diff --git a/packages/core/src/submodules/schema/TypeRegistry.ts b/packages/core/src/submodules/schema/TypeRegistry.ts new file mode 100644 index 00000000000..232f81396c4 --- /dev/null +++ b/packages/core/src/submodules/schema/TypeRegistry.ts @@ -0,0 +1,72 @@ +import type { Schema as ISchema } from "@smithy/types"; + +import { ErrorSchema } from "./schemas/ErrorSchema"; + +export class TypeRegistry { + public static active: TypeRegistry | null = null; + public static readonly registries = new Map(); + + private constructor( + public readonly namespace: string, + private schemas: Map = new Map() + ) {} + + /** + * @param namespace - specifier. + * @returns the schema for that namespace, creating it if necessary. + */ + public static for(namespace: string): TypeRegistry { + if (!TypeRegistry.registries.has(namespace)) { + TypeRegistry.registries.set(namespace, new TypeRegistry(namespace)); + } + return TypeRegistry.registries.get(namespace)!; + } + + /** + * The active type registry's namespace is used. + * @param shapeId - to be registered. + * @param schema - to be registered. + */ + public register(shapeId: string, schema: ISchema) { + const qualifiedName = this.normalizeShapeId(shapeId); + const registry = TypeRegistry.for(this.getNamespace(shapeId)); + registry.schemas.set(qualifiedName, schema); + } + + /** + * @param shapeId - query. + * @returns the schema. + */ + public getSchema(shapeId: string): ISchema { + const id = this.normalizeShapeId(shapeId); + if (!this.schemas.has(id)) { + throw new Error(`@smithy/core/schema - schema not found for ${id}`); + } + return this.schemas.get(id)!; + } + + public getBaseException(): ErrorSchema | undefined { + for (const [id, schema] of this.schemas.entries()) { + if (id.startsWith("awssdkjs.synthetic.") && id.endsWith("ServiceException")) { + return schema as ErrorSchema; + } + } + return undefined; + } + + public destroy() { + TypeRegistry.registries.delete(this.namespace); + this.schemas.clear(); + } + + private normalizeShapeId(shapeId: string) { + if (shapeId.includes("#")) { + return shapeId; + } + return this.namespace + "#" + shapeId; + } + + private getNamespace(shapeId: string) { + return this.normalizeShapeId(shapeId).split("#")[0]; + } +} diff --git a/packages/core/src/submodules/schema/deref.ts b/packages/core/src/submodules/schema/deref.ts new file mode 100644 index 00000000000..faee11d9587 --- /dev/null +++ b/packages/core/src/submodules/schema/deref.ts @@ -0,0 +1,12 @@ +import { Schema, SchemaRef } from "@smithy/types"; + +/** + * Dereferences a Schema pointer fn if needed. + * @internal + */ +export const deref = (schemaRef: SchemaRef): Schema => { + if (typeof schemaRef === "function") { + return schemaRef(); + } + return schemaRef; +}; diff --git a/packages/core/src/submodules/schema/index.ts b/packages/core/src/submodules/schema/index.ts new file mode 100644 index 00000000000..9ce425627c1 --- /dev/null +++ b/packages/core/src/submodules/schema/index.ts @@ -0,0 +1,12 @@ +export * from "./deref"; +export * from "./middleware/schema-serde-plugin"; +export * from "./schemas/ListSchema"; +export * from "./schemas/MapSchema"; +export * from "./schemas/OperationSchema"; +export * from "./schemas/ErrorSchema"; +export * from "./schemas/NormalizedSchema"; +export * from "./schemas/Schema"; +export * from "./schemas/SimpleSchema"; +export * from "./schemas/StructureSchema"; +export * from "./schemas/sentinels"; +export * from "./TypeRegistry"; diff --git a/packages/core/src/submodules/schema/middleware/schema-serde-plugin.ts b/packages/core/src/submodules/schema/middleware/schema-serde-plugin.ts new file mode 100644 index 00000000000..90ca8274f53 --- /dev/null +++ b/packages/core/src/submodules/schema/middleware/schema-serde-plugin.ts @@ -0,0 +1,143 @@ +import { + DeserializeHandler, + DeserializeHandlerArguments, + DeserializeHandlerOptions, + Endpoint, + HandlerExecutionContext, + MetadataBearer, + MiddlewareStack, + OperationSchema as IOperationSchema, + Pluggable, + Protocol, + Provider, + SerdeContext, + SerdeFunctions, + SerializeHandler, + SerializeHandlerArguments, + SerializeHandlerOptions, + UrlParser, +} from "@smithy/types"; +import { getSmithyContext } from "@smithy/util-middleware"; + +export const deserializerMiddlewareOption: DeserializeHandlerOptions = { + name: "deserializerMiddleware", + step: "deserialize", + tags: ["DESERIALIZER"], + override: true, +}; + +export const serializerMiddlewareOption: SerializeHandlerOptions = { + name: "serializerMiddleware", + step: "serialize", + tags: ["SERIALIZER"], + override: true, +}; + +/** + * @internal + */ +export type ProtocolAwareConfig = SerdeContext & { + protocol: Protocol; + urlParser: UrlParser; +}; + +/** + * @internal + */ +export function getSchemaSerdePlugin( + config: ProtocolAwareConfig & SerdeContext +): Pluggable { + return { + applyToStack: (commandStack: MiddlewareStack) => { + commandStack.add(schemaSerializationMiddleware(config), serializerMiddlewareOption); + commandStack.add(schemaDeserializationMiddleware(config), deserializerMiddlewareOption); + // `config` is fully resolved at the point of applying plugins. + // As such, config qualifies as SerdeContext. + config.protocol.setSerdeContext(config); + }, + }; +} + +/** + * @internal + */ +export const schemaSerializationMiddleware = + (config: ProtocolAwareConfig & SerdeFunctions) => + (next: SerializeHandler, context: HandlerExecutionContext) => + async (args: SerializeHandlerArguments) => { + const { operationSchema } = getSmithyContext(context) as { + operationSchema: IOperationSchema; + }; + + const endpoint: Provider = + context.endpointV2?.url && config.urlParser + ? async () => config.urlParser!(context.endpointV2!.url as URL) + : config.endpoint!; + + const request = await config.protocol.serializeRequest(operationSchema, args.input, { + ...config, + ...context, + endpoint, + }); + return next({ + ...args, + request, + }); + }; + +/** + * @internal + */ +export const schemaDeserializationMiddleware = + (config: ProtocolAwareConfig & SerdeFunctions) => + (next: DeserializeHandler, context: HandlerExecutionContext) => + async (args: DeserializeHandlerArguments) => { + const { response } = await next(args); + const { operationSchema } = getSmithyContext(context) as { + operationSchema: IOperationSchema; + }; + try { + const parsed = await config.protocol.deserializeResponse( + operationSchema, + { + ...config, + ...context, + }, + response + ); + return { + response, + output: parsed as O, + }; + } catch (error) { + // For security reasons, the error response is not completely visible by default. + Object.defineProperty(error, "$response", { + value: response, + }); + + if (!("$metadata" in error)) { + // only apply this to non-ServiceException. + const hint = `Deserialization error: to see the raw response, inspect the hidden field {error}.$response on this object.`; + try { + error.message += "\n " + hint; + } catch (e) { + // Error with an unwritable message (strict mode getter with no setter). + if (!context.logger || context.logger?.constructor?.name === "NoOpLogger") { + console.warn(hint); + } else { + context.logger?.warn?.(hint); + } + } + + if (typeof error.$responseBodyText !== "undefined") { + // if $responseBodyText was collected by the error parser, assign it to + // replace the response body, because it was consumed and is now empty. + if (error.$response) { + error.$response.body = error.$responseBodyText; + } + } + } + + throw error; + } + }; diff --git a/packages/core/src/submodules/schema/schemas/ErrorSchema.ts b/packages/core/src/submodules/schema/schemas/ErrorSchema.ts new file mode 100644 index 00000000000..add8e05c887 --- /dev/null +++ b/packages/core/src/submodules/schema/schemas/ErrorSchema.ts @@ -0,0 +1,32 @@ +import type { SchemaRef, SchemaTraits } from "@smithy/types"; + +import { TypeRegistry } from "../TypeRegistry"; +import { StructureSchema } from "./StructureSchema"; + +export class ErrorSchema extends StructureSchema { + public constructor( + public name: string, + public traits: SchemaTraits, + public memberNames: string[], + public memberList: SchemaRef[], + /** + * Constructor for a modeled service exception class that extends Error. + */ + public ctor: any + ) { + super(name, traits, memberNames, memberList); + } +} + +export function error( + namespace: string, + name: string, + traits: SchemaTraits = {}, + memberNames: string[], + memberList: SchemaRef[], + ctor: any +): ErrorSchema { + const schema = new ErrorSchema(namespace + "#" + name, traits, memberNames, memberList, ctor); + TypeRegistry.for(namespace).register(name, schema); + return schema; +} diff --git a/packages/core/src/submodules/schema/schemas/ListSchema.ts b/packages/core/src/submodules/schema/schemas/ListSchema.ts new file mode 100644 index 00000000000..631334f35c4 --- /dev/null +++ b/packages/core/src/submodules/schema/schemas/ListSchema.ts @@ -0,0 +1,24 @@ +import type { ListSchema as IListSchema, SchemaRef, SchemaTraits } from "@smithy/types"; + +import { TypeRegistry } from "../TypeRegistry"; +import { Schema } from "./Schema"; + +export class ListSchema extends Schema implements IListSchema { + public constructor( + public name: string, + public traits: SchemaTraits, + public valueSchema: SchemaRef + ) { + super(name, traits); + } +} + +export function list(namespace: string, name: string, traits: SchemaTraits = {}, valueSchema: SchemaRef): ListSchema { + const schema = new ListSchema( + namespace + "#" + name, + traits, + typeof valueSchema === "function" ? valueSchema() : valueSchema + ); + TypeRegistry.for(namespace).register(name, schema); + return schema; +} diff --git a/packages/core/src/submodules/schema/schemas/MapSchema.ts b/packages/core/src/submodules/schema/schemas/MapSchema.ts new file mode 100644 index 00000000000..fe1f3b860de --- /dev/null +++ b/packages/core/src/submodules/schema/schemas/MapSchema.ts @@ -0,0 +1,35 @@ +import type { MapSchema as IMapSchema, SchemaRef, SchemaTraits } from "@smithy/types"; + +import { TypeRegistry } from "../TypeRegistry"; +import { Schema } from "./Schema"; + +export class MapSchema extends Schema implements IMapSchema { + public constructor( + public name: string, + public traits: SchemaTraits, + /** + * This is expected to be StringSchema, but may have traits. + */ + public keySchema: SchemaRef, + public valueSchema: SchemaRef + ) { + super(name, traits); + } +} + +export function map( + namespace: string, + name: string, + traits: SchemaTraits = {}, + keySchema: SchemaRef, + valueSchema: SchemaRef +): MapSchema { + const schema = new MapSchema( + namespace + "#" + name, + traits, + keySchema, + typeof valueSchema === "function" ? valueSchema() : valueSchema + ); + TypeRegistry.for(namespace).register(name, schema); + return schema; +} diff --git a/packages/core/src/submodules/schema/schemas/NormalizedSchema.ts b/packages/core/src/submodules/schema/schemas/NormalizedSchema.ts new file mode 100644 index 00000000000..b12c2b37138 --- /dev/null +++ b/packages/core/src/submodules/schema/schemas/NormalizedSchema.ts @@ -0,0 +1,349 @@ +import type { + MemberSchema, + NormalizedSchema as INormalizedSchema, + Schema as ISchema, + SchemaRef, + SchemaTraits, + SchemaTraitsObject, +} from "@smithy/types"; + +import { deref } from "../deref"; +import { ListSchema } from "./ListSchema"; +import { MapSchema } from "./MapSchema"; +import { SCHEMA } from "./sentinels"; +import { SimpleSchema } from "./SimpleSchema"; +import { StructureSchema } from "./StructureSchema"; + +/** + * Wraps SchemaRef values for easier handling. + * @internal + */ +export class NormalizedSchema implements INormalizedSchema { + public readonly name: string; + public readonly traits: SchemaTraits; + + private _isMemberSchema: boolean; + private schema: Exclude; + private memberTraits: SchemaTraits; + private normalizedTraits?: SchemaTraitsObject; + + public constructor( + public readonly ref: SchemaRef, + private memberName?: string + ) { + const traitStack = [] as SchemaTraits[]; + let _ref = ref; + let schema = ref; + this._isMemberSchema = false; + + while (Array.isArray(_ref)) { + traitStack.push(_ref[1]); + _ref = _ref[0]; + schema = deref(_ref); + this._isMemberSchema = true; + } + + if (traitStack.length > 0) { + this.memberTraits = {}; + for (let i = traitStack.length - 1; i >= 0; --i) { + const traitSet = traitStack[i]; + Object.assign(this.memberTraits, NormalizedSchema.translateTraits(traitSet)); + } + } else { + this.memberTraits = 0; + } + + if (schema instanceof NormalizedSchema) { + this.name = schema.name; + this.traits = schema.traits; + this._isMemberSchema = schema._isMemberSchema; + this.schema = schema.schema; + this.memberTraits = Object.assign({}, schema.getMemberTraits(), this.getMemberTraits()); + this.normalizedTraits = void 0; + this.ref = schema.ref; + this.memberName = memberName ?? schema.memberName; + return; + } + + this.schema = deref(schema) as Exclude; + + if (this.schema && typeof this.schema === "object") { + this.traits = this.schema?.traits ?? {}; + } else { + this.traits = 0; + } + + this.name = + (typeof this.schema === "object" ? this.schema?.name : void 0) ?? this.memberName ?? this.getSchemaName(); + + if (this._isMemberSchema && !memberName) { + throw new Error( + `@smithy/core/schema - NormalizedSchema member schema ${this.getName(true)} must initialize with memberName argument.` + ); + } + } + + public static of(ref: SchemaRef, memberName?: string): NormalizedSchema { + if (ref instanceof NormalizedSchema) { + return ref; + } + return new NormalizedSchema(ref, memberName); + } + + /** + * @param indicator - numeric indicator for preset trait combination. + * @returns equivalent trait object. + */ + public static translateTraits(indicator: SchemaTraits): SchemaTraitsObject { + if (typeof indicator === "object") { + return indicator; + } + indicator = indicator | 0; + const traits = {} as SchemaTraitsObject; + if ((indicator & 1) === 1) { + traits.httpLabel = 1; + } + if (((indicator >> 1) & 1) === 1) { + traits.idempotent = 1; + } + if (((indicator >> 2) & 1) === 1) { + traits.idempotencyToken = 1; + } + if (((indicator >> 3) & 1) === 1) { + traits.sensitive = 1; + } + if (((indicator >> 4) & 1) === 1) { + traits.httpPayload = 1; + } + if (((indicator >> 5) & 1) === 1) { + traits.httpResponseCode = 1; + } + if (((indicator >> 6) & 1) === 1) { + traits.httpQueryParams = 1; + } + return traits; + } + + private static memberFrom(memberSchema: [SchemaRef, SchemaTraits], memberName: string): NormalizedSchema { + if (memberSchema instanceof NormalizedSchema) { + memberSchema.memberName = memberName; + memberSchema._isMemberSchema = true; + return memberSchema; + } + return new NormalizedSchema(memberSchema, memberName); + } + + public getSchema(): ISchema { + if (this.schema instanceof NormalizedSchema) { + return this.schema.getSchema(); + } + if (this.schema instanceof SimpleSchema) { + return deref(this.schema.schemaRef); + } + return deref(this.schema); + } + + public getName(withNamespace = false): string | undefined { + if (!withNamespace) { + if (this.name && this.name.includes("#")) { + return this.name.split("#")[1]; + } + } + // empty name should return as undefined + return this.name || undefined; + } + + public getMemberName(): string { + if (!this.isMemberSchema()) { + throw new Error(`@smithy/core/schema - cannot get member name on non-member schema: ${this.getName(true)}`); + } + return this.memberName!; + } + + public isMemberSchema(): boolean { + return this._isMemberSchema; + } + + public isUnitSchema(): boolean { + return this.getSchema() === ("unit" as const); + } + + public isListSchema(): boolean { + const inner = this.getSchema(); + if (typeof inner === "number") { + return inner >> 6 === SCHEMA.LIST_MODIFIER >> 6; + } + return inner instanceof ListSchema; + } + + public isMapSchema(): boolean { + const inner = this.getSchema(); + if (typeof inner === "number") { + return inner >> 6 === SCHEMA.MAP_MODIFIER >> 6; + } + return inner instanceof MapSchema; + } + + public isDocumentSchema(): boolean { + return this.getSchema() === SCHEMA.DOCUMENT; + } + + public isStructSchema(): boolean { + const inner = this.getSchema(); + return (inner !== null && typeof inner === "object" && "members" in inner) || inner instanceof StructureSchema; + } + + public isBlobSchema(): boolean { + return this.getSchema() === SCHEMA.BLOB || this.getSchema() === SCHEMA.STREAMING_BLOB; + } + + public isTimestampSchema(): boolean { + const schema = this.getSchema(); + return typeof schema === "number" && schema >= SCHEMA.TIMESTAMP_DEFAULT && schema <= SCHEMA.TIMESTAMP_EPOCH_SECONDS; + } + + public isStringSchema(): boolean { + return this.getSchema() === SCHEMA.STRING; + } + + public isBooleanSchema(): boolean { + return this.getSchema() === SCHEMA.BOOLEAN; + } + + public isNumericSchema(): boolean { + return this.getSchema() === SCHEMA.NUMERIC; + } + + public isBigIntegerSchema(): boolean { + return this.getSchema() === SCHEMA.BIG_INTEGER; + } + + public isBigDecimalSchema(): boolean { + return this.getSchema() === SCHEMA.BIG_DECIMAL; + } + + public isStreaming(): boolean { + const streaming = !!this.getMergedTraits().streaming; + if (streaming) { + return true; + } + return this.getSchema() === SCHEMA.STREAMING_BLOB; + } + + public getMergedTraits(): SchemaTraitsObject { + if (this.normalizedTraits) { + return this.normalizedTraits; + } + this.normalizedTraits = { + ...this.getOwnTraits(), + ...this.getMemberTraits(), + }; + return this.normalizedTraits; + } + + public getMemberTraits(): SchemaTraitsObject { + return NormalizedSchema.translateTraits(this.memberTraits); + } + + public getOwnTraits(): SchemaTraitsObject { + return NormalizedSchema.translateTraits(this.traits); + } + + public getKeySchema(): NormalizedSchema { + if (this.isDocumentSchema()) { + return NormalizedSchema.memberFrom([SCHEMA.DOCUMENT, 0], "key"); + } + if (!this.isMapSchema()) { + throw new Error(`@smithy/core/schema - cannot get key schema for non-map schema: ${this.getName(true)}`); + } + const schema = this.getSchema(); + if (typeof schema === "number") { + return NormalizedSchema.memberFrom([0b0011_1111 & schema, 0], "key"); + } + return NormalizedSchema.memberFrom([(schema as MapSchema).keySchema, 0], "key"); + } + + public getValueSchema(): NormalizedSchema { + const schema = this.getSchema(); + + if (typeof schema === "number") { + if (this.isMapSchema()) { + return NormalizedSchema.memberFrom([0b0011_1111 & schema, 0], "value"); + } else if (this.isListSchema()) { + return NormalizedSchema.memberFrom([0b0011_1111 & schema, 0], "member"); + } + } + + if (schema && typeof schema === "object") { + if (this.isStructSchema()) { + throw new Error(`cannot call getValueSchema() with StructureSchema ${this.getName(true)}`); + } + const collection = schema as MapSchema | ListSchema; + if ("valueSchema" in collection) { + if (this.isMapSchema()) { + return NormalizedSchema.memberFrom([collection.valueSchema, 0], "value"); + } else if (this.isListSchema()) { + return NormalizedSchema.memberFrom([collection.valueSchema, 0], "member"); + } + } + } + + if (this.isDocumentSchema()) { + return NormalizedSchema.memberFrom([SCHEMA.DOCUMENT, 0], "value"); + } + + throw new Error(`@smithy/core/schema - the schema ${this.getName(true)} does not have a value member.`); + } + + public getMemberSchema(member: string): NormalizedSchema | undefined { + if (this.isStructSchema()) { + const struct = this.getSchema() as StructureSchema; + if (!(member in struct.members)) { + // indicates the member is not recognized. + return undefined; + } + return NormalizedSchema.memberFrom(struct.members[member], member); + } + if (this.isDocumentSchema()) { + return NormalizedSchema.memberFrom([SCHEMA.DOCUMENT, 0], member); + } + throw new Error(`@smithy/core/schema - the schema ${this.getName(true)} does not have members.`); + } + + public getMemberSchemas(): Record { + const { schema } = this; + const struct = schema as StructureSchema; + if (!struct || typeof struct !== "object") { + return {}; + } + if ("members" in struct) { + const buffer = {} as Record; + for (const member of struct.memberNames) { + buffer[member] = this.getMemberSchema(member)!; + } + return buffer; + } + return {}; + } + + private getSchemaName(): string { + const schema = this.getSchema(); + if (typeof schema === "number") { + const _schema = 0b0011_1111 & schema; + const container = 0b1100_0000 & schema; + const type = + Object.entries(SCHEMA).find(([, value]) => { + return value === _schema; + })?.[0] ?? "Unknown"; + switch (container) { + case SCHEMA.MAP_MODIFIER: + return `${type}Map`; + case SCHEMA.LIST_MODIFIER: + return `${type}List`; + case 0: + return type; + } + } + return "Unknown"; + } +} diff --git a/packages/core/src/submodules/schema/schemas/OperationSchema.ts b/packages/core/src/submodules/schema/schemas/OperationSchema.ts new file mode 100644 index 00000000000..66693eb3989 --- /dev/null +++ b/packages/core/src/submodules/schema/schemas/OperationSchema.ts @@ -0,0 +1,27 @@ +import type { OperationSchema as IOperationSchema, SchemaRef, SchemaTraits } from "@smithy/types"; + +import { TypeRegistry } from "../TypeRegistry"; +import { Schema } from "./Schema"; + +export class OperationSchema extends Schema implements IOperationSchema { + public constructor( + public name: string, + public traits: SchemaTraits, + public input: SchemaRef, + public output: SchemaRef + ) { + super(name, traits); + } +} + +export function op( + namespace: string, + name: string, + traits: SchemaTraits = {}, + input: SchemaRef, + output: SchemaRef +): OperationSchema { + const schema = new OperationSchema(namespace + "#" + name, traits, input, output); + TypeRegistry.for(namespace).register(name, schema); + return schema; +} diff --git a/packages/core/src/submodules/schema/schemas/Schema.ts b/packages/core/src/submodules/schema/schemas/Schema.ts new file mode 100644 index 00000000000..80c27384368 --- /dev/null +++ b/packages/core/src/submodules/schema/schemas/Schema.ts @@ -0,0 +1,11 @@ +import type { SchemaTraits, TraitsSchema } from "@smithy/types"; + +/** + * @internal + */ +export abstract class Schema implements TraitsSchema { + protected constructor( + public name: string, + public traits: SchemaTraits + ) {} +} diff --git a/packages/core/src/submodules/schema/schemas/SimpleSchema.ts b/packages/core/src/submodules/schema/schemas/SimpleSchema.ts new file mode 100644 index 00000000000..8b218c40c0d --- /dev/null +++ b/packages/core/src/submodules/schema/schemas/SimpleSchema.ts @@ -0,0 +1,20 @@ +import { SchemaRef, SchemaTraits, TraitsSchema } from "@smithy/types"; + +import { TypeRegistry } from "../TypeRegistry"; +import { Schema } from "./Schema"; + +export class SimpleSchema extends Schema implements TraitsSchema { + public constructor( + public name: string, + public schemaRef: SchemaRef, + public traits: SchemaTraits + ) { + super(name, traits); + } +} + +export function sim(namespace: string, name: string, schemaRef: SchemaRef, traits: SchemaTraits) { + const schema = new SimpleSchema(namespace + "#" + name, schemaRef, traits); + TypeRegistry.for(namespace).register(name, schema); + return schema; +} diff --git a/packages/core/src/submodules/schema/schemas/StructureSchema.ts b/packages/core/src/submodules/schema/schemas/StructureSchema.ts new file mode 100644 index 00000000000..d773b51730d --- /dev/null +++ b/packages/core/src/submodules/schema/schemas/StructureSchema.ts @@ -0,0 +1,34 @@ +import type { MemberSchema, SchemaRef, SchemaTraits, StructureSchema as IStructureSchema } from "@smithy/types"; + +import { TypeRegistry } from "../TypeRegistry"; +import { Schema } from "./Schema"; + +export class StructureSchema extends Schema implements IStructureSchema { + public members: Record = {}; + + public constructor( + public name: string, + public traits: SchemaTraits, + public memberNames: string[], + public memberList: SchemaRef[] + ) { + super(name, traits); + for (let i = 0; i < memberNames.length; ++i) { + this.members[memberNames[i]] = Array.isArray(memberList[i]) + ? (memberList[i] as MemberSchema) + : [memberList[i], 0]; + } + } +} + +export function struct( + namespace: string, + name: string, + traits: SchemaTraits, + memberNames: string[], + memberList: SchemaRef[] +): StructureSchema { + const schema = new StructureSchema(namespace + "#" + name, traits, memberNames, memberList); + TypeRegistry.for(namespace).register(name, schema); + return schema; +} diff --git a/packages/core/src/submodules/schema/schemas/sentinels.ts b/packages/core/src/submodules/schema/schemas/sentinels.ts new file mode 100644 index 00000000000..8397ed0bce8 --- /dev/null +++ b/packages/core/src/submodules/schema/schemas/sentinels.ts @@ -0,0 +1,52 @@ +import { + BigDecimalSchema, + BigIntegerSchema, + BlobSchema, + BooleanSchema, + DocumentSchema, + ListSchemaModifier, + MapSchemaModifier, + NumericSchema, + StreamingBlobSchema, + StringSchema, + TimestampDateTimeSchema, + TimestampDefaultSchema, + TimestampEpochSecondsSchema, + TimestampHttpDateSchema, +} from "@smithy/types"; + +/** + * Schema sentinel runtime values. + * @internal + */ +export const SCHEMA: { + BLOB: BlobSchema; + STREAMING_BLOB: StreamingBlobSchema; + BOOLEAN: BooleanSchema; + STRING: StringSchema; + NUMERIC: NumericSchema; + BIG_INTEGER: BigIntegerSchema; + BIG_DECIMAL: BigDecimalSchema; + DOCUMENT: DocumentSchema; + TIMESTAMP_DEFAULT: TimestampDefaultSchema; + TIMESTAMP_DATE_TIME: TimestampDateTimeSchema; + TIMESTAMP_HTTP_DATE: TimestampHttpDateSchema; + TIMESTAMP_EPOCH_SECONDS: TimestampEpochSecondsSchema; + LIST_MODIFIER: ListSchemaModifier; + MAP_MODIFIER: MapSchemaModifier; +} = { + BLOB: 0b0001_0101, // 21 + STREAMING_BLOB: 0b0010_1010, // 42 + BOOLEAN: 0b0000_0010, // 2 + STRING: 0b0000_0000, // 0 + NUMERIC: 0b0000_0001, // 1 + BIG_INTEGER: 0b0001_0001, // 17 + BIG_DECIMAL: 0b0001_0011, // 19 + DOCUMENT: 0b0000_1111, // 15 + TIMESTAMP_DEFAULT: 0b0000_0100, // 4 + TIMESTAMP_DATE_TIME: 0b0000_0101, // 5 + TIMESTAMP_HTTP_DATE: 0b0000_0110, // 6 + TIMESTAMP_EPOCH_SECONDS: 0b0000_0111, // 7 + LIST_MODIFIER: 0b0100_0000, // 64 + MAP_MODIFIER: 0b1000_0000, // 128 +}; diff --git a/packages/core/src/submodules/serde/copyDocumentWithTransform.ts b/packages/core/src/submodules/serde/copyDocumentWithTransform.ts new file mode 100644 index 00000000000..d74a2a7291b --- /dev/null +++ b/packages/core/src/submodules/serde/copyDocumentWithTransform.ts @@ -0,0 +1,61 @@ +import { NormalizedSchema } from "@smithy/core/schema"; +import { SchemaRef } from "@smithy/types"; + +/** + * @internal + */ +export const copyDocumentWithTransform = ( + source: any, + schemaRef: SchemaRef, + transform: (_: any, schemaRef: SchemaRef) => any = (_) => _ +): any => { + const ns = NormalizedSchema.of(schemaRef); + switch (typeof source) { + case "undefined": + case "boolean": + case "number": + case "string": + case "bigint": + case "symbol": + return transform(source, ns); + case "function": + case "object": + if (source === null) { + return transform(null, ns); + } + if (Array.isArray(source)) { + const newArray = new Array(source.length); + let i = 0; + for (const item of source) { + newArray[i++] = copyDocumentWithTransform(item, ns.getValueSchema(), transform); + } + return transform(newArray, ns); + } + if ("byteLength" in (source as Uint8Array)) { + const newBytes = new Uint8Array(source.byteLength); + newBytes.set(source, 0); + return transform(newBytes, ns); + } + if (source instanceof Date) { + return transform(source, ns); + } + const newObject = {} as any; + if (ns.isMapSchema()) { + for (const key of Object.keys(source)) { + newObject[key] = copyDocumentWithTransform(source[key], ns.getValueSchema(), transform); + } + } else if (ns.isStructSchema()) { + for (const [key, memberSchema] of Object.entries(ns.getMemberSchemas())) { + newObject[key] = copyDocumentWithTransform(source[key], memberSchema, transform); + } + } else if (ns.isDocumentSchema()) { + for (const key of Object.keys(source)) { + newObject[key] = copyDocumentWithTransform(source[key], ns.getValueSchema(), transform); + } + } + + return transform(newObject, ns); + default: + return transform(source, ns); + } +}; diff --git a/packages/core/src/submodules/serde/date-utils.spec.ts b/packages/core/src/submodules/serde/date-utils.spec.ts new file mode 100644 index 00000000000..7a8e162e79d --- /dev/null +++ b/packages/core/src/submodules/serde/date-utils.spec.ts @@ -0,0 +1,316 @@ +import { describe, expect, test as it } from "vitest"; + +import { + parseEpochTimestamp, + parseRfc3339DateTime, + parseRfc3339DateTimeWithOffset, + parseRfc7231DateTime, +} from "./date-utils"; + +const invalidRfc3339DateTimes = [ + "85-04-12T23:20:50.52Z", + "985-04-12T23:20:50.52Z", + "1985-13-12T23:20:50.52Z", + "1985-00-12T23:20:50.52Z", + "1985-4-12T23:20:50.52Z", + "1985-04-32T23:20:50.52Z", + "1985-04-00T23:20:50.52Z", + "1985-04-05T24:20:50.52Z", + "1985-04-05T23:61:50.52Z", + "1985-04-05T23:20:61.52Z", + "1985-04-31T23:20:50.52Z", + "2005-02-29T15:59:59Z", + "1996-12-19T16:39:57", + "Mon, 31 Dec 1990 15:59:60 GMT", + "Monday, 31-Dec-90 15:59:60 GMT", + "Mon Dec 31 15:59:60 1990", + "1985-04-12T23:20:50.52Z1985-04-12T23:20:50.52Z", + "1985-04-12T23:20:50.52ZA", + "A1985-04-12T23:20:50.52Z", +]; + +describe("parseRfc3339DateTime", () => { + it.each([null, undefined])("returns undefined for %s", (value) => { + expect(parseRfc3339DateTime(value)).toBeUndefined(); + }); + + describe("parses properly formatted dates", () => { + it("with fractional seconds", () => { + expect(parseRfc3339DateTime("1985-04-12T23:20:50.52Z")).toEqual(new Date(Date.UTC(1985, 3, 12, 23, 20, 50, 520))); + }); + it("without fractional seconds", () => { + expect(parseRfc3339DateTime("1985-04-12T23:20:50Z")).toEqual(new Date(Date.UTC(1985, 3, 12, 23, 20, 50, 0))); + }); + it("with leap seconds", () => { + expect(parseRfc3339DateTime("1990-12-31T15:59:60Z")).toEqual(new Date(Date.UTC(1990, 11, 31, 15, 59, 60, 0))); + }); + it("with leap days", () => { + expect(parseRfc3339DateTime("2004-02-29T15:59:59Z")).toEqual(new Date(Date.UTC(2004, 1, 29, 15, 59, 59, 0))); + }); + it("with leading zeroes", () => { + expect(parseRfc3339DateTime("0004-02-09T05:09:09.09Z")).toEqual(new Date(Date.UTC(4, 1, 9, 5, 9, 9, 90))); + expect(parseRfc3339DateTime("0004-02-09T00:00:00.00Z")).toEqual(new Date(Date.UTC(4, 1, 9, 0, 0, 0, 0))); + }); + }); + + it.each(invalidRfc3339DateTimes)("rejects %s", (value) => { + expect(() => parseRfc3339DateTime(value)).toThrowError(); + }); + + // parseRfc3339DateTime throws on offsets. parseRfc3339DateTimeWithOffset can handle these. + it.each(["2019-12-16T22:48:18+02:04", "2019-12-16T22:48:18-01:02"])("rejects %s", (value) => { + expect(() => parseRfc3339DateTime(value)).toThrowError(); + }); +}); + +describe("parseRfc3339DateTimeWithOffset", () => { + it.each([null, undefined])("returns undefined for %s", (value) => { + expect(parseRfc3339DateTime(value)).toBeUndefined(); + }); + + describe("parses properly formatted dates", () => { + it("with fractional seconds", () => { + expect(parseRfc3339DateTimeWithOffset("1985-04-12T23:20:50.52Z")).toEqual( + new Date(Date.UTC(1985, 3, 12, 23, 20, 50, 520)) + ); + }); + it("without fractional seconds", () => { + expect(parseRfc3339DateTimeWithOffset("1985-04-12T23:20:50Z")).toEqual( + new Date(Date.UTC(1985, 3, 12, 23, 20, 50, 0)) + ); + }); + it("with leap seconds", () => { + expect(parseRfc3339DateTimeWithOffset("1990-12-31T15:59:60Z")).toEqual( + new Date(Date.UTC(1990, 11, 31, 15, 59, 60, 0)) + ); + }); + it("with leap days", () => { + expect(parseRfc3339DateTimeWithOffset("2004-02-29T15:59:59Z")).toEqual( + new Date(Date.UTC(2004, 1, 29, 15, 59, 59, 0)) + ); + }); + it("with leading zeroes", () => { + expect(parseRfc3339DateTimeWithOffset("0004-02-09T05:09:09.09Z")).toEqual( + new Date(Date.UTC(4, 1, 9, 5, 9, 9, 90)) + ); + expect(parseRfc3339DateTimeWithOffset("0004-02-09T00:00:00.00Z")).toEqual( + new Date(Date.UTC(4, 1, 9, 0, 0, 0, 0)) + ); + }); + it("with negative offset", () => { + expect(parseRfc3339DateTimeWithOffset("2019-12-16T22:48:18-01:02")).toEqual( + new Date(Date.UTC(2019, 11, 16, 23, 50, 18, 0)) + ); + }); + it("with positive offset", () => { + expect(parseRfc3339DateTimeWithOffset("2019-12-16T22:48:18+02:04")).toEqual( + new Date(Date.UTC(2019, 11, 16, 20, 44, 18, 0)) + ); + }); + }); + + it.each(invalidRfc3339DateTimes)("rejects %s", (value) => { + expect(() => parseRfc3339DateTimeWithOffset(value)).toThrowError(); + }); +}); + +describe("parseRfc7231DateTime", () => { + it.each([null, undefined])("returns undefined for %s", (value) => { + expect(parseRfc7231DateTime(value)).toBeUndefined(); + }); + + describe("parses properly formatted dates", () => { + describe("with fractional seconds", () => { + it.each([ + ["imf-fixdate", "Sun, 06 Nov 1994 08:49:37.52 GMT"], + ["rfc-850", "Sunday, 06-Nov-94 08:49:37.52 GMT"], + ["asctime", "Sun Nov 6 08:49:37.52 1994"], + ])("in format %s", (_, value) => { + expect(parseRfc7231DateTime(value)).toEqual(new Date(Date.UTC(1994, 10, 6, 8, 49, 37, 520))); + }); + }); + describe("with fractional seconds - single digit hour", () => { + it.each([ + ["imf-fixdate", "Sun, 06 Nov 1994 8:49:37.52 GMT"], + ["rfc-850", "Sunday, 06-Nov-94 8:49:37.52 GMT"], + ["asctime", "Sun Nov 6 8:49:37.52 1994"], + ])("in format %s", (_, value) => { + expect(parseRfc7231DateTime(value)).toEqual(new Date(Date.UTC(1994, 10, 6, 8, 49, 37, 520))); + }); + }); + describe("without fractional seconds", () => { + it.each([ + ["imf-fixdate", "Sun, 06 Nov 1994 08:49:37 GMT"], + ["rfc-850", "Sunday, 06-Nov-94 08:49:37 GMT"], + ["asctime", "Sun Nov 6 08:49:37 1994"], + ])("in format %s", (_, value) => { + expect(parseRfc7231DateTime(value)).toEqual(new Date(Date.UTC(1994, 10, 6, 8, 49, 37, 0))); + }); + }); + describe("without fractional seconds - single digit hour", () => { + it.each([ + ["imf-fixdate", "Sun, 06 Nov 1994 8:49:37 GMT"], + ["rfc-850", "Sunday, 06-Nov-94 8:49:37 GMT"], + ["asctime", "Sun Nov 6 8:49:37 1994"], + ])("in format %s", (_, value) => { + expect(parseRfc7231DateTime(value)).toEqual(new Date(Date.UTC(1994, 10, 6, 8, 49, 37, 0))); + }); + }); + describe("with leap seconds", () => { + it.each([ + ["imf-fixdate", "Mon, 31 Dec 1990 15:59:60 GMT"], + ["rfc-850", "Monday, 31-Dec-90 15:59:60 GMT"], + ["asctime", "Mon Dec 31 15:59:60 1990"], + ])("in format %s", (_, value) => { + expect(parseRfc7231DateTime(value)).toEqual(new Date(Date.UTC(1990, 11, 31, 15, 59, 60, 0))); + }); + }); + describe("with leap seconds - single digit hour", () => { + it.each([ + ["imf-fixdate", "Mon, 31 Dec 1990 8:59:60 GMT"], + ["rfc-850", "Monday, 31-Dec-90 8:59:60 GMT"], + ["asctime", "Mon Dec 31 8:59:60 1990"], + ])("in format %s", (_, value) => { + expect(parseRfc7231DateTime(value)).toEqual(new Date(Date.UTC(1990, 11, 31, 8, 59, 60, 0))); + }); + }); + describe("with leap days", () => { + it.each([ + ["imf-fixdate", "Sun, 29 Feb 2004 15:59:59 GMT"], + ["rfc-850", "Sunday, 29-Feb-04 15:59:59 GMT"], + ["asctime", "Sun Feb 29 15:59:59 2004"], + ])("in format %s", (_, value) => { + expect(parseRfc7231DateTime(value)).toEqual(new Date(Date.UTC(2004, 1, 29, 15, 59, 59, 0))); + }); + }); + describe("with leap days - single digit hour", () => { + it.each([ + ["imf-fixdate", "Sun, 29 Feb 2004 8:59:59 GMT"], + ["rfc-850", "Sunday, 29-Feb-04 8:59:59 GMT"], + ["asctime", "Sun Feb 29 8:59:59 2004"], + ])("in format %s", (_, value) => { + expect(parseRfc7231DateTime(value)).toEqual(new Date(Date.UTC(2004, 1, 29, 8, 59, 59, 0))); + }); + }); + describe("with leading zeroes", () => { + it.each([ + ["imf-fixdate", "Sun, 06 Nov 0004 08:09:07.02 GMT", 4], + ["rfc-850", "Sunday, 06-Nov-04 08:09:07.02 GMT", 2004], + ["asctime", "Sun Nov 6 08:09:07.02 0004", 4], + ])("in format %s", (_, value, year) => { + expect(parseRfc7231DateTime(value)).toEqual(new Date(Date.UTC(year, 10, 6, 8, 9, 7, 20))); + }); + }); + describe("with all-zero components", () => { + it.each([ + ["imf-fixdate", "Sun, 06 Nov 0004 00:00:00.00 GMT", 4], + ["rfc-850", "Sunday, 06-Nov-04 00:00:00.00 GMT", 2004], + ["asctime", "Sun Nov 6 00:00:00.00 0004", 4], + ])("in format %s", (_, value, year) => { + expect(parseRfc7231DateTime(value)).toEqual(new Date(Date.UTC(year, 10, 6, 0, 0, 0, 0))); + }); + }); + }); + + describe("when parsing rfc-850 dates", () => { + it("properly adjusts 2-digit years", () => { + // These tests will fail in a couple of decades. Good luck future developers. + expect(parseRfc7231DateTime("Friday, 31-Dec-99 12:34:56.789 GMT")).toEqual( + new Date(Date.UTC(1999, 11, 31, 12, 34, 56, 789)) + ); + expect(parseRfc7231DateTime("Thursday, 31-Dec-65 12:34:56.789 GMT")).toEqual( + new Date(Date.UTC(2065, 11, 31, 12, 34, 56, 789)) + ); + }); + }); + + it.each([ + "1985-04-12T23:20:50.52Z", + "1985-04-12T23:20:50Z", + + "Sun, 06 Nov 0004 08:09:07.02 GMTSun, 06 Nov 0004 08:09:07.02 GMT", + "Sun, 06 Nov 0004 08:09:07.02 GMTA", + "ASun, 06 Nov 0004 08:09:07.02 GMT", + "Sun, 06 Nov 94 08:49:37 GMT", + "Sun, 06 Dov 1994 08:49:37 GMT", + "Mun, 06 Nov 1994 08:49:37 GMT", + "Sunday, 06 Nov 1994 08:49:37 GMT", + "Sun, 06 November 1994 08:49:37 GMT", + "Sun, 06 Nov 1994 24:49:37 GMT", + "Sun, 06 Nov 1994 08:69:37 GMT", + "Sun, 06 Nov 1994 08:49:67 GMT", + "Sun, 06-11-1994 08:49:37 GMT", + "Sun, 06 11 1994 08:49:37 GMT", + "Sun, 31 Nov 1994 08:49:37 GMT", + "Sun, 29 Feb 2005 15:59:59 GMT", + + "Sunday, 06-Nov-04 08:09:07.02 GMTSunday, 06-Nov-04 08:09:07.02 GMT", + "ASunday, 06-Nov-04 08:09:07.02 GMT", + "Sunday, 06-Nov-04 08:09:07.02 GMTA", + "Sunday, 06-Nov-1994 08:49:37 GMT", + "Sunday, 06-Dov-94 08:49:37 GMT", + "Sundae, 06-Nov-94 08:49:37 GMT", + "Sun, 06-Nov-94 08:49:37 GMT", + "Sunday, 06-November-94 08:49:37 GMT", + "Sunday, 06-Nov-94 24:49:37 GMT", + "Sunday, 06-Nov-94 08:69:37 GMT", + "Sunday, 06-Nov-94 08:49:67 GMT", + "Sunday, 06 11 94 08:49:37 GMT", + "Sunday, 06-11-1994 08:49:37 GMT", + "Sunday, 31-Nov-94 08:49:37 GMT", + "Sunday, 29-Feb-05 15:59:59 GMT", + + "Sun Nov 6 08:09:07.02 0004Sun Nov 6 08:09:07.02 0004", + "ASun Nov 6 08:09:07.02 0004", + "Sun Nov 6 08:09:07.02 0004A", + "Sun Nov 6 08:49:37 94", + "Sun Dov 6 08:49:37 1994", + "Mun Nov 6 08:49:37 1994", + "Sunday Nov 6 08:49:37 1994", + "Sun November 6 08:49:37 1994", + "Sun Nov 6 24:49:37 1994", + "Sun Nov 6 08:69:37 1994", + "Sun Nov 6 08:49:67 1994", + "Sun 06-11 08:49:37 1994", + "Sun 06 11 08:49:37 1994", + "Sun 11 6 08:49:37 1994", + "Sun Nov 31 08:49:37 1994", + "Sun Feb 29 15:59:59 2005", + "Sun Nov 6 08:49:37 1994", + ])("rejects %s", (value) => { + expect(() => parseRfc7231DateTime(value)).toThrowError(); + }); +}); + +describe("parseEpochTimestamp", () => { + it.each([null, undefined])("returns undefined for %s", (value) => { + expect(parseEpochTimestamp(value)).toBeUndefined(); + }); + + describe("parses properly formatted dates", () => { + describe("with fractional seconds", () => { + it.each(["482196050.52", 482196050.52])("parses %s", (value) => { + expect(parseEpochTimestamp(value)).toEqual(new Date(Date.UTC(1985, 3, 12, 23, 20, 50, 520))); + }); + }); + describe("without fractional seconds", () => { + it.each(["482196050", 482196050, 482196050.0])("parses %s", (value) => { + expect(parseEpochTimestamp(value)).toEqual(new Date(Date.UTC(1985, 3, 12, 23, 20, 50, 0))); + }); + }); + }); + it.each([ + "1985-04-12T23:20:50.52Z", + "1985-04-12T23:20:50Z", + "Mon, 31 Dec 1990 15:59:60 GMT", + "Monday, 31-Dec-90 15:59:60 GMT", + "Mon Dec 31 15:59:60 1990", + "NaN", + NaN, + "Infinity", + Infinity, + "0x42", + ])("rejects %s", (value) => { + expect(() => parseEpochTimestamp(value)).toThrowError(); + }); +}); diff --git a/packages/core/src/submodules/serde/date-utils.ts b/packages/core/src/submodules/serde/date-utils.ts new file mode 100644 index 00000000000..34e2afea950 --- /dev/null +++ b/packages/core/src/submodules/serde/date-utils.ts @@ -0,0 +1,396 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { strictParseByte, strictParseDouble, strictParseFloat32, strictParseShort } from "./parse-utils"; + +// Build indexes outside so we allocate them once. +const DAYS: Array = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + +// These must be kept in order +// prettier-ignore +const MONTHS: Array = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +/** + * @internal + * + * Builds a proper UTC HttpDate timestamp from a Date object + * since not all environments will have this as the expected + * format. + * + * @see {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toUTCString} + * - Prior to ECMAScript 2018, the format of the return value + * - varied according to the platform. The most common return + * - value was an RFC-1123 formatted date stamp, which is a + * - slightly updated version of RFC-822 date stamps. + */ +export function dateToUtcString(date: Date): string { + const year = date.getUTCFullYear(); + const month = date.getUTCMonth(); + const dayOfWeek = date.getUTCDay(); + const dayOfMonthInt = date.getUTCDate(); + const hoursInt = date.getUTCHours(); + const minutesInt = date.getUTCMinutes(); + const secondsInt = date.getUTCSeconds(); + + // Build 0 prefixed strings for contents that need to be + // two digits and where we get an integer back. + const dayOfMonthString = dayOfMonthInt < 10 ? `0${dayOfMonthInt}` : `${dayOfMonthInt}`; + const hoursString = hoursInt < 10 ? `0${hoursInt}` : `${hoursInt}`; + const minutesString = minutesInt < 10 ? `0${minutesInt}` : `${minutesInt}`; + const secondsString = secondsInt < 10 ? `0${secondsInt}` : `${secondsInt}`; + + return `${DAYS[dayOfWeek]}, ${dayOfMonthString} ${MONTHS[month]} ${year} ${hoursString}:${minutesString}:${secondsString} GMT`; +} + +const RFC3339 = new RegExp(/^(\d{4})-(\d{2})-(\d{2})[tT](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?[zZ]$/); + +/** + * @internal + * + * Parses a value into a Date. Returns undefined if the input is null or + * undefined, throws an error if the input is not a string that can be parsed + * as an RFC 3339 date. + * + * Input strings must conform to RFC3339 section 5.6, and cannot have a UTC + * offset. Fractional precision is supported. + * + * @see {@link https://xml2rfc.tools.ietf.org/public/rfc/html/rfc3339.html#anchor14} + * + * @param value - the value to parse + * @returns a Date or undefined + */ +export const parseRfc3339DateTime = (value: unknown): Date | undefined => { + if (value === null || value === undefined) { + return undefined; + } + if (typeof value !== "string") { + throw new TypeError("RFC-3339 date-times must be expressed as strings"); + } + const match = RFC3339.exec(value); + if (!match) { + throw new TypeError("Invalid RFC-3339 date-time value"); + } + + const [_, yearStr, monthStr, dayStr, hours, minutes, seconds, fractionalMilliseconds] = match; + + const year = strictParseShort(stripLeadingZeroes(yearStr))!; + const month = parseDateValue(monthStr, "month", 1, 12); + const day = parseDateValue(dayStr, "day", 1, 31); + + return buildDate(year, month, day, { hours, minutes, seconds, fractionalMilliseconds }); +}; + +const RFC3339_WITH_OFFSET = new RegExp( + /^(\d{4})-(\d{2})-(\d{2})[tT](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(([-+]\d{2}\:\d{2})|[zZ])$/ +); + +/** + * @internal + * + * Parses a value into a Date. Returns undefined if the input is null or + * undefined, throws an error if the input is not a string that can be parsed + * as an RFC 3339 date. + * + * Input strings must conform to RFC3339 section 5.6, and can have a UTC + * offset. Fractional precision is supported. + * + * @see {@link https://xml2rfc.tools.ietf.org/public/rfc/html/rfc3339.html#anchor14} + * + * @param value - the value to parse + * @returns a Date or undefined + */ +export const parseRfc3339DateTimeWithOffset = (value: unknown): Date | undefined => { + if (value === null || value === undefined) { + return undefined; + } + if (typeof value !== "string") { + throw new TypeError("RFC-3339 date-times must be expressed as strings"); + } + const match = RFC3339_WITH_OFFSET.exec(value); + if (!match) { + throw new TypeError("Invalid RFC-3339 date-time value"); + } + + const [_, yearStr, monthStr, dayStr, hours, minutes, seconds, fractionalMilliseconds, offsetStr] = match; + + const year = strictParseShort(stripLeadingZeroes(yearStr))!; + const month = parseDateValue(monthStr, "month", 1, 12); + const day = parseDateValue(dayStr, "day", 1, 31); + const date = buildDate(year, month, day, { hours, minutes, seconds, fractionalMilliseconds }); + + // The final regex capture group is either an offset, or "z". If it is not a "z", + // attempt to parse the offset and adjust the date. + if (offsetStr.toUpperCase() != "Z") { + date.setTime(date.getTime() - parseOffsetToMilliseconds(offsetStr)); + } + return date; +}; + +const IMF_FIXDATE = new RegExp( + /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d{1,2}):(\d{2}):(\d{2})(?:\.(\d+))? GMT$/ +); +const RFC_850_DATE = new RegExp( + /^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\d{2})-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(\d{2}) (\d{1,2}):(\d{2}):(\d{2})(?:\.(\d+))? GMT$/ +); +const ASC_TIME = new RegExp( + /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ( [1-9]|\d{2}) (\d{1,2}):(\d{2}):(\d{2})(?:\.(\d+))? (\d{4})$/ +); + +/** + * @internal + * + * Parses a value into a Date. Returns undefined if the input is null or + * undefined, throws an error if the input is not a string that can be parsed + * as an RFC 7231 IMF-fixdate or obs-date. + * + * Input strings must conform to RFC7231 section 7.1.1.1. Fractional seconds are supported. + * + * @see {@link https://datatracker.ietf.org/doc/html/rfc7231.html#section-7.1.1.1} + * + * @param value - the value to parse + * @returns a Date or undefined + */ +export const parseRfc7231DateTime = (value: unknown): Date | undefined => { + if (value === null || value === undefined) { + return undefined; + } + if (typeof value !== "string") { + throw new TypeError("RFC-7231 date-times must be expressed as strings"); + } + + let match = IMF_FIXDATE.exec(value); + if (match) { + const [_, dayStr, monthStr, yearStr, hours, minutes, seconds, fractionalMilliseconds] = match; + return buildDate( + strictParseShort(stripLeadingZeroes(yearStr))!, + parseMonthByShortName(monthStr), + parseDateValue(dayStr, "day", 1, 31), + { hours, minutes, seconds, fractionalMilliseconds } + ); + } + + match = RFC_850_DATE.exec(value); + if (match) { + const [_, dayStr, monthStr, yearStr, hours, minutes, seconds, fractionalMilliseconds] = match; + // RFC 850 dates use 2-digit years. So we parse the year specifically, + // and then once we've constructed the entire date, we adjust it if the resultant date + // is too far in the future. + return adjustRfc850Year( + buildDate(parseTwoDigitYear(yearStr), parseMonthByShortName(monthStr), parseDateValue(dayStr, "day", 1, 31), { + hours, + minutes, + seconds, + fractionalMilliseconds, + }) + ); + } + + match = ASC_TIME.exec(value); + if (match) { + const [_, monthStr, dayStr, hours, minutes, seconds, fractionalMilliseconds, yearStr] = match; + return buildDate( + strictParseShort(stripLeadingZeroes(yearStr))!, + parseMonthByShortName(monthStr), + parseDateValue(dayStr.trimLeft(), "day", 1, 31), + { hours, minutes, seconds, fractionalMilliseconds } + ); + } + + throw new TypeError("Invalid RFC-7231 date-time value"); +}; + +/** + * @internal + * + * Parses a value into a Date. Returns undefined if the input is null or + * undefined, throws an error if the input is not a number or a parseable string. + * + * Input strings must be an integer or floating point number. Fractional seconds are supported. + * + * @param value - the value to parse + * @returns a Date or undefined + */ +export const parseEpochTimestamp = (value: unknown): Date | undefined => { + if (value === null || value === undefined) { + return undefined; + } + + let valueAsDouble: number; + if (typeof value === "number") { + valueAsDouble = value; + } else if (typeof value === "string") { + valueAsDouble = strictParseDouble(value)!; + } else if (typeof value === "object" && (value as { tag: number; value: number }).tag === 1) { + // timestamp is a CBOR tag type. + valueAsDouble = (value as { tag: number; value: number }).value; + } else { + throw new TypeError("Epoch timestamps must be expressed as floating point numbers or their string representation"); + } + + if (Number.isNaN(valueAsDouble) || valueAsDouble === Infinity || valueAsDouble === -Infinity) { + throw new TypeError("Epoch timestamps must be valid, non-Infinite, non-NaN numerics"); + } + return new Date(Math.round(valueAsDouble * 1000)); +}; + +interface RawTime { + hours: string; + minutes: string; + seconds: string; + fractionalMilliseconds: string | undefined; +} + +/** + * Build a date from a numeric year, month, date, and an match with named groups + * "H", "m", s", and "frac", representing hours, minutes, seconds, and optional fractional seconds. + * @param year - numeric year + * @param month - numeric month, 1-indexed + * @param day - numeric year + * @param match - match with groups "H", "m", s", and "frac" + */ +const buildDate = (year: number, month: number, day: number, time: RawTime): Date => { + const adjustedMonth = month - 1; // JavaScript, and our internal data structures, expect 0-indexed months + validateDayOfMonth(year, adjustedMonth, day); + // Adjust month down by 1 + return new Date( + Date.UTC( + year, + adjustedMonth, + day, + parseDateValue(time.hours, "hour", 0, 23), + parseDateValue(time.minutes, "minute", 0, 59), + // seconds can go up to 60 for leap seconds + parseDateValue(time.seconds, "seconds", 0, 60), + parseMilliseconds(time.fractionalMilliseconds) + ) + ); +}; + +/** + * RFC 850 dates use a 2-digit year; start with the assumption that if it doesn't + * match the current year, then it's a date in the future, then let adjustRfc850Year adjust + * the final date back to the past if it's too far in the future. + * + * Example: in 2021, start with the assumption that '11' is '2111', and that '22' is '2022'. + * adjustRfc850Year will adjust '11' to 2011, (as 2111 is more than 50 years in the future), + * but keep '22' as 2022. in 2099, '11' will represent '2111', but '98' should be '2098'. + * There's no description of an RFC 850 date being considered too far in the past in RFC-7231, + * so it's entirely possible that 2011 is a valid interpretation of '11' in 2099. + * @param value - the 2 digit year to parse + * @returns number a year that is equal to or greater than the current UTC year + */ +const parseTwoDigitYear = (value: string): number => { + const thisYear = new Date().getUTCFullYear(); + const valueInThisCentury = Math.floor(thisYear / 100) * 100 + strictParseShort(stripLeadingZeroes(value))!; + if (valueInThisCentury < thisYear) { + // This may end up returning a year that adjustRfc850Year turns back by 100. + // That's fine! We don't know the other components of the date yet, so there are + // boundary conditions that only adjustRfc850Year can handle. + return valueInThisCentury + 100; + } + return valueInThisCentury; +}; + +const FIFTY_YEARS_IN_MILLIS = 50 * 365 * 24 * 60 * 60 * 1000; + +/** + * Adjusts the year value found in RFC 850 dates according to the rules + * expressed in RFC7231, which state: + * + *
Recipients of a timestamp value in rfc850-date format, which uses a + * two-digit year, MUST interpret a timestamp that appears to be more + * than 50 years in the future as representing the most recent year in + * the past that had the same last two digits.
+ * + * @param input - a Date that assumes the two-digit year was in the future + * @returns a Date that is in the past if input is \> 50 years in the future + */ +const adjustRfc850Year = (input: Date): Date => { + if (input.getTime() - new Date().getTime() > FIFTY_YEARS_IN_MILLIS) { + return new Date( + Date.UTC( + input.getUTCFullYear() - 100, + input.getUTCMonth(), + input.getUTCDate(), + input.getUTCHours(), + input.getUTCMinutes(), + input.getUTCSeconds(), + input.getUTCMilliseconds() + ) + ); + } + return input; +}; + +const parseMonthByShortName = (value: string): number => { + const monthIdx = MONTHS.indexOf(value); + if (monthIdx < 0) { + throw new TypeError(`Invalid month: ${value}`); + } + return monthIdx + 1; +}; + +const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + +/** + * Validate the day is valid for the given month. + * @param year - the year + * @param month - the month (0-indexed) + * @param day - the day of the month + */ +const validateDayOfMonth = (year: number, month: number, day: number) => { + let maxDays = DAYS_IN_MONTH[month]; + if (month === 1 && isLeapYear(year)) { + maxDays = 29; + } + + if (day > maxDays) { + throw new TypeError(`Invalid day for ${MONTHS[month]} in ${year}: ${day}`); + } +}; + +const isLeapYear = (year: number): boolean => { + return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); +}; + +const parseDateValue = (value: string, type: string, lower: number, upper: number): number => { + const dateVal = strictParseByte(stripLeadingZeroes(value))!; + if (dateVal < lower || dateVal > upper) { + throw new TypeError(`${type} must be between ${lower} and ${upper}, inclusive`); + } + return dateVal; +}; + +const parseMilliseconds = (value: string | undefined): number => { + if (value === null || value === undefined) { + return 0; + } + + return strictParseFloat32("0." + value)! * 1000; +}; + +// Parses offset string and returns offset in milliseconds. +const parseOffsetToMilliseconds = (value: string): number => { + const directionStr = value[0]; + let direction = 1; + if (directionStr == "+") { + direction = 1; + } else if (directionStr == "-") { + direction = -1; + } else { + throw new TypeError(`Offset direction, ${directionStr}, must be "+" or "-"`); + } + + const hour = Number(value.substring(1, 3)); + const minute = Number(value.substring(4, 6)); + return direction * (hour * 60 + minute) * 60 * 1000; +}; + +const stripLeadingZeroes = (value: string): string => { + let idx = 0; + while (idx < value.length - 1 && value.charAt(idx) === "0") { + idx++; + } + if (idx === 0) { + return value; + } + return value.slice(idx); +}; diff --git a/packages/core/src/submodules/serde/index.ts b/packages/core/src/submodules/serde/index.ts index a70d0dda874..b6cc044fa53 100644 --- a/packages/core/src/submodules/serde/index.ts +++ b/packages/core/src/submodules/serde/index.ts @@ -1 +1,8 @@ +export * from "./copyDocumentWithTransform"; +export * from "./parse-utils"; +export * from "./date-utils"; +export * from "./quote-header"; +export * from "./split-header"; export * from "./value/NumericValue"; +export * from "./lazy-json"; +export * from "./split-every"; diff --git a/packages/core/src/submodules/serde/lazy-json.spec.ts b/packages/core/src/submodules/serde/lazy-json.spec.ts new file mode 100644 index 00000000000..1a7b747250c --- /dev/null +++ b/packages/core/src/submodules/serde/lazy-json.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, test as it } from "vitest"; + +import { LazyJsonString } from "./lazy-json"; + +describe("LazyJsonString", () => { + it("should have string methods", () => { + const jsonValue = new LazyJsonString('"foo"'); + expect(jsonValue.length).toBe(5); + expect(jsonValue.toString()).toBe('"foo"'); + }); + + it("should deserialize json properly", () => { + const jsonValue = new LazyJsonString('"foo"'); + expect(jsonValue.deserializeJSON()).toBe("foo"); + const wrongJsonValue = new LazyJsonString("foo"); + expect(() => wrongJsonValue.deserializeJSON()).toThrow(); + }); + + it("should get JSON string properly", () => { + const jsonValue = new LazyJsonString('{"foo", "bar"}'); + expect(jsonValue.toJSON()).toBe('{"foo", "bar"}'); + }); + + it("can instantiate from LazyJsonString class", () => { + const original = new LazyJsonString('"foo"'); + const newOne = LazyJsonString.from(original); + expect(newOne.toString()).toBe('"foo"'); + }); + + it("can instantiate from String class", () => { + const jsonValue = LazyJsonString.from(new String('"foo"')); + expect(jsonValue.toString()).toBe('"foo"'); + }); + + it("can instantiate from object", () => { + const jsonValue = LazyJsonString.from({ foo: "bar" }); + expect(jsonValue.toString()).toBe('{"foo":"bar"}'); + }); + + it("passes instanceof String check", () => { + const jsonValue = LazyJsonString.from({ foo: "bar" }); + expect(jsonValue).toBeInstanceOf(String); + }); +}); diff --git a/packages/core/src/submodules/serde/lazy-json.ts b/packages/core/src/submodules/serde/lazy-json.ts new file mode 100644 index 00000000000..105b4c119ad --- /dev/null +++ b/packages/core/src/submodules/serde/lazy-json.ts @@ -0,0 +1,79 @@ +/** + * @public + * + * A model field with this type means that you may provide a JavaScript + * object in lieu of a JSON string, and it will be serialized to JSON + * automatically before being sent in a request. + * + * For responses, you will receive a "LazyJsonString", which is a boxed String object + * with additional mixin methods. + * To get the string value, call `.toString()`, or to get the JSON object value, + * call `.deserializeJSON()` or parse it yourself. + */ +export type AutomaticJsonStringConversion = Parameters[0] | LazyJsonString; + +/** + * @internal + * + */ +export interface LazyJsonString extends String { + /** + * @returns the JSON parsing of the string value. + */ + deserializeJSON(): any; + + /** + * @returns the original string value rather than a JSON.stringified value. + */ + toJSON(): string; +} + +/** + * @internal + * + * Extension of the native String class in the previous implementation + * has negative global performance impact on method dispatch for strings, + * and is generally discouraged. + * + * This current implementation may look strange, but is necessary to preserve the interface and + * behavior of extending the String class. + */ +export const LazyJsonString = function LazyJsonString(val: string): void { + const str = Object.assign(new String(val), { + deserializeJSON() { + return JSON.parse(String(val)); + }, + + toString() { + return String(val); + }, + + toJSON() { + return String(val); + }, + }); + + return str as never; +} as any as { + new (s: string): LazyJsonString; + (s: string): LazyJsonString; + from(s: any): LazyJsonString; + /** + * @deprecated use #from. + */ + fromObject(s: any): LazyJsonString; +}; + +LazyJsonString.from = (object: any): LazyJsonString => { + if (object && typeof object === "object" && (object instanceof LazyJsonString || "deserializeJSON" in object)) { + return object as any; + } else if (typeof object === "string" || Object.getPrototypeOf(object) === String.prototype) { + return LazyJsonString(String(object) as string) as any; + } + return LazyJsonString(JSON.stringify(object)) as any; +}; + +/** + * @deprecated use #from. + */ +LazyJsonString.fromObject = LazyJsonString.from; diff --git a/packages/core/src/submodules/serde/parse-utils.spec.ts b/packages/core/src/submodules/serde/parse-utils.spec.ts new file mode 100644 index 00000000000..455af97344c --- /dev/null +++ b/packages/core/src/submodules/serde/parse-utils.spec.ts @@ -0,0 +1,792 @@ +import { afterEach, beforeEach, describe, expect, test as it, vi } from "vitest"; + +import { + expectByte, + expectFloat32, + expectInt32, + expectLong, + expectNonNull, + expectObject, + expectShort, + expectUnion, + limitedParseDouble, + limitedParseFloat32, + logger, + parseBoolean, + strictParseByte, + strictParseDouble, + strictParseFloat32, + strictParseInt32, + strictParseLong, + strictParseShort, +} from "./parse-utils"; +import { expectBoolean, expectNumber, expectString } from "./parse-utils"; + +describe("parseBoolean", () => { + it('Returns true for "true"', () => { + expect(parseBoolean("true")).toEqual(true); + }); + + it('Returns false for "false"', () => { + expect(parseBoolean("false")).toEqual(false); + }); + + describe("Throws an error on invalid input", () => { + it.each([ + // These are valid booleans in YAML + "y", + "Y", + "yes", + "Yes", + "YES", + "n", + "N", + "no", + "No", + "NO", + "True", + "TRUE", + "False", + "FALSE", + "on", + "On", + "ON", + "off", + "Off", + "OFF", + // These would be resolve to false using Boolean + 0, + null, + "", + false, + // These would resolve to true using Boolean + true, + "Su Lin", + [], + {}, + ])("rejects %s", (value) => { + expect(() => parseBoolean(value as any)).toThrowError(); + }); + }); +}); + +describe("expectBoolean", () => { + it.each([true, false])("accepts %s", (value) => { + expect(expectBoolean(value)).toEqual(value); + }); + + it.each([null, undefined])("accepts %s", (value) => { + expect(expectBoolean(value)).toEqual(undefined); + }); + + describe("reluctantly", () => { + let consoleMock: any; + beforeEach(() => { + consoleMock = vi.spyOn(logger, "warn"); + }); + + afterEach(() => { + consoleMock.mockRestore(); + }); + + it.each([1, "true", "True"])("accepts %s", (value) => { + expect(expectBoolean(value)).toEqual(true); + expect(logger.warn).toHaveBeenCalled(); + }); + + it.each([0, "false", "False"])("accepts %s", (value) => { + expect(expectBoolean(value)).toEqual(false); + expect(logger.warn).toHaveBeenCalled(); + }); + }); + + describe("rejects non-booleans", () => { + it.each([1.1, Infinity, -Infinity, NaN, {}, []])("rejects %s", (value) => { + expect(() => expectBoolean(value)).toThrowError(); + }); + }); +}); + +describe("expectNumber", () => { + describe("accepts numbers", () => { + it.each([1, 1.1, Infinity, -Infinity])("accepts %s", (value) => { + expect(expectNumber(value)).toEqual(value); + }); + }); + + it.each([null, undefined])("accepts %s", (value) => { + expect(expectNumber(value)).toEqual(undefined); + }); + + describe("reluctantly", () => { + let consoleMock: any; + beforeEach(() => { + consoleMock = vi.spyOn(logger, "warn"); + }); + + afterEach(() => { + consoleMock.mockRestore(); + }); + + it.each(["-0", "-1.15", "-1e-5", "1", "1.1", "Infinity", "-Infinity"])("accepts string: %s", (value) => { + expect(expectNumber(value)).toEqual(parseFloat(value)); + }); + + it.each(["-0abcd", "-1.15abcd", "-1e-5abcd", "1abcd", "1.1abcd", "Infinityabcd", "-Infinityabcd"])( + "accepts string: %s", + (value) => { + expect(expectNumber(value)).toEqual(parseFloat(value)); + expect(logger.warn).toHaveBeenCalled(); + } + ); + }); + + describe("rejects non-numbers", () => { + it.each(["NaN", true, false, [], {}])("rejects %s", (value) => { + expect(() => expectNumber(value)).toThrowError(); + }); + }); +}); + +describe("expectFloat32", () => { + describe("accepts numbers", () => { + it.each([ + 1, + 1.1, + Infinity, + -Infinity, + // Smallest positive subnormal number + 2 ** -149, + // Largest subnormal number + 2 ** -126 * (1 - 2 ** -23), + // Smallest positive normal number + 2 ** -126, + // Largest normal number + 2 ** 127 * (2 - 2 ** -23), + // Largest number less than one + 1 - 2 ** -24, + // Smallest number larger than one + 1 + 2 ** -23, + ])("accepts %s", (value) => { + expect(expectNumber(value)).toEqual(value); + }); + }); + + it.each([null, undefined])("accepts %s", (value) => { + expect(expectNumber(value)).toEqual(undefined); + }); + + describe("rejects non-numbers", () => { + it.each([true, false, [], {}])("rejects %s", (value) => { + expect(() => expectNumber(value)).toThrowError(); + }); + }); + + describe("rejects doubles", () => { + it.each([2 ** 128, -(2 ** 128)])("rejects %s", (value) => { + expect(() => expectFloat32(value)).toThrowError(); + }); + }); +}); + +describe("expectLong", () => { + describe("accepts 64-bit integers", () => { + it.each([ + 1, + Number.MAX_SAFE_INTEGER, + Number.MIN_SAFE_INTEGER, + 2 ** 31 - 1, + -(2 ** 31), + 2 ** 15 - 1, + -(2 ** 15), + 127, + -128, + ])("accepts %s", (value) => { + expect(expectLong(value)).toEqual(value); + }); + }); + + it.each([null, undefined])("accepts %s", (value) => { + expect(expectLong(value)).toEqual(undefined); + }); + + describe("rejects non-integers", () => { + it.each([1.1, "1", "1.1", NaN, true, [], {}])("rejects %s", (value) => { + expect(() => expectLong(value)).toThrowError(); + }); + }); +}); + +describe("expectInt32", () => { + describe("accepts 32-bit integers", () => { + it.each([1, 2 ** 31 - 1, -(2 ** 31), 2 ** 15 - 1, -(2 ** 15), 127, -128])("accepts %s", (value) => { + expect(expectInt32(value)).toEqual(value); + }); + }); + + it.each([null, undefined])("accepts %s", (value) => { + expect(expectInt32(value)).toEqual(undefined); + }); + + describe("rejects non-integers", () => { + it.each([ + 1.1, + "1", + "1.1", + NaN, + true, + [], + {}, + Number.MAX_SAFE_INTEGER, + Number.MIN_SAFE_INTEGER, + 2 ** 31, + -(2 ** 31 + 1), + ])("rejects %s", (value) => { + expect(() => expectInt32(value)).toThrowError(); + }); + }); +}); + +describe("expectShort", () => { + describe("accepts 16-bit integers", () => { + it.each([1, 2 ** 15 - 1, -(2 ** 15), 127, -128])("accepts %s", (value) => { + expect(expectShort(value)).toEqual(value); + }); + }); + + it.each([null, undefined])("accepts %s", (value) => { + expect(expectShort(value)).toEqual(undefined); + }); + + describe("rejects non-integers", () => { + it.each([ + 1.1, + "1", + "1.1", + NaN, + true, + [], + {}, + 2 ** 63 - 1, + -(2 ** 63 + 1), + 2 ** 31 - 1, + -(2 ** 31 + 1), + 2 ** 15, + -(2 ** 15 + 1), + ])("rejects %s", (value) => { + expect(() => expectShort(value)).toThrowError(); + }); + }); +}); + +describe("expectByte", () => { + describe("accepts 8-bit integers", () => { + it.each([1, 127, -128])("accepts %s", (value) => { + expect(expectByte(value)).toEqual(value); + }); + }); + + it.each([null, undefined])("accepts %s", (value) => { + expect(expectByte(value)).toEqual(undefined); + }); + + describe("rejects non-integers", () => { + it.each([ + 1.1, + "1", + "1.1", + NaN, + true, + [], + {}, + Number.MAX_SAFE_INTEGER, + Number.MIN_SAFE_INTEGER, + 2 ** 31 - 1, + -(2 ** 31 + 1), + 2 ** 15 - 1, + -(2 ** 15 + 1), + 128, + -129, + ])("rejects %s", (value) => { + expect(() => expectByte(value)).toThrowError(); + }); + }); +}); + +describe("expectNonNull", () => { + it.each([1, 1.1, "1", NaN, true, [], ["a", 123], { a: 123 }, [{ a: 123 }], "{ a : 123 }", '{"a":123}'])( + "accepts %s", + (value) => { + expect(expectNonNull(value)).toEqual(value); + } + ); + + it.each([null, undefined])("rejects %s", (value) => { + expect(() => expectNonNull(value)).toThrowError(); + }); +}); + +describe("expectObject", () => { + it("accepts objects", () => { + expect(expectObject({ a: 123 })).toEqual({ a: 123 }); + }); + + it.each([null, undefined])("accepts %s", (value) => { + expect(expectObject(value)).toEqual(undefined); + }); + + describe("rejects non-objects", () => { + it.each([1, 1.1, "1", NaN, true, [], ["a", 123], [{ a: 123 }], "{ a : 123 }", '{"a":123}'])( + "rejects %s", + (value) => { + expect(() => expectObject(value)).toThrowError(); + } + ); + }); +}); + +describe("expectString", () => { + it("accepts strings", () => { + expect(expectString("foo")).toEqual("foo"); + }); + + it.each([null, undefined])("accepts %s", (value) => { + expect(expectString(value)).toEqual(undefined); + }); + + describe("reluctantly", () => { + let consoleMock: any; + beforeEach(() => { + consoleMock = vi.spyOn(logger, "warn"); + }); + + afterEach(() => { + consoleMock.mockRestore(); + }); + + it.each([1, NaN, Infinity, -Infinity, true, false])("accepts numbers or booleans: %s", (value) => { + expect(expectString(value)).toEqual(String(value)); + expect(logger.warn).toHaveBeenCalled(); + }); + }); + + describe("rejects non-strings", () => { + it.each([[], {}])("rejects %s", (value) => { + expect(() => expectString(value)).toThrowError(); + }); + }); +}); + +describe("expectUnion", () => { + it.each([null, undefined])("accepts %s", (value) => { + expect(expectUnion(value)).toEqual(undefined); + }); + describe("rejects non-objects", () => { + it.each([1, NaN, Infinity, -Infinity, true, false, [], "abc"])("%s", (value) => { + expect(() => expectUnion(value)).toThrowError(); + }); + }); + describe("rejects malformed unions", () => { + it.each([{}, { a: null }, { a: undefined }, { a: 1, b: 2 }])("%s", (value) => { + expect(() => expectUnion(value)).toThrowError(); + }); + }); + describe("accepts unions", () => { + it.each([{ a: 1 }, { a: 1, b: null }])("%s", (value) => { + expect(expectUnion(value)).toEqual(value); + }); + }); +}); + +describe("strictParseDouble", () => { + it("accepts non-numeric floats as strings", () => { + expect(strictParseDouble("Infinity")).toEqual(Infinity); + expect(strictParseDouble("-Infinity")).toEqual(-Infinity); + expect(strictParseDouble("NaN")).toEqual(NaN); + }); + + describe("rejects implicit NaN", () => { + it.each([ + "foo", + "123ABC", + "ABC123", + "12AB3C", + "1.A", + "1.1A", + "1.1A1", + "0xFF", + "0XFF", + "0b1111", + "0B1111", + "0777", + "0o777", + "0O777", + "1n", + "1N", + "1_000", + "e", + "e1", + ".1", + ])("rejects %s", (value) => { + expect(() => strictParseDouble(value)).toThrowError(); + }); + }); + + it("accepts numeric strings", () => { + expect(strictParseDouble("1")).toEqual(1); + expect(strictParseDouble("-1")).toEqual(-1); + expect(strictParseDouble("1.1")).toEqual(1.1); + expect(strictParseDouble("1e1")).toEqual(10); + expect(strictParseDouble("-1e1")).toEqual(-10); + expect(strictParseDouble("1e+1")).toEqual(10); + expect(strictParseDouble("1e-1")).toEqual(0.1); + expect(strictParseDouble("1E1")).toEqual(10); + expect(strictParseDouble("1E+1")).toEqual(10); + expect(strictParseDouble("1E-1")).toEqual(0.1); + }); + + describe("accepts numbers", () => { + it.each([1, 1.1, Infinity, -Infinity, NaN])("accepts %s", (value) => { + expect(strictParseDouble(value)).toEqual(value); + }); + }); + + it.each([null, undefined])("accepts %s", (value) => { + expect(strictParseDouble(value as any)).toEqual(undefined); + }); +}); + +describe("strictParseFloat32", () => { + it("accepts non-numeric floats as strings", () => { + expect(strictParseFloat32("Infinity")).toEqual(Infinity); + expect(strictParseFloat32("-Infinity")).toEqual(-Infinity); + expect(strictParseFloat32("NaN")).toEqual(NaN); + }); + + describe("rejects implicit NaN", () => { + it.each([ + "foo", + "123ABC", + "ABC123", + "12AB3C", + "1.A", + "1.1A", + "1.1A1", + "0xFF", + "0XFF", + "0b1111", + "0B1111", + "0777", + "0o777", + "0O777", + "1n", + "1N", + "1_000", + "e", + "e1", + ".1", + ])("rejects %s", (value) => { + expect(() => strictParseFloat32(value)).toThrowError(); + }); + }); + + describe("rejects doubles", () => { + it.each([2 ** 128, -(2 ** 128)])("rejects %s", (value) => { + expect(() => strictParseFloat32(value)).toThrowError(); + }); + }); + + it("accepts numeric strings", () => { + expect(strictParseFloat32("1")).toEqual(1); + expect(strictParseFloat32("-1")).toEqual(-1); + expect(strictParseFloat32("1.1")).toEqual(1.1); + expect(strictParseFloat32("1e1")).toEqual(10); + expect(strictParseFloat32("-1e1")).toEqual(-10); + expect(strictParseFloat32("1e+1")).toEqual(10); + expect(strictParseFloat32("1e-1")).toEqual(0.1); + expect(strictParseFloat32("1E1")).toEqual(10); + expect(strictParseFloat32("1E+1")).toEqual(10); + expect(strictParseFloat32("1E-1")).toEqual(0.1); + }); + + describe("accepts numbers", () => { + it.each([1, 1.1, Infinity, -Infinity, NaN])("accepts %s", (value) => { + expect(strictParseFloat32(value)).toEqual(value); + }); + }); + + it.each([null, undefined])("accepts %s", (value) => { + expect(strictParseFloat32(value as any)).toEqual(undefined); + }); +}); + +describe("limitedParseDouble", () => { + it("accepts non-numeric floats as strings", () => { + expect(limitedParseDouble("Infinity")).toEqual(Infinity); + expect(limitedParseDouble("-Infinity")).toEqual(-Infinity); + expect(limitedParseDouble("NaN")).toEqual(NaN); + }); + + it("rejects implicit NaN", () => { + expect(() => limitedParseDouble("foo")).toThrowError(); + }); + + describe("rejects numeric strings", () => { + it.each(["1", "1.1"])("rejects %s", (value) => { + expect(() => limitedParseDouble(value)).toThrowError(); + }); + }); + + describe("accepts numbers", () => { + it.each([ + 1, + 1.1, + Infinity, + -Infinity, + NaN, + // Smallest positive subnormal number + 2 ** -1074, + // Largest subnormal number + 2 ** -1022 * (1 - 2 ** -52), + // Smallest positive normal number + 2 ** -1022, + // Largest number + 2 ** 1023 * (1 + (1 - 2 ** -52)), + // Largest number less than one + 1 - 2 ** -53, + // Smallest number larger than one + 1 + 2 ** -52, + ])("accepts %s", (value) => { + expect(limitedParseDouble(value)).toEqual(value); + }); + }); + + it.each([null, undefined])("accepts %s", (value: any) => { + expect(limitedParseDouble(value)).toEqual(undefined); + }); +}); + +describe("limitedParseFloat32", () => { + it("accepts non-numeric floats as strings", () => { + expect(limitedParseFloat32("Infinity")).toEqual(Infinity); + expect(limitedParseFloat32("-Infinity")).toEqual(-Infinity); + expect(limitedParseFloat32("NaN")).toEqual(NaN); + }); + + it("rejects implicit NaN", () => { + expect(() => limitedParseFloat32("foo")).toThrowError(); + }); + + describe("rejects numeric strings", () => { + it.each(["1", "1.1"])("rejects %s", (value) => { + expect(() => limitedParseFloat32(value)).toThrowError(); + }); + }); + + describe("accepts numbers", () => { + it.each([ + 1, + 1.1, + Infinity, + -Infinity, + NaN, + // Smallest positive subnormal number + 2 ** -149, + // Largest subnormal number + 2 ** -126 * (1 - 2 ** -23), + // Smallest positive normal number + 2 ** -126, + // Largest normal number + 2 ** 127 * (2 - 2 ** -23), + // Largest number less than one + 1 - 2 ** -24, + // Smallest number larger than one + 1 + 2 ** -23, + ])("accepts %s", (value) => { + expect(limitedParseFloat32(value)).toEqual(value); + }); + }); + + describe("rejects doubles", () => { + it.each([2 ** 128, -(2 ** 128)])("rejects %s", (value) => { + expect(() => limitedParseFloat32(value)).toThrowError(); + }); + }); + + it.each([null, undefined])("accepts %s", (value: any) => { + expect(limitedParseFloat32(value)).toEqual(undefined); + }); +}); + +describe("strictParseLong", () => { + describe("accepts integers", () => { + describe("accepts 64-bit integers", () => { + it.each([1, 2 ** 63 - 1, -(2 ** 63), 2 ** 31 - 1, -(2 ** 31), 2 ** 15 - 1, -(2 ** 15), 127, -128])( + "accepts %s", + (value) => { + expect(strictParseLong(value)).toEqual(value); + } + ); + }); + expect(strictParseLong("1")).toEqual(1); + }); + + it.each([null, undefined])("accepts %s", (value: any) => { + expect(strictParseLong(value)).toEqual(undefined); + }); + + describe("rejects non-integers", () => { + it.each([ + 1.1, + "1.1", + "NaN", + "Infinity", + "-Infinity", + NaN, + Infinity, + -Infinity, + true, + false, + [], + {}, + "foo", + "123ABC", + "ABC123", + "12AB3C", + ])("rejects %s", (value) => { + expect(() => strictParseLong(value as any)).toThrowError(); + }); + }); +}); + +describe("strictParseInt32", () => { + describe("accepts integers", () => { + describe("accepts 32-bit integers", () => { + it.each([1, 2 ** 31 - 1, -(2 ** 31), 2 ** 15 - 1, -(2 ** 15), 127, -128])("accepts %s", (value) => { + expect(strictParseInt32(value)).toEqual(value); + }); + }); + expect(strictParseInt32("1")).toEqual(1); + }); + + it.each([null, undefined])("accepts %s", (value: any) => { + expect(strictParseInt32(value)).toEqual(undefined); + }); + + describe("rejects non-integers", () => { + it.each([ + 1.1, + "1.1", + "NaN", + "Infinity", + "-Infinity", + NaN, + Infinity, + -Infinity, + true, + false, + [], + {}, + 2 ** 63 - 1, + -(2 ** 63 + 1), + 2 ** 31, + -(2 ** 31 + 1), + "foo", + "123ABC", + "ABC123", + "12AB3C", + ])("rejects %s", (value) => { + expect(() => strictParseInt32(value as any)).toThrowError(); + }); + }); +}); + +describe("strictParseShort", () => { + describe("accepts integers", () => { + describe("accepts 16-bit integers", () => { + it.each([1, 2 ** 15 - 1, -(2 ** 15), 127, -128])("accepts %s", (value) => { + expect(strictParseShort(value)).toEqual(value); + }); + }); + expect(strictParseShort("1")).toEqual(1); + }); + + it.each([null, undefined])("accepts %s", (value: any) => { + expect(strictParseShort(value)).toEqual(undefined); + }); + + describe("rejects non-integers", () => { + it.each([ + 1.1, + "1.1", + "NaN", + "Infinity", + "-Infinity", + NaN, + Infinity, + -Infinity, + true, + false, + [], + {}, + 2 ** 63 - 1, + -(2 ** 63 + 1), + 2 ** 31 - 1, + -(2 ** 31 + 1), + 2 ** 15, + -(2 ** 15 + 1), + "foo", + "123ABC", + "ABC123", + "12AB3C", + ])("rejects %s", (value) => { + expect(() => strictParseShort(value as any)).toThrowError(); + }); + }); +}); + +describe("strictParseByte", () => { + describe("accepts integers", () => { + describe("accepts 8-bit integers", () => { + it.each([1, 127, -128])("accepts %s", (value) => { + expect(strictParseByte(value)).toEqual(value); + }); + }); + expect(strictParseByte("1")).toEqual(1); + }); + + it.each([null, undefined])("accepts %s", (value: any) => { + expect(strictParseByte(value)).toEqual(undefined); + }); + + describe("rejects non-integers", () => { + it.each([ + 1.1, + "1.1", + "NaN", + "Infinity", + "-Infinity", + NaN, + Infinity, + -Infinity, + true, + false, + [], + {}, + 2 ** 63 - 1, + -(2 ** 63 + 1), + 2 ** 31 - 1, + -(2 ** 31 + 1), + 2 ** 15, + -(2 ** 15 + 1), + 128, + -129, + "foo", + "123ABC", + "ABC123", + "12AB3C", + ])("rejects %s", (value) => { + expect(() => strictParseByte(value as any)).toThrowError(); + }); + }); +}); diff --git a/packages/core/src/submodules/serde/parse-utils.ts b/packages/core/src/submodules/serde/parse-utils.ts new file mode 100644 index 00000000000..7d02f7a2765 --- /dev/null +++ b/packages/core/src/submodules/serde/parse-utils.ts @@ -0,0 +1,558 @@ +/** + * @internal + * + * Give an input string, strictly parses a boolean value. + * + * @param value - The boolean string to parse. + * @returns true for "true", false for "false", otherwise an error is thrown. + */ +export const parseBoolean = (value: string): boolean => { + switch (value) { + case "true": + return true; + case "false": + return false; + default: + throw new Error(`Unable to parse boolean value "${value}"`); + } +}; + +/** + * @internal + * + * Asserts a value is a boolean and returns it. + * Casts strings and numbers with a warning if there is evidence that they were + * intended to be booleans. + * + * @param value - A value that is expected to be a boolean. + * @returns The value if it's a boolean, undefined if it's null/undefined, + * otherwise an error is thrown. + */ +export const expectBoolean = (value: any): boolean | undefined => { + if (value === null || value === undefined) { + return undefined; + } + if (typeof value === "number") { + if (value === 0 || value === 1) { + logger.warn(stackTraceWarning(`Expected boolean, got ${typeof value}: ${value}`)); + } + if (value === 0) { + return false; + } + if (value === 1) { + return true; + } + } + if (typeof value === "string") { + const lower = value.toLowerCase(); + if (lower === "false" || lower === "true") { + logger.warn(stackTraceWarning(`Expected boolean, got ${typeof value}: ${value}`)); + } + if (lower === "false") { + return false; + } + if (lower === "true") { + return true; + } + } + if (typeof value === "boolean") { + return value; + } + throw new TypeError(`Expected boolean, got ${typeof value}: ${value}`); +}; + +/** + * @internal + * + * Asserts a value is a number and returns it. + * Casts strings with a warning if the string is a parseable number. + * This is to unblock slight API definition/implementation inconsistencies. + * + * @param value - A value that is expected to be a number. + * @returns The value if it's a number, undefined if it's null/undefined, + * otherwise an error is thrown. + */ +export const expectNumber = (value: any): number | undefined => { + if (value === null || value === undefined) { + return undefined; + } + if (typeof value === "string") { + const parsed = parseFloat(value); + if (!Number.isNaN(parsed)) { + if (String(parsed) !== String(value)) { + logger.warn(stackTraceWarning(`Expected number but observed string: ${value}`)); + } + return parsed; + } + } + if (typeof value === "number") { + return value; + } + throw new TypeError(`Expected number, got ${typeof value}: ${value}`); +}; + +const MAX_FLOAT = Math.ceil(2 ** 127 * (2 - 2 ** -23)); + +/** + * @internal + * + * Asserts a value is a 32-bit float and returns it. + * + * @param value - A value that is expected to be a 32-bit float. + * @returns The value if it's a float, undefined if it's null/undefined, + * otherwise an error is thrown. + */ +export const expectFloat32 = (value: any): number | undefined => { + const expected = expectNumber(value); + if (expected !== undefined && !Number.isNaN(expected) && expected !== Infinity && expected !== -Infinity) { + // IEEE-754 is an imperfect representation for floats. Consider the simple + // value `0.1`. The representation in a 32-bit float would look like: + // + // 0 01111011 10011001100110011001101 + // Actual value: 0.100000001490116119384765625 + // + // Note the repeating pattern of `1001` in the fraction part. The 64-bit + // representation is similar: + // + // 0 01111111011 1001100110011001100110011001100110011001100110011010 + // Actual value: 0.100000000000000005551115123126 + // + // So even for what we consider simple numbers, the representation differs + // between the two formats. And it's non-obvious how one might look at the + // 64-bit value (which is how JS represents numbers) and determine if it + // can be represented reasonably in the 32-bit form. Primarily because you + // can't know whether the intent was to represent `0.1` or the actual + // value in memory. But even if you have both the decimal value and the + // double value, that still doesn't communicate the intended precision. + // + // So rather than attempting to divine the intent of the caller, we instead + // do some simple bounds checking to make sure the value is passingly + // representable in a 32-bit float. It's not perfect, but it's good enough. + // Perfect, even if possible to achieve, would likely be too costly to + // be worth it. + // + // The maximum value of a 32-bit float. Since the 64-bit representation + // could be more or less, we just round it up to the nearest whole number. + // This further reduces our ability to be certain of the value, but it's + // an acceptable tradeoff. + // + // Compare against the absolute value to simplify things. + if (Math.abs(expected) > MAX_FLOAT) { + throw new TypeError(`Expected 32-bit float, got ${value}`); + } + } + return expected; +}; + +/** + * @internal + * + * Asserts a value is an integer and returns it. + * + * @param value - A value that is expected to be an integer. + * @returns The value if it's an integer, undefined if it's null/undefined, + * otherwise an error is thrown. + */ +export const expectLong = (value: any): number | undefined => { + if (value === null || value === undefined) { + return undefined; + } + if (Number.isInteger(value) && !Number.isNaN(value)) { + return value; + } + throw new TypeError(`Expected integer, got ${typeof value}: ${value}`); +}; + +/** + * @internal + * + * @deprecated Use expectLong + */ +export const expectInt = expectLong; + +/** + * @internal + * + * Asserts a value is a 32-bit integer and returns it. + * + * @param value - A value that is expected to be an integer. + * @returns The value if it's an integer, undefined if it's null/undefined, + * otherwise an error is thrown. + */ +export const expectInt32 = (value: any): number | undefined => expectSizedInt(value, 32); + +/** + * @internal + * + * Asserts a value is a 16-bit integer and returns it. + * + * @param value - A value that is expected to be an integer. + * @returns The value if it's an integer, undefined if it's null/undefined, + * otherwise an error is thrown. + */ +export const expectShort = (value: any): number | undefined => expectSizedInt(value, 16); + +/** + * @internal + * + * Asserts a value is an 8-bit integer and returns it. + * + * @param value - A value that is expected to be an integer. + * @returns The value if it's an integer, undefined if it's null/undefined, + * otherwise an error is thrown. + */ +export const expectByte = (value: any): number | undefined => expectSizedInt(value, 8); + +type IntSize = 32 | 16 | 8; + +const expectSizedInt = (value: any, size: IntSize): number | undefined => { + const expected = expectLong(value); + if (expected !== undefined && castInt(expected, size) !== expected) { + throw new TypeError(`Expected ${size}-bit integer, got ${value}`); + } + return expected; +}; + +const castInt = (value: number, size: IntSize) => { + switch (size) { + case 32: + return Int32Array.of(value)[0]; + case 16: + return Int16Array.of(value)[0]; + case 8: + return Int8Array.of(value)[0]; + } +}; + +/** + * @internal + * + * Asserts a value is not null or undefined and returns it, or throws an error. + * + * @param value - A value that is expected to be defined + * @param location - The location where we're expecting to find a defined object (optional) + * @returns The value if it's not undefined, otherwise throws an error + */ +export const expectNonNull = (value: T | null | undefined, location?: string): T => { + if (value === null || value === undefined) { + if (location) { + throw new TypeError(`Expected a non-null value for ${location}`); + } + throw new TypeError("Expected a non-null value"); + } + return value; +}; + +/** + * @internal + * + * Asserts a value is an JSON-like object and returns it. This is expected to be used + * with values parsed from JSON (arrays, objects, numbers, strings, booleans). + * + * @param value - A value that is expected to be an object + * @returns The value if it's an object, undefined if it's null/undefined, + * otherwise an error is thrown. + */ +export const expectObject = (value: any): Record | undefined => { + if (value === null || value === undefined) { + return undefined; + } + if (typeof value === "object" && !Array.isArray(value)) { + return value; + } + const receivedType = Array.isArray(value) ? "array" : typeof value; + throw new TypeError(`Expected object, got ${receivedType}: ${value}`); +}; + +/** + * @internal + * + * Asserts a value is a string and returns it. + * Numbers and boolean will be cast to strings with a warning. + * + * @param value - A value that is expected to be a string. + * @returns The value if it's a string, undefined if it's null/undefined, + * otherwise an error is thrown. + */ +export const expectString = (value: any): string | undefined => { + if (value === null || value === undefined) { + return undefined; + } + if (typeof value === "string") { + return value; + } + if (["boolean", "number", "bigint"].includes(typeof value)) { + logger.warn(stackTraceWarning(`Expected string, got ${typeof value}: ${value}`)); + return String(value); + } + throw new TypeError(`Expected string, got ${typeof value}: ${value}`); +}; + +/** + * @internal + * + * Asserts a value is a JSON-like object with only one non-null/non-undefined key and + * returns it. + * + * @param value - A value that is expected to be an object with exactly one non-null, + * non-undefined key. + * @returns the value if it's a union, undefined if it's null/undefined, otherwise + * an error is thrown. + */ +export const expectUnion = (value: unknown): Record | undefined => { + if (value === null || value === undefined) { + return undefined; + } + const asObject = expectObject(value)!; + + const setKeys = Object.entries(asObject) + .filter(([, v]) => v != null) + .map(([k]) => k); + + if (setKeys.length === 0) { + throw new TypeError(`Unions must have exactly one non-null member. None were found.`); + } + + if (setKeys.length > 1) { + throw new TypeError(`Unions must have exactly one non-null member. Keys ${setKeys} were not null.`); + } + + return asObject; +}; + +/** + * @internal + * + * Parses a value into a double. If the value is null or undefined, undefined + * will be returned. If the value is a string, it will be parsed by the standard + * parseFloat with one exception: NaN may only be explicitly set as the string + * "NaN", any implicit Nan values will result in an error being thrown. If any + * other type is provided, an exception will be thrown. + * + * @param value - A number or string representation of a double. + * @returns The value as a number, or undefined if it's null/undefined. + */ +export const strictParseDouble = (value: string | number): number | undefined => { + if (typeof value == "string") { + return expectNumber(parseNumber(value)); + } + return expectNumber(value); +}; + +/** + * @internal + * + * @deprecated Use strictParseDouble + */ +export const strictParseFloat = strictParseDouble; + +/** + * @internal + * + * Parses a value into a float. If the value is null or undefined, undefined + * will be returned. If the value is a string, it will be parsed by the standard + * parseFloat with one exception: NaN may only be explicitly set as the string + * "NaN", any implicit Nan values will result in an error being thrown. If any + * other type is provided, an exception will be thrown. + * + * @param value - A number or string representation of a float. + * @returns The value as a number, or undefined if it's null/undefined. + */ +export const strictParseFloat32 = (value: string | number): number | undefined => { + if (typeof value == "string") { + return expectFloat32(parseNumber(value)); + } + return expectFloat32(value); +}; + +// This regex matches JSON-style numbers. In short: +// * The integral may start with a negative sign, but not a positive one +// * No leading 0 on the integral unless it's immediately followed by a '.' +// * Exponent indicated by a case-insensitive 'E' optionally followed by a +// positive/negative sign and some number of digits. +// It also matches both positive and negative infinity as well and explicit NaN. +const NUMBER_REGEX = /(-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)|(-?Infinity)|(NaN)/g; + +const parseNumber = (value: string): number => { + const matches = value.match(NUMBER_REGEX); + if (matches === null || matches[0].length !== value.length) { + throw new TypeError(`Expected real number, got implicit NaN`); + } + return parseFloat(value); +}; + +/** + * @internal + * + * Asserts a value is a number and returns it. If the value is a string + * representation of a non-numeric number type (NaN, Infinity, -Infinity), + * the value will be parsed. Any other string value will result in an exception + * being thrown. Null or undefined will be returned as undefined. Any other + * type will result in an exception being thrown. + * + * @param value - A number or string representation of a non-numeric float. + * @returns The value as a number, or undefined if it's null/undefined. + */ +export const limitedParseDouble = (value: string | number): number | undefined => { + if (typeof value == "string") { + return parseFloatString(value); + } + return expectNumber(value); +}; + +/** + * @internal + * + * @deprecated Use limitedParseDouble + */ +export const handleFloat = limitedParseDouble; + +/** + * @internal + * + * @deprecated Use limitedParseDouble + */ +export const limitedParseFloat = limitedParseDouble; + +/** + * @internal + * + * Asserts a value is a 32-bit float and returns it. If the value is a string + * representation of a non-numeric number type (NaN, Infinity, -Infinity), + * the value will be parsed. Any other string value will result in an exception + * being thrown. Null or undefined will be returned as undefined. Any other + * type will result in an exception being thrown. + * + * @param value - A number or string representation of a non-numeric float. + * @returns The value as a number, or undefined if it's null/undefined. + */ +export const limitedParseFloat32 = (value: string | number): number | undefined => { + if (typeof value == "string") { + return parseFloatString(value); + } + return expectFloat32(value); +}; + +const parseFloatString = (value: string): number => { + switch (value) { + case "NaN": + return NaN; + case "Infinity": + return Infinity; + case "-Infinity": + return -Infinity; + default: + throw new Error(`Unable to parse float value: ${value}`); + } +}; + +/** + * @internal + * + * Parses a value into an integer. If the value is null or undefined, undefined + * will be returned. If the value is a string, it will be parsed by parseFloat + * and the result will be asserted to be an integer. If the parsed value is not + * an integer, or the raw value is any type other than a string or number, an + * exception will be thrown. + * + * @param value - A number or string representation of an integer. + * @returns The value as a number, or undefined if it's null/undefined. + */ +export const strictParseLong = (value: string | number): number | undefined => { + if (typeof value === "string") { + // parseInt can't be used here, because it will silently discard any + // existing decimals. We want to instead throw an error if there are any. + return expectLong(parseNumber(value)); + } + return expectLong(value); +}; + +/** + * @internal + * + * @deprecated Use strictParseLong + */ +export const strictParseInt = strictParseLong; + +/** + * @internal + * + * Parses a value into a 32-bit integer. If the value is null or undefined, undefined + * will be returned. If the value is a string, it will be parsed by parseFloat + * and the result will be asserted to be an integer. If the parsed value is not + * an integer, or the raw value is any type other than a string or number, an + * exception will be thrown. + * + * @param value - A number or string representation of a 32-bit integer. + * @returns The value as a number, or undefined if it's null/undefined. + */ +export const strictParseInt32 = (value: string | number): number | undefined => { + if (typeof value === "string") { + // parseInt can't be used here, because it will silently discard any + // existing decimals. We want to instead throw an error if there are any. + return expectInt32(parseNumber(value)); + } + return expectInt32(value); +}; + +/** + * @internal + * + * Parses a value into a 16-bit integer. If the value is null or undefined, undefined + * will be returned. If the value is a string, it will be parsed by parseFloat + * and the result will be asserted to be an integer. If the parsed value is not + * an integer, or the raw value is any type other than a string or number, an + * exception will be thrown. + * + * @param value - A number or string representation of a 16-bit integer. + * @returns The value as a number, or undefined if it's null/undefined. + */ +export const strictParseShort = (value: string | number): number | undefined => { + if (typeof value === "string") { + // parseInt can't be used here, because it will silently discard any + // existing decimals. We want to instead throw an error if there are any. + return expectShort(parseNumber(value)); + } + return expectShort(value); +}; + +/** + * @internal + * + * Parses a value into an 8-bit integer. If the value is null or undefined, undefined + * will be returned. If the value is a string, it will be parsed by parseFloat + * and the result will be asserted to be an integer. If the parsed value is not + * an integer, or the raw value is any type other than a string or number, an + * exception will be thrown. + * + * @param value - A number or string representation of an 8-bit integer. + * @returns The value as a number, or undefined if it's null/undefined. + */ +export const strictParseByte = (value: string | number): number | undefined => { + if (typeof value === "string") { + // parseInt can't be used here, because it will silently discard any + // existing decimals. We want to instead throw an error if there are any. + return expectByte(parseNumber(value)); + } + return expectByte(value); +}; + +/** + * @internal + * @param message - error message. + * @returns truncated stack trace omitting this function. + */ +const stackTraceWarning = (message: string): string => { + return String(new TypeError(message).stack || message) + .split("\n") + .slice(0, 5) + .filter((s) => !s.includes("stackTraceWarning")) + .join("\n"); +}; + +/** + * @internal + */ +export const logger = { + warn: console.warn, +}; diff --git a/packages/core/src/submodules/serde/quote-header.spec.ts b/packages/core/src/submodules/serde/quote-header.spec.ts new file mode 100644 index 00000000000..1118422cfb9 --- /dev/null +++ b/packages/core/src/submodules/serde/quote-header.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, test as it } from "vitest"; + +import { quoteHeader } from "./quote-header"; + +describe(quoteHeader.name, () => { + it("should not wrap header elements that don't include the delimiter or double quotes", () => { + expect(quoteHeader("bc")).toBe("bc"); + }); + + it("should wrap header elements that include the delimiter", () => { + expect(quoteHeader("b,c")).toBe('"b,c"'); + }); + + it("should wrap header elements that include double quotes", () => { + expect(quoteHeader(`"bc"`)).toBe('"\\"bc\\""'); + }); + + it("should wrap header elements that include the delimiter and double quotes", () => { + expect(quoteHeader(`"b,c"`)).toBe('"\\"b,c\\""'); + }); +}); diff --git a/packages/core/src/submodules/serde/quote-header.ts b/packages/core/src/submodules/serde/quote-header.ts new file mode 100644 index 00000000000..d7eb7d60349 --- /dev/null +++ b/packages/core/src/submodules/serde/quote-header.ts @@ -0,0 +1,11 @@ +/** + * @public + * @param part - header list element + * @returns quoted string if part contains delimiter. + */ +export function quoteHeader(part: string) { + if (part.includes(",") || part.includes('"')) { + part = `"${part.replace(/"/g, '\\"')}"`; + } + return part; +} diff --git a/packages/core/src/submodules/serde/split-every.spec.ts b/packages/core/src/submodules/serde/split-every.spec.ts new file mode 100644 index 00000000000..cb7a8afafcd --- /dev/null +++ b/packages/core/src/submodules/serde/split-every.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect, test as it } from "vitest"; + +import { splitEvery } from "./split-every"; +describe("splitEvery", () => { + const m1 = "foo"; + const m2 = "foo, bar"; + const m3 = "foo, bar, baz"; + const m4 = "foo, bar, baz, qux"; + const m5 = "foo, bar, baz, qux, coo"; + const m6 = "foo, bar, baz, qux, coo, tan"; + const delim = ", "; + + it("Errors on <= 0", () => { + expect(() => { + splitEvery(m2, delim, -1); + }).toThrow("Invalid number of delimiters"); + + expect(() => { + splitEvery(m2, delim, 0); + }).toThrow("Invalid number of delimiters"); + }); + + it("Errors on non-integer", () => { + expect(() => { + splitEvery(m2, delim, 1.3); + }).toThrow("Invalid number of delimiters"); + + expect(() => { + splitEvery(m2, delim, 4.9); + }).toThrow("Invalid number of delimiters"); + }); + + it("Handles splitting on 1", () => { + const count = 1; + expect(splitEvery(m1, delim, count)).toMatchObject(m1.split(delim)); + expect(splitEvery(m2, delim, count)).toMatchObject(m2.split(delim)); + expect(splitEvery(m3, delim, count)).toMatchObject(m3.split(delim)); + expect(splitEvery(m4, delim, count)).toMatchObject(m4.split(delim)); + expect(splitEvery(m5, delim, count)).toMatchObject(m5.split(delim)); + expect(splitEvery(m6, delim, count)).toMatchObject(m6.split(delim)); + }); + + it("Handles splitting on 2", () => { + const count = 2; + expect(splitEvery(m1, delim, count)).toMatchObject(["foo"]); + expect(splitEvery(m2, delim, count)).toMatchObject(["foo, bar"]); + expect(splitEvery(m3, delim, count)).toMatchObject(["foo, bar", "baz"]); + expect(splitEvery(m4, delim, count)).toMatchObject(["foo, bar", "baz, qux"]); + expect(splitEvery(m5, delim, count)).toMatchObject(["foo, bar", "baz, qux", "coo"]); + expect(splitEvery(m6, delim, count)).toMatchObject(["foo, bar", "baz, qux", "coo, tan"]); + }); + + it("Handles splitting on 3", () => { + const count = 3; + expect(splitEvery(m1, delim, count)).toMatchObject(["foo"]); + expect(splitEvery(m2, delim, count)).toMatchObject(["foo, bar"]); + expect(splitEvery(m3, delim, count)).toMatchObject(["foo, bar, baz"]); + expect(splitEvery(m4, delim, count)).toMatchObject(["foo, bar, baz", "qux"]); + expect(splitEvery(m5, delim, count)).toMatchObject(["foo, bar, baz", "qux, coo"]); + expect(splitEvery(m6, delim, count)).toMatchObject(["foo, bar, baz", "qux, coo, tan"]); + }); +}); diff --git a/packages/core/src/submodules/serde/split-every.ts b/packages/core/src/submodules/serde/split-every.ts new file mode 100644 index 00000000000..2644fb7bee4 --- /dev/null +++ b/packages/core/src/submodules/serde/split-every.ts @@ -0,0 +1,48 @@ +/** + * @internal + * + * Given an input string, splits based on the delimiter after a given + * number of delimiters has been encountered. + * + * @param value - The input string to split. + * @param delimiter - The delimiter to split on. + * @param numDelimiters - The number of delimiters to have encountered to split. + */ +export function splitEvery(value: string, delimiter: string, numDelimiters: number): Array { + // Fail if we don't have a clear number to split on. + if (numDelimiters <= 0 || !Number.isInteger(numDelimiters)) { + throw new Error("Invalid number of delimiters (" + numDelimiters + ") for splitEvery."); + } + + const segments = value.split(delimiter); + // Short circuit extra logic for the simple case. + if (numDelimiters === 1) { + return segments; + } + + const compoundSegments: Array = []; + let currentSegment = ""; + for (let i = 0; i < segments.length; i++) { + if (currentSegment === "") { + // Start a new segment. + currentSegment = segments[i]; + } else { + // Compound the current segment with the delimiter. + currentSegment += delimiter + segments[i]; + } + + if ((i + 1) % numDelimiters === 0) { + // We encountered the right number of delimiters, so add the entry. + compoundSegments.push(currentSegment); + // And reset the current segment. + currentSegment = ""; + } + } + + // Handle any leftover segment portion. + if (currentSegment !== "") { + compoundSegments.push(currentSegment); + } + + return compoundSegments; +} diff --git a/packages/core/src/submodules/serde/split-header.spec.ts b/packages/core/src/submodules/serde/split-header.spec.ts new file mode 100644 index 00000000000..0bfa0245ac2 --- /dev/null +++ b/packages/core/src/submodules/serde/split-header.spec.ts @@ -0,0 +1,24 @@ +import { describe, expect, test as it } from "vitest"; + +import { splitHeader } from "./split-header"; + +describe(splitHeader.name, () => { + it("should split a string by commas and trim only the comma delimited outer values", () => { + expect(splitHeader("abc")).toEqual(["abc"]); + expect(splitHeader("a,b,c")).toEqual(["a", "b", "c"]); + expect(splitHeader("a, b, c")).toEqual(["a", "b", "c"]); + expect(splitHeader("a , b , c")).toEqual(["a", "b", "c"]); + expect(splitHeader(`a , b , " c "`)).toEqual(["a", "b", " c "]); + expect(splitHeader(` a , , b`)).toEqual(["a", "", "b"]); + expect(splitHeader(`,,`)).toEqual(["", "", ""]); + expect(splitHeader(` , , `)).toEqual(["", "", ""]); + }); + it("should split a string by commas that are not in quotes, and remove outer quotes", () => { + expect(splitHeader('"b,c", "\\"def\\"", a')).toEqual(["b,c", '"def"', "a"]); + expect(splitHeader('"a,b,c", ""def"", "a,b ,c"')).toEqual(["a,b,c", '"def"', "a,b ,c"]); + expect(splitHeader(`""`)).toEqual([``]); + expect(splitHeader(``)).toEqual([``]); + expect(splitHeader(`\\"`)).toEqual([`"`]); + expect(splitHeader(`"`)).toEqual([`"`]); + }); +}); diff --git a/packages/core/src/submodules/serde/split-header.ts b/packages/core/src/submodules/serde/split-header.ts new file mode 100644 index 00000000000..e76aa577de6 --- /dev/null +++ b/packages/core/src/submodules/serde/split-header.ts @@ -0,0 +1,45 @@ +/** + * @param value - header string value. + * @returns value split by commas that aren't in quotes. + */ +export const splitHeader = (value: string): string[] => { + const z = value.length; + const values = []; + + let withinQuotes = false; + let prevChar = undefined; + let anchor = 0; + + for (let i = 0; i < z; ++i) { + const char = value[i]; + switch (char) { + case `"`: + if (prevChar !== "\\") { + withinQuotes = !withinQuotes; + } + break; + case ",": + if (!withinQuotes) { + values.push(value.slice(anchor, i)); + anchor = i + 1; + } + break; + default: + } + prevChar = char; + } + + values.push(value.slice(anchor)); + + return values.map((v) => { + v = v.trim(); + const z = v.length; + if (z < 2) { + return v; + } + if (v[0] === `"` && v[z - 1] === `"`) { + v = v.slice(1, z - 1); + } + return v.replace(/\\"/g, '"'); + }); +}; diff --git a/packages/core/tsconfig.cjs.json b/packages/core/tsconfig.cjs.json index a5bbf2547b8..fe488825071 100644 --- a/packages/core/tsconfig.cjs.json +++ b/packages/core/tsconfig.cjs.json @@ -6,6 +6,7 @@ "paths": { "@smithy/core/cbor": ["./src/submodules/cbor/index.ts"], "@smithy/core/protocols": ["./src/submodules/protocols/index.ts"], + "@smithy/core/schema": ["./src/submodules/schema/index.ts"], "@smithy/core/serde": ["./src/submodules/serde/index.ts"] } }, diff --git a/packages/core/tsconfig.es.json b/packages/core/tsconfig.es.json index 95225ee3300..763da8edd2b 100644 --- a/packages/core/tsconfig.es.json +++ b/packages/core/tsconfig.es.json @@ -7,6 +7,7 @@ "paths": { "@smithy/core/cbor": ["./src/submodules/cbor/index.ts"], "@smithy/core/protocols": ["./src/submodules/protocols/index.ts"], + "@smithy/core/schema": ["./src/submodules/schema/index.ts"], "@smithy/core/serde": ["./src/submodules/serde/index.ts"] } }, diff --git a/packages/core/tsconfig.types.json b/packages/core/tsconfig.types.json index 5400575ea0b..1260e37efca 100644 --- a/packages/core/tsconfig.types.json +++ b/packages/core/tsconfig.types.json @@ -6,6 +6,7 @@ "paths": { "@smithy/core/cbor": ["./src/submodules/cbor/index.ts"], "@smithy/core/protocols": ["./src/submodules/protocols/index.ts"], + "@smithy/core/schema": ["./src/submodules/schema/index.ts"], "@smithy/core/serde": ["./src/submodules/serde/index.ts"] } }, diff --git a/packages/smithy-client/src/client.ts b/packages/smithy-client/src/client.ts index bd05e65faa7..33ec482a11b 100644 --- a/packages/smithy-client/src/client.ts +++ b/packages/smithy-client/src/client.ts @@ -7,6 +7,7 @@ import { MetadataBearer, MiddlewareStack, NodeHttpHandlerOptions, + Protocol, RequestHandler, } from "@smithy/types"; @@ -14,6 +15,11 @@ import { * @public */ export interface SmithyConfiguration { + /** + * A requestHandler instance or its initializer object. + * + * @public + */ requestHandler: | RequestHandler | NodeHttpHandlerOptions @@ -26,8 +32,6 @@ export interface SmithyConfiguration { */ readonly apiVersion: string; /** - * @public - * * Default false. * * When true, the client will only resolve the middleware stack once per @@ -39,8 +43,19 @@ export interface SmithyConfiguration { * * Enable this only if needing the additional time saved (0-1ms per request) * and not needing middleware modifications between requests. + * + * @public */ cacheMiddleware?: boolean; + /** + * The protocol controlling the message type (e.g. HTTP) and format (e.g. JSON) + * may be overridden. A default will always be set by the client. + * + * Available options depend on the service's supported protocols. + * + * @public + */ + protocol?: Protocol; } /** @@ -50,6 +65,7 @@ export type SmithyResolvedConfiguration = { requestHandler: RequestHandler; readonly apiVersion: string; cacheMiddleware?: boolean; + protocol: Protocol; }; /** diff --git a/packages/smithy-client/src/command.ts b/packages/smithy-client/src/command.ts index b100c8233e1..e486e41f613 100644 --- a/packages/smithy-client/src/command.ts +++ b/packages/smithy-client/src/command.ts @@ -11,6 +11,8 @@ import type { Logger, MetadataBearer, MiddlewareStack as IMiddlewareStack, + Mutable, + OperationSchema, OptionalParameter, Pluggable, RequestHandler, @@ -31,6 +33,7 @@ export abstract class Command< { public abstract input: Input; public readonly middlewareStack: IMiddlewareStack = constructStack(); + public readonly schema?: OperationSchema; /** * Factory for Command ClassBuilder. @@ -131,6 +134,8 @@ class ClassBuilder< private _outputFilterSensitiveLog = (_: any) => _; private _serializer: (input: I, context: SerdeContext | any) => Promise = null as any; private _deserializer: (output: IHttpResponse, context: SerdeContext | any) => Promise = null as any; + private _operationSchema?: OperationSchema; + /** * Optional init callback. */ @@ -212,6 +217,16 @@ class ClassBuilder< this._deserializer = deserializer; return this; } + + /** + * Sets input/output schema for the operation. + */ + public sc(operation: OperationSchema): ClassBuilder { + this._operationSchema = operation; + this._smithyContext.operationSchema = operation; + return this; + } + /** * @returns a Command class with the classBuilder properties. */ @@ -241,6 +256,7 @@ class ClassBuilder< super(); this.input = input ?? ({} as unknown as I); closure._init(this); + (this as Mutable).schema = closure._operationSchema; } /** diff --git a/packages/smithy-client/src/object-mapping.ts b/packages/smithy-client/src/object-mapping.ts index 6c9bc0b61b0..275c4a36174 100644 --- a/packages/smithy-client/src/object-mapping.ts +++ b/packages/smithy-client/src/object-mapping.ts @@ -313,11 +313,11 @@ const applyInstruction = ( }; /** - * internal + * @internal */ const nonNullish = (_: any) => _ != null; /** - * internal + * @internal */ const pass = (_: any) => _; diff --git a/packages/types/src/command.ts b/packages/types/src/command.ts index 101651f1ed7..fa1d2553eac 100644 --- a/packages/types/src/command.ts +++ b/packages/types/src/command.ts @@ -1,5 +1,6 @@ import { Handler, MiddlewareStack } from "./middleware"; import { MetadataBearer } from "./response"; +import { OperationSchema } from "./schema/schema"; /** * @public @@ -13,6 +14,8 @@ export interface Command< > extends CommandIO { readonly input: InputType; readonly middlewareStack: MiddlewareStack; + readonly schema?: OperationSchema; + resolveMiddleware( stack: MiddlewareStack, configuration: ResolvedConfiguration, diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index c370335c2ab..b1dfb30b38f 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -21,6 +21,8 @@ export * from "./pagination"; export * from "./profile"; export * from "./response"; export * from "./retry"; +export * from "./schema/schema"; +export * from "./schema/sentinels"; export * from "./serde"; export * from "./shapes"; export * from "./signature"; @@ -30,6 +32,7 @@ export * from "./streaming-payload/streaming-blob-payload-input-types"; export * from "./streaming-payload/streaming-blob-payload-output-types"; export * from "./transfer"; export * from "./transform/client-payload-blob-type-narrow"; +export * from "./transform/mutable"; export * from "./transform/no-undefined"; export * from "./transform/type-transform"; export * from "./uri"; diff --git a/packages/types/src/schema/schema.ts b/packages/types/src/schema/schema.ts new file mode 100644 index 00000000000..dc41b48e28d --- /dev/null +++ b/packages/types/src/schema/schema.ts @@ -0,0 +1,321 @@ +import { EndpointV2 } from "../endpoint"; +import { HandlerExecutionContext } from "../middleware"; +import { MetadataBearer } from "../response"; +import { SerdeContext } from "../serde"; +import type { + BigDecimalSchema, + BigIntegerSchema, + BlobSchema, + BooleanSchema, + DocumentSchema, + NumericSchema, + StreamingBlobSchema, + StringSchema, + TimestampDateTimeSchema, + TimestampDefaultSchema, + TimestampEpochSecondsSchema, + TimestampHttpDateSchema, +} from "./sentinels"; +import type { TraitBitVector } from "./traits"; + +/** + * Sentinel value for Timestamp schema. + * "time" means unspecified and to use the protocol serializer's default format. + * + * @public + */ +export type TimestampSchemas = + | TimestampDefaultSchema + | TimestampDateTimeSchema + | TimestampHttpDateSchema + | TimestampEpochSecondsSchema; + +/** + * Sentinel values for Blob schema. + * @public + */ +export type BlobSchemas = BlobSchema | StreamingBlobSchema; + +/** + * Signal value for operation Unit input or output. + * + * @internal + */ +export type UnitSchema = "unit"; + +/** + * Traits attached to schema objects. + * + * When this is a number, it refers to a pre-allocated + * trait combination that is equivalent to one of the + * object type's variations. + * + * @public + */ +export type SchemaTraits = TraitBitVector | SchemaTraitsObject; + +/** + * @public + */ +export type SchemaTraitsObject = { + idempotent?: 1; + idempotencyToken?: 1; + sensitive?: 1; + sparse?: 1; + + /** + * timestampFormat is expressed by the schema sentinel values of 4, 5, 6, and 7, + * and not contained in trait objects. + * @deprecated use schema value. + */ + timestampFormat?: never; + + httpLabel?: 1; + httpHeader?: string; + httpQuery?: string; + httpPrefixHeaders?: string; + httpQueryParams?: 1; + httpPayload?: 1; + /** + * [method, path, statusCode] + */ + http?: [string, string, number]; + httpResponseCode?: 1; + /** + * [hostPrefix] + */ + endpoint?: [string]; + + xmlAttribute?: 1; + xmlName?: string; + /** + * [prefix, uri] + */ + xmlNamespace?: [string, string]; + xmlFlattened?: 1; + jsonName?: string; + + mediaType?: string; + + [traitName: string]: any; +}; + +/** + * A schema having traits. + * + * @public + */ +export interface TraitsSchema { + name: string; + traits: SchemaTraits; +} + +/** + * A data object containing serialization traits of a + * shape to assist with (de)serialization. + * + * @public + */ +export interface StructureSchema extends TraitsSchema { + name: string; + traits: SchemaTraits; + members: Record; +} + +/** + * @public + */ +export interface ListSchema extends TraitsSchema { + name: string; + traits: SchemaTraits; + valueSchema: SchemaRef; +} + +/** + * @public + */ +export interface MapSchema extends TraitsSchema { + name: string; + traits: SchemaTraits; + valueSchema: SchemaRef; +} + +/** + * @public + */ +export type MemberSchema = [SchemaRef, SchemaTraits]; + +/** + * Schema for an operation. + * + * @public + */ +export interface OperationSchema extends TraitsSchema { + name: string; + traits: SchemaTraits; + input: SchemaRef; + output: SchemaRef; +} + +/** + * Normalization wrapper for various schema data objects. + * @internal + */ +export interface NormalizedSchema extends TraitsSchema { + name: string; + traits: SchemaTraits; + getSchema(): Schema; + getName(): string | undefined; + isMemberSchema(): boolean; + isListSchema(): boolean; + isMapSchema(): boolean; + isStructSchema(): boolean; + isBlobSchema(): boolean; + isTimestampSchema(): boolean; + isStringSchema(): boolean; + isBooleanSchema(): boolean; + isNumericSchema(): boolean; + isBigIntegerSchema(): boolean; + isBigDecimalSchema(): boolean; + isStreaming(): boolean; + getMergedTraits(): SchemaTraitsObject; + getMemberTraits(): SchemaTraitsObject; + getOwnTraits(): SchemaTraitsObject; + /** + * For list/set/map. + */ + getValueSchema(): NormalizedSchema; + /** + * For struct/union. + */ + getMemberSchema(member: string): NormalizedSchema | undefined; + getMemberSchemas(): Record; +} + +/** + * @public + */ +export type SimpleSchema = + | BlobSchemas + | StringSchema + | BooleanSchema + | NumericSchema + | BigIntegerSchema + | BigDecimalSchema + | DocumentSchema + | TimestampSchemas + | number; + +/** + * @public + */ +export type Schema = + | UnitSchema + | TraitsSchema + | SimpleSchema + | ListSchema + | MapSchema + | StructureSchema + | MemberSchema + | OperationSchema + | NormalizedSchema; + +/** + * @public + */ +export type SchemaRef = Schema | (() => Schema); + +/** + * A codec creates serializers and deserializers for some format such as JSON, XML, or CBOR. + * + * @public + */ +export interface Codec extends ConfigurableSerdeContext { + createSerializer(): ShapeSerializer; + createDeserializer(): ShapeDeserializer; +} + +/** + * @public + */ +export type CodecSettings = { + timestampFormat: { + /** + * Whether to use member timestamp format traits. + */ + useTrait: boolean; + /** + * Default timestamp format. + */ + default: TimestampDateTimeSchema | TimestampHttpDateSchema | TimestampEpochSecondsSchema; + }; + /** + * Whether to use HTTP binding traits. + */ + httpBindings?: boolean; +}; + +/** + * @public + */ +export interface ShapeDeserializer extends ConfigurableSerdeContext { + /** + * Optionally async. + */ + read(schema: Schema, data: SerializationType): any | Promise; +} + +/** + * @public + */ +export interface ShapeSerializer extends ConfigurableSerdeContext { + write(schema: Schema, value: unknown): void; + + flush(): SerializationType; +} + +/** + * @public + */ +export interface Protocol extends ConfigurableSerdeContext { + getShapeId(): string; + + getRequestType(): { new (...args: any[]): Request }; + getResponseType(): { new (...args: any[]): Response }; + + getPayloadCodec(): Codec; + + serializeRequest( + operationSchema: OperationSchema, + input: Input, + context: HandlerExecutionContext & SerdeContext + ): Promise; + + updateServiceEndpoint(request: Request, endpoint: EndpointV2): Request; + + deserializeResponse( + operationSchema: OperationSchema, + context: HandlerExecutionContext & SerdeContext, + response: Response + ): Promise; +} + +/** + * Indicates implementation may need the request's serdeContext to + * be provided. + * + * @internal + */ +export interface ConfigurableSerdeContext { + setSerdeContext(serdeContext: SerdeContext): void; +} + +/** + * @public + */ +export interface Transport { + getRequestType(): { new (...args: any[]): Request }; + getResponseType(): { new (...args: any[]): Response }; + + send(context: HandlerExecutionContext, request: Request): Promise; +} diff --git a/packages/types/src/schema/sentinels.ts b/packages/types/src/schema/sentinels.ts new file mode 100644 index 00000000000..0000b9c2a29 --- /dev/null +++ b/packages/types/src/schema/sentinels.ts @@ -0,0 +1,79 @@ +// =============== Simple types =================== + +/** + * The blob Smithy type, in JS as Uint8Array and other representations + * such as Buffer, string, or Readable(Stream) depending on circumstances. + * @public + */ +export type BlobSchema = 0b0001_0101; // 21 + +/** + * @public + */ +export type StreamingBlobSchema = 0b0010_1010; // 42 + +/** + * @public + */ +export type BooleanSchema = 0b0000_0010; // 2 + +/** + * Includes string and enum Smithy types. + * @public + */ +export type StringSchema = 0b0000_0000; // 0 + +/** + * Includes all numeric Smithy types except bigInteger and bigDecimal. + * byte, short, integer, long, float, double, intEnum. + * + * @public + */ +export type NumericSchema = 0b0000_0001; // 1 + +/** + * @public + */ +export type BigIntegerSchema = 0b0001_0001; // 17 + +/** + * @public + */ +export type BigDecimalSchema = 0b0001_0011; // 19 + +/** + * @public + */ +export type DocumentSchema = 0b0000_1111; // 15 + +/** + * Smithy type timestamp, in JS as native Date object. + * @public + */ +export type TimestampDefaultSchema = 0b0000_0100; // 4 +/** + * @public + */ +export type TimestampDateTimeSchema = 0b0000_0101; // 5 +/** + * @public + */ +export type TimestampHttpDateSchema = 0b0000_0110; // 6 +/** + * @public + */ +export type TimestampEpochSecondsSchema = 0b0000_0111; // 7 + +// =============== Aggregate types =================== + +/** + * Additional bit indicating the type is a list. + * @public + */ +export type ListSchemaModifier = 0b0100_0000; // 64 + +/** + * Additional bit indicating the type is a map. + * @public + */ +export type MapSchemaModifier = 0b1000_0000; // 128 diff --git a/packages/types/src/schema/traits.ts b/packages/types/src/schema/traits.ts new file mode 100644 index 00000000000..979b90b4934 --- /dev/null +++ b/packages/types/src/schema/traits.ts @@ -0,0 +1,55 @@ +/** + * A bitvector representing a traits object. + * + * Vector index to trait: + * 0 - httpLabel + * 1 - idempotent + * 2 - idempotencyToken + * 3 - sensitive + * 4 - httpPayload + * 5 - httpResponseCode + * 6 - httpQueryParams + * + * The singular trait values are enumerated for quick identification, but + * combination values are left to the `number` union type. + * + * @public + */ +export type TraitBitVector = + | HttpLabelBitMask + | IdempotentBitMask + | IdempotencyTokenBitMask + | SensitiveBitMask + | HttpPayloadBitMask + | HttpResponseCodeBitMask + | HttpQueryParamsBitMask + | number; + +/** + * @public + */ +export type HttpLabelBitMask = 1; +/** + * @public + */ +export type IdempotentBitMask = 2; +/** + * @public + */ +export type IdempotencyTokenBitMask = 4; +/** + * @public + */ +export type SensitiveBitMask = 8; +/** + * @public + */ +export type HttpPayloadBitMask = 16; +/** + * @public + */ +export type HttpResponseCodeBitMask = 32; +/** + * @public + */ +export type HttpQueryParamsBitMask = 64; diff --git a/packages/types/src/serde.ts b/packages/types/src/serde.ts index 5af727e6aab..608404cb87c 100644 --- a/packages/types/src/serde.ts +++ b/packages/types/src/serde.ts @@ -1,4 +1,5 @@ import { Endpoint } from "./http"; +import { Protocol } from "./schema/schema"; import { RequestHandler } from "./transfer"; import { Decoder, Encoder, Provider } from "./util"; @@ -34,6 +35,7 @@ export interface StreamCollector { export interface SerdeContext extends SerdeFunctions, EndpointBearer { requestHandler: RequestHandler; disableHostPrefix: boolean; + protocol?: Protocol; } /** @@ -51,6 +53,7 @@ export interface SerdeFunctions { /** * @public + * @deprecated - use SchemaRequestSerializer. */ export interface RequestSerializer { /** @@ -65,6 +68,7 @@ export interface RequestSerializer { /** diff --git a/packages/types/src/transform/mutable.ts b/packages/types/src/transform/mutable.ts new file mode 100644 index 00000000000..5f7a47dee93 --- /dev/null +++ b/packages/types/src/transform/mutable.ts @@ -0,0 +1,6 @@ +/** + * @internal + */ +export type Mutable = { + -readonly [Property in keyof Type]: Type[Property]; +}; diff --git a/private/smithy-rpcv2-cbor/src/RpcV2ProtocolClient.ts b/private/smithy-rpcv2-cbor/src/RpcV2ProtocolClient.ts index 05e3283f2ea..2220bada5a7 100644 --- a/private/smithy-rpcv2-cbor/src/RpcV2ProtocolClient.ts +++ b/private/smithy-rpcv2-cbor/src/RpcV2ProtocolClient.ts @@ -41,6 +41,7 @@ import { resolveCustomEndpointsConfig, } from "@smithy/config-resolver"; import { DefaultIdentityProviderConfig, getHttpAuthSchemePlugin, getHttpSigningPlugin } from "@smithy/core"; +import { getSchemaSerdePlugin } from "@smithy/core/schema"; import { getContentLengthPlugin } from "@smithy/middleware-content-length"; import { RetryInputConfig, RetryResolvedConfig, getRetryPlugin, resolveRetryConfig } from "@smithy/middleware-retry"; import { HttpHandlerUserInput as __HttpHandlerUserInput } from "@smithy/protocol-http"; @@ -254,6 +255,7 @@ export class RpcV2ProtocolClient extends __Client< let _config_3 = resolveHttpAuthSchemeConfig(_config_2); let _config_4 = resolveRuntimeExtensions(_config_3, configuration?.extensions || []); this.config = _config_4; + this.middlewareStack.use(getSchemaSerdePlugin(this.config)); this.middlewareStack.use(getRetryPlugin(this.config)); this.middlewareStack.use(getContentLengthPlugin(this.config)); this.middlewareStack.use( diff --git a/private/smithy-rpcv2-cbor/src/auth/httpAuthSchemeProvider.ts b/private/smithy-rpcv2-cbor/src/auth/httpAuthSchemeProvider.ts index 55bbd627a21..d4490b9636d 100644 --- a/private/smithy-rpcv2-cbor/src/auth/httpAuthSchemeProvider.ts +++ b/private/smithy-rpcv2-cbor/src/auth/httpAuthSchemeProvider.ts @@ -7,8 +7,9 @@ import { HttpAuthSchemeParameters, HttpAuthSchemeParametersProvider, HttpAuthSchemeProvider, + Provider, } from "@smithy/types"; -import { getSmithyContext } from "@smithy/util-middleware"; +import { getSmithyContext, normalizeProvider } from "@smithy/util-middleware"; /** * @internal @@ -68,6 +69,14 @@ export const defaultRpcV2ProtocolHttpAuthSchemeProvider: RpcV2ProtocolHttpAuthSc * @internal */ export interface HttpAuthSchemeInputConfig { + /** + * A comma-separated list of case-sensitive auth scheme names. + * An auth scheme name is a fully qualified auth scheme ID with the namespace prefix trimmed. + * For example, the auth scheme with ID aws.auth#sigv4 is named sigv4. + * @public + */ + authSchemePreference?: string[] | Provider; + /** * Configuration of HttpAuthSchemes for a client which provides default identity providers and signers per auth scheme. * @internal @@ -85,6 +94,14 @@ export interface HttpAuthSchemeInputConfig { * @internal */ export interface HttpAuthSchemeResolvedConfig { + /** + * A comma-separated list of case-sensitive auth scheme names. + * An auth scheme name is a fully qualified auth scheme ID with the namespace prefix trimmed. + * For example, the auth scheme with ID aws.auth#sigv4 is named sigv4. + * @public + */ + readonly authSchemePreference: Provider; + /** * Configuration of HttpAuthSchemes for a client which provides default identity providers and signers per auth scheme. * @internal @@ -104,5 +121,7 @@ export interface HttpAuthSchemeResolvedConfig { export const resolveHttpAuthSchemeConfig = ( config: T & HttpAuthSchemeInputConfig ): T & HttpAuthSchemeResolvedConfig => { - return Object.assign(config, {}) as T & HttpAuthSchemeResolvedConfig; + return Object.assign(config, { + authSchemePreference: normalizeProvider(config.authSchemePreference ?? []), + }) as T & HttpAuthSchemeResolvedConfig; }; diff --git a/private/smithy-rpcv2-cbor/src/commands/EmptyInputOutputCommand.ts b/private/smithy-rpcv2-cbor/src/commands/EmptyInputOutputCommand.ts index 0e60c36f071..a2fa370a5fe 100644 --- a/private/smithy-rpcv2-cbor/src/commands/EmptyInputOutputCommand.ts +++ b/private/smithy-rpcv2-cbor/src/commands/EmptyInputOutputCommand.ts @@ -1,8 +1,7 @@ // smithy-typescript generated code import { RpcV2ProtocolClientResolvedConfig, ServiceInputTypes, ServiceOutputTypes } from "../RpcV2ProtocolClient"; import { EmptyStructure } from "../models/models_0"; -import { de_EmptyInputOutputCommand, se_EmptyInputOutputCommand } from "../protocols/Rpcv2cbor"; -import { getSerdePlugin } from "@smithy/middleware-serde"; +import { EmptyInputOutput } from "../schemas/schemas"; import { Command as $Command } from "@smithy/smithy-client"; import { MetadataBearer as __MetadataBearer } from "@smithy/types"; @@ -49,6 +48,7 @@ export interface EmptyInputOutputCommandOutput extends EmptyStructure, __Metadat * @throws {@link RpcV2ProtocolServiceException} *

Base exception class for all service exceptions from RpcV2Protocol service.

* + * */ export class EmptyInputOutputCommand extends $Command .classBuilder< @@ -59,13 +59,12 @@ export class EmptyInputOutputCommand extends $Command ServiceOutputTypes >() .m(function (this: any, Command: any, cs: any, config: RpcV2ProtocolClientResolvedConfig, o: any) { - return [getSerdePlugin(config, this.serialize, this.deserialize)]; + return []; }) .s("RpcV2Protocol", "EmptyInputOutput", {}) .n("RpcV2ProtocolClient", "EmptyInputOutputCommand") .f(void 0, void 0) - .ser(se_EmptyInputOutputCommand) - .de(de_EmptyInputOutputCommand) + .sc(EmptyInputOutput) .build() { /** @internal type navigation helper, not in runtime. */ protected declare static __types: { diff --git a/private/smithy-rpcv2-cbor/src/commands/Float16Command.ts b/private/smithy-rpcv2-cbor/src/commands/Float16Command.ts index f2de09ee243..8787a1e4e1a 100644 --- a/private/smithy-rpcv2-cbor/src/commands/Float16Command.ts +++ b/private/smithy-rpcv2-cbor/src/commands/Float16Command.ts @@ -1,8 +1,7 @@ // smithy-typescript generated code import { RpcV2ProtocolClientResolvedConfig, ServiceInputTypes, ServiceOutputTypes } from "../RpcV2ProtocolClient"; import { Float16Output } from "../models/models_0"; -import { de_Float16Command, se_Float16Command } from "../protocols/Rpcv2cbor"; -import { getSerdePlugin } from "@smithy/middleware-serde"; +import { Float16 } from "../schemas/schemas"; import { Command as $Command } from "@smithy/smithy-client"; import { MetadataBearer as __MetadataBearer } from "@smithy/types"; @@ -51,6 +50,7 @@ export interface Float16CommandOutput extends Float16Output, __MetadataBearer {} * @throws {@link RpcV2ProtocolServiceException} *

Base exception class for all service exceptions from RpcV2Protocol service.

* + * */ export class Float16Command extends $Command .classBuilder< @@ -61,13 +61,12 @@ export class Float16Command extends $Command ServiceOutputTypes >() .m(function (this: any, Command: any, cs: any, config: RpcV2ProtocolClientResolvedConfig, o: any) { - return [getSerdePlugin(config, this.serialize, this.deserialize)]; + return []; }) .s("RpcV2Protocol", "Float16", {}) .n("RpcV2ProtocolClient", "Float16Command") .f(void 0, void 0) - .ser(se_Float16Command) - .de(de_Float16Command) + .sc(Float16) .build() { /** @internal type navigation helper, not in runtime. */ protected declare static __types: { diff --git a/private/smithy-rpcv2-cbor/src/commands/FractionalSecondsCommand.ts b/private/smithy-rpcv2-cbor/src/commands/FractionalSecondsCommand.ts index ce131c6a195..4fbae640fdd 100644 --- a/private/smithy-rpcv2-cbor/src/commands/FractionalSecondsCommand.ts +++ b/private/smithy-rpcv2-cbor/src/commands/FractionalSecondsCommand.ts @@ -1,8 +1,7 @@ // smithy-typescript generated code import { RpcV2ProtocolClientResolvedConfig, ServiceInputTypes, ServiceOutputTypes } from "../RpcV2ProtocolClient"; import { FractionalSecondsOutput } from "../models/models_0"; -import { de_FractionalSecondsCommand, se_FractionalSecondsCommand } from "../protocols/Rpcv2cbor"; -import { getSerdePlugin } from "@smithy/middleware-serde"; +import { FractionalSeconds } from "../schemas/schemas"; import { Command as $Command } from "@smithy/smithy-client"; import { MetadataBearer as __MetadataBearer } from "@smithy/types"; @@ -51,6 +50,7 @@ export interface FractionalSecondsCommandOutput extends FractionalSecondsOutput, * @throws {@link RpcV2ProtocolServiceException} *

Base exception class for all service exceptions from RpcV2Protocol service.

* + * */ export class FractionalSecondsCommand extends $Command .classBuilder< @@ -61,13 +61,12 @@ export class FractionalSecondsCommand extends $Command ServiceOutputTypes >() .m(function (this: any, Command: any, cs: any, config: RpcV2ProtocolClientResolvedConfig, o: any) { - return [getSerdePlugin(config, this.serialize, this.deserialize)]; + return []; }) .s("RpcV2Protocol", "FractionalSeconds", {}) .n("RpcV2ProtocolClient", "FractionalSecondsCommand") .f(void 0, void 0) - .ser(se_FractionalSecondsCommand) - .de(de_FractionalSecondsCommand) + .sc(FractionalSeconds) .build() { /** @internal type navigation helper, not in runtime. */ protected declare static __types: { diff --git a/private/smithy-rpcv2-cbor/src/commands/GreetingWithErrorsCommand.ts b/private/smithy-rpcv2-cbor/src/commands/GreetingWithErrorsCommand.ts index 9f374819462..7fdc7f0cbd5 100644 --- a/private/smithy-rpcv2-cbor/src/commands/GreetingWithErrorsCommand.ts +++ b/private/smithy-rpcv2-cbor/src/commands/GreetingWithErrorsCommand.ts @@ -1,8 +1,7 @@ // smithy-typescript generated code import { RpcV2ProtocolClientResolvedConfig, ServiceInputTypes, ServiceOutputTypes } from "../RpcV2ProtocolClient"; import { GreetingWithErrorsOutput } from "../models/models_0"; -import { de_GreetingWithErrorsCommand, se_GreetingWithErrorsCommand } from "../protocols/Rpcv2cbor"; -import { getSerdePlugin } from "@smithy/middleware-serde"; +import { GreetingWithErrors } from "../schemas/schemas"; import { Command as $Command } from "@smithy/smithy-client"; import { MetadataBearer as __MetadataBearer } from "@smithy/types"; @@ -63,6 +62,7 @@ export interface GreetingWithErrorsCommandOutput extends GreetingWithErrorsOutpu * @throws {@link RpcV2ProtocolServiceException} *

Base exception class for all service exceptions from RpcV2Protocol service.

* + * * @public */ export class GreetingWithErrorsCommand extends $Command @@ -74,13 +74,12 @@ export class GreetingWithErrorsCommand extends $Command ServiceOutputTypes >() .m(function (this: any, Command: any, cs: any, config: RpcV2ProtocolClientResolvedConfig, o: any) { - return [getSerdePlugin(config, this.serialize, this.deserialize)]; + return []; }) .s("RpcV2Protocol", "GreetingWithErrors", {}) .n("RpcV2ProtocolClient", "GreetingWithErrorsCommand") .f(void 0, void 0) - .ser(se_GreetingWithErrorsCommand) - .de(de_GreetingWithErrorsCommand) + .sc(GreetingWithErrors) .build() { /** @internal type navigation helper, not in runtime. */ protected declare static __types: { diff --git a/private/smithy-rpcv2-cbor/src/commands/NoInputOutputCommand.ts b/private/smithy-rpcv2-cbor/src/commands/NoInputOutputCommand.ts index 8859c9484a5..2df76ed56c6 100644 --- a/private/smithy-rpcv2-cbor/src/commands/NoInputOutputCommand.ts +++ b/private/smithy-rpcv2-cbor/src/commands/NoInputOutputCommand.ts @@ -1,7 +1,6 @@ // smithy-typescript generated code import { RpcV2ProtocolClientResolvedConfig, ServiceInputTypes, ServiceOutputTypes } from "../RpcV2ProtocolClient"; -import { de_NoInputOutputCommand, se_NoInputOutputCommand } from "../protocols/Rpcv2cbor"; -import { getSerdePlugin } from "@smithy/middleware-serde"; +import { NoInputOutput } from "../schemas/schemas"; import { Command as $Command } from "@smithy/smithy-client"; import { MetadataBearer as __MetadataBearer } from "@smithy/types"; @@ -48,6 +47,7 @@ export interface NoInputOutputCommandOutput extends __MetadataBearer {} * @throws {@link RpcV2ProtocolServiceException} *

Base exception class for all service exceptions from RpcV2Protocol service.

* + * */ export class NoInputOutputCommand extends $Command .classBuilder< @@ -58,13 +58,12 @@ export class NoInputOutputCommand extends $Command ServiceOutputTypes >() .m(function (this: any, Command: any, cs: any, config: RpcV2ProtocolClientResolvedConfig, o: any) { - return [getSerdePlugin(config, this.serialize, this.deserialize)]; + return []; }) .s("RpcV2Protocol", "NoInputOutput", {}) .n("RpcV2ProtocolClient", "NoInputOutputCommand") .f(void 0, void 0) - .ser(se_NoInputOutputCommand) - .de(de_NoInputOutputCommand) + .sc(NoInputOutput) .build() { /** @internal type navigation helper, not in runtime. */ protected declare static __types: { diff --git a/private/smithy-rpcv2-cbor/src/commands/OperationWithDefaultsCommand.ts b/private/smithy-rpcv2-cbor/src/commands/OperationWithDefaultsCommand.ts index 895a6e26fb3..9fd1dd2cf7e 100644 --- a/private/smithy-rpcv2-cbor/src/commands/OperationWithDefaultsCommand.ts +++ b/private/smithy-rpcv2-cbor/src/commands/OperationWithDefaultsCommand.ts @@ -1,8 +1,7 @@ // smithy-typescript generated code import { RpcV2ProtocolClientResolvedConfig, ServiceInputTypes, ServiceOutputTypes } from "../RpcV2ProtocolClient"; import { OperationWithDefaultsInput, OperationWithDefaultsOutput } from "../models/models_0"; -import { de_OperationWithDefaultsCommand, se_OperationWithDefaultsCommand } from "../protocols/Rpcv2cbor"; -import { getSerdePlugin } from "@smithy/middleware-serde"; +import { OperationWithDefaults } from "../schemas/schemas"; import { Command as $Command } from "@smithy/smithy-client"; import { MetadataBearer as __MetadataBearer } from "@smithy/types"; @@ -117,6 +116,7 @@ export interface OperationWithDefaultsCommandOutput extends OperationWithDefault * @throws {@link RpcV2ProtocolServiceException} *

Base exception class for all service exceptions from RpcV2Protocol service.

* + * */ export class OperationWithDefaultsCommand extends $Command .classBuilder< @@ -127,13 +127,12 @@ export class OperationWithDefaultsCommand extends $Command ServiceOutputTypes >() .m(function (this: any, Command: any, cs: any, config: RpcV2ProtocolClientResolvedConfig, o: any) { - return [getSerdePlugin(config, this.serialize, this.deserialize)]; + return []; }) .s("RpcV2Protocol", "OperationWithDefaults", {}) .n("RpcV2ProtocolClient", "OperationWithDefaultsCommand") .f(void 0, void 0) - .ser(se_OperationWithDefaultsCommand) - .de(de_OperationWithDefaultsCommand) + .sc(OperationWithDefaults) .build() { /** @internal type navigation helper, not in runtime. */ protected declare static __types: { diff --git a/private/smithy-rpcv2-cbor/src/commands/OptionalInputOutputCommand.ts b/private/smithy-rpcv2-cbor/src/commands/OptionalInputOutputCommand.ts index 10d706d5496..f8242386349 100644 --- a/private/smithy-rpcv2-cbor/src/commands/OptionalInputOutputCommand.ts +++ b/private/smithy-rpcv2-cbor/src/commands/OptionalInputOutputCommand.ts @@ -1,8 +1,7 @@ // smithy-typescript generated code import { RpcV2ProtocolClientResolvedConfig, ServiceInputTypes, ServiceOutputTypes } from "../RpcV2ProtocolClient"; import { SimpleStructure } from "../models/models_0"; -import { de_OptionalInputOutputCommand, se_OptionalInputOutputCommand } from "../protocols/Rpcv2cbor"; -import { getSerdePlugin } from "@smithy/middleware-serde"; +import { OptionalInputOutput } from "../schemas/schemas"; import { Command as $Command } from "@smithy/smithy-client"; import { MetadataBearer as __MetadataBearer } from "@smithy/types"; @@ -53,6 +52,7 @@ export interface OptionalInputOutputCommandOutput extends SimpleStructure, __Met * @throws {@link RpcV2ProtocolServiceException} *

Base exception class for all service exceptions from RpcV2Protocol service.

* + * */ export class OptionalInputOutputCommand extends $Command .classBuilder< @@ -63,13 +63,12 @@ export class OptionalInputOutputCommand extends $Command ServiceOutputTypes >() .m(function (this: any, Command: any, cs: any, config: RpcV2ProtocolClientResolvedConfig, o: any) { - return [getSerdePlugin(config, this.serialize, this.deserialize)]; + return []; }) .s("RpcV2Protocol", "OptionalInputOutput", {}) .n("RpcV2ProtocolClient", "OptionalInputOutputCommand") .f(void 0, void 0) - .ser(se_OptionalInputOutputCommand) - .de(de_OptionalInputOutputCommand) + .sc(OptionalInputOutput) .build() { /** @internal type navigation helper, not in runtime. */ protected declare static __types: { diff --git a/private/smithy-rpcv2-cbor/src/commands/RecursiveShapesCommand.ts b/private/smithy-rpcv2-cbor/src/commands/RecursiveShapesCommand.ts index c6cbf640477..e2559587ca2 100644 --- a/private/smithy-rpcv2-cbor/src/commands/RecursiveShapesCommand.ts +++ b/private/smithy-rpcv2-cbor/src/commands/RecursiveShapesCommand.ts @@ -1,8 +1,7 @@ // smithy-typescript generated code import { RpcV2ProtocolClientResolvedConfig, ServiceInputTypes, ServiceOutputTypes } from "../RpcV2ProtocolClient"; import { RecursiveShapesInputOutput } from "../models/models_0"; -import { de_RecursiveShapesCommand, se_RecursiveShapesCommand } from "../protocols/Rpcv2cbor"; -import { getSerdePlugin } from "@smithy/middleware-serde"; +import { RecursiveShapes } from "../schemas/schemas"; import { Command as $Command } from "@smithy/smithy-client"; import { MetadataBearer as __MetadataBearer } from "@smithy/types"; @@ -77,6 +76,7 @@ export interface RecursiveShapesCommandOutput extends RecursiveShapesInputOutput * @throws {@link RpcV2ProtocolServiceException} *

Base exception class for all service exceptions from RpcV2Protocol service.

* + * */ export class RecursiveShapesCommand extends $Command .classBuilder< @@ -87,13 +87,12 @@ export class RecursiveShapesCommand extends $Command ServiceOutputTypes >() .m(function (this: any, Command: any, cs: any, config: RpcV2ProtocolClientResolvedConfig, o: any) { - return [getSerdePlugin(config, this.serialize, this.deserialize)]; + return []; }) .s("RpcV2Protocol", "RecursiveShapes", {}) .n("RpcV2ProtocolClient", "RecursiveShapesCommand") .f(void 0, void 0) - .ser(se_RecursiveShapesCommand) - .de(de_RecursiveShapesCommand) + .sc(RecursiveShapes) .build() { /** @internal type navigation helper, not in runtime. */ protected declare static __types: { diff --git a/private/smithy-rpcv2-cbor/src/commands/RpcV2CborDenseMapsCommand.ts b/private/smithy-rpcv2-cbor/src/commands/RpcV2CborDenseMapsCommand.ts index 733838315cf..e7818c8e0e5 100644 --- a/private/smithy-rpcv2-cbor/src/commands/RpcV2CborDenseMapsCommand.ts +++ b/private/smithy-rpcv2-cbor/src/commands/RpcV2CborDenseMapsCommand.ts @@ -1,8 +1,7 @@ // smithy-typescript generated code import { RpcV2ProtocolClientResolvedConfig, ServiceInputTypes, ServiceOutputTypes } from "../RpcV2ProtocolClient"; import { RpcV2CborDenseMapsInputOutput } from "../models/models_0"; -import { de_RpcV2CborDenseMapsCommand, se_RpcV2CborDenseMapsCommand } from "../protocols/Rpcv2cbor"; -import { getSerdePlugin } from "@smithy/middleware-serde"; +import { RpcV2CborDenseMaps } from "../schemas/schemas"; import { Command as $Command } from "@smithy/smithy-client"; import { MetadataBearer as __MetadataBearer } from "@smithy/types"; @@ -93,6 +92,7 @@ export interface RpcV2CborDenseMapsCommandOutput extends RpcV2CborDenseMapsInput * @throws {@link RpcV2ProtocolServiceException} *

Base exception class for all service exceptions from RpcV2Protocol service.

* + * * @public */ export class RpcV2CborDenseMapsCommand extends $Command @@ -104,13 +104,12 @@ export class RpcV2CborDenseMapsCommand extends $Command ServiceOutputTypes >() .m(function (this: any, Command: any, cs: any, config: RpcV2ProtocolClientResolvedConfig, o: any) { - return [getSerdePlugin(config, this.serialize, this.deserialize)]; + return []; }) .s("RpcV2Protocol", "RpcV2CborDenseMaps", {}) .n("RpcV2ProtocolClient", "RpcV2CborDenseMapsCommand") .f(void 0, void 0) - .ser(se_RpcV2CborDenseMapsCommand) - .de(de_RpcV2CborDenseMapsCommand) + .sc(RpcV2CborDenseMaps) .build() { /** @internal type navigation helper, not in runtime. */ protected declare static __types: { diff --git a/private/smithy-rpcv2-cbor/src/commands/RpcV2CborListsCommand.ts b/private/smithy-rpcv2-cbor/src/commands/RpcV2CborListsCommand.ts index 2d36006907e..3594df6b6b1 100644 --- a/private/smithy-rpcv2-cbor/src/commands/RpcV2CborListsCommand.ts +++ b/private/smithy-rpcv2-cbor/src/commands/RpcV2CborListsCommand.ts @@ -1,8 +1,7 @@ // smithy-typescript generated code import { RpcV2ProtocolClientResolvedConfig, ServiceInputTypes, ServiceOutputTypes } from "../RpcV2ProtocolClient"; import { RpcV2CborListInputOutput } from "../models/models_0"; -import { de_RpcV2CborListsCommand, se_RpcV2CborListsCommand } from "../protocols/Rpcv2cbor"; -import { getSerdePlugin } from "@smithy/middleware-serde"; +import { RpcV2CborLists } from "../schemas/schemas"; import { Command as $Command } from "@smithy/smithy-client"; import { MetadataBearer as __MetadataBearer } from "@smithy/types"; @@ -131,6 +130,7 @@ export interface RpcV2CborListsCommandOutput extends RpcV2CborListInputOutput, _ * @throws {@link RpcV2ProtocolServiceException} *

Base exception class for all service exceptions from RpcV2Protocol service.

* + * * @public */ export class RpcV2CborListsCommand extends $Command @@ -142,13 +142,12 @@ export class RpcV2CborListsCommand extends $Command ServiceOutputTypes >() .m(function (this: any, Command: any, cs: any, config: RpcV2ProtocolClientResolvedConfig, o: any) { - return [getSerdePlugin(config, this.serialize, this.deserialize)]; + return []; }) .s("RpcV2Protocol", "RpcV2CborLists", {}) .n("RpcV2ProtocolClient", "RpcV2CborListsCommand") .f(void 0, void 0) - .ser(se_RpcV2CborListsCommand) - .de(de_RpcV2CborListsCommand) + .sc(RpcV2CborLists) .build() { /** @internal type navigation helper, not in runtime. */ protected declare static __types: { diff --git a/private/smithy-rpcv2-cbor/src/commands/RpcV2CborSparseMapsCommand.ts b/private/smithy-rpcv2-cbor/src/commands/RpcV2CborSparseMapsCommand.ts index a70d44e374a..9fbd2e3a5fb 100644 --- a/private/smithy-rpcv2-cbor/src/commands/RpcV2CborSparseMapsCommand.ts +++ b/private/smithy-rpcv2-cbor/src/commands/RpcV2CborSparseMapsCommand.ts @@ -1,8 +1,7 @@ // smithy-typescript generated code import { RpcV2ProtocolClientResolvedConfig, ServiceInputTypes, ServiceOutputTypes } from "../RpcV2ProtocolClient"; import { RpcV2CborSparseMapsInputOutput } from "../models/models_0"; -import { de_RpcV2CborSparseMapsCommand, se_RpcV2CborSparseMapsCommand } from "../protocols/Rpcv2cbor"; -import { getSerdePlugin } from "@smithy/middleware-serde"; +import { RpcV2CborSparseMaps } from "../schemas/schemas"; import { Command as $Command } from "@smithy/smithy-client"; import { MetadataBearer as __MetadataBearer } from "@smithy/types"; @@ -94,6 +93,7 @@ export interface RpcV2CborSparseMapsCommandOutput extends RpcV2CborSparseMapsInp * @throws {@link RpcV2ProtocolServiceException} *

Base exception class for all service exceptions from RpcV2Protocol service.

* + * */ export class RpcV2CborSparseMapsCommand extends $Command .classBuilder< @@ -104,13 +104,12 @@ export class RpcV2CborSparseMapsCommand extends $Command ServiceOutputTypes >() .m(function (this: any, Command: any, cs: any, config: RpcV2ProtocolClientResolvedConfig, o: any) { - return [getSerdePlugin(config, this.serialize, this.deserialize)]; + return []; }) .s("RpcV2Protocol", "RpcV2CborSparseMaps", {}) .n("RpcV2ProtocolClient", "RpcV2CborSparseMapsCommand") .f(void 0, void 0) - .ser(se_RpcV2CborSparseMapsCommand) - .de(de_RpcV2CborSparseMapsCommand) + .sc(RpcV2CborSparseMaps) .build() { /** @internal type navigation helper, not in runtime. */ protected declare static __types: { diff --git a/private/smithy-rpcv2-cbor/src/commands/SimpleScalarPropertiesCommand.ts b/private/smithy-rpcv2-cbor/src/commands/SimpleScalarPropertiesCommand.ts index e8517f1a394..1460cf91997 100644 --- a/private/smithy-rpcv2-cbor/src/commands/SimpleScalarPropertiesCommand.ts +++ b/private/smithy-rpcv2-cbor/src/commands/SimpleScalarPropertiesCommand.ts @@ -1,8 +1,7 @@ // smithy-typescript generated code import { RpcV2ProtocolClientResolvedConfig, ServiceInputTypes, ServiceOutputTypes } from "../RpcV2ProtocolClient"; import { SimpleScalarStructure } from "../models/models_0"; -import { de_SimpleScalarPropertiesCommand, se_SimpleScalarPropertiesCommand } from "../protocols/Rpcv2cbor"; -import { getSerdePlugin } from "@smithy/middleware-serde"; +import { SimpleScalarProperties } from "../schemas/schemas"; import { Command as $Command } from "@smithy/smithy-client"; import { MetadataBearer as __MetadataBearer } from "@smithy/types"; @@ -71,6 +70,7 @@ export interface SimpleScalarPropertiesCommandOutput extends SimpleScalarStructu * @throws {@link RpcV2ProtocolServiceException} *

Base exception class for all service exceptions from RpcV2Protocol service.

* + * */ export class SimpleScalarPropertiesCommand extends $Command .classBuilder< @@ -81,13 +81,12 @@ export class SimpleScalarPropertiesCommand extends $Command ServiceOutputTypes >() .m(function (this: any, Command: any, cs: any, config: RpcV2ProtocolClientResolvedConfig, o: any) { - return [getSerdePlugin(config, this.serialize, this.deserialize)]; + return []; }) .s("RpcV2Protocol", "SimpleScalarProperties", {}) .n("RpcV2ProtocolClient", "SimpleScalarPropertiesCommand") .f(void 0, void 0) - .ser(se_SimpleScalarPropertiesCommand) - .de(de_SimpleScalarPropertiesCommand) + .sc(SimpleScalarProperties) .build() { /** @internal type navigation helper, not in runtime. */ protected declare static __types: { diff --git a/private/smithy-rpcv2-cbor/src/commands/SparseNullsOperationCommand.ts b/private/smithy-rpcv2-cbor/src/commands/SparseNullsOperationCommand.ts index bf07bfade30..c91c9ce8553 100644 --- a/private/smithy-rpcv2-cbor/src/commands/SparseNullsOperationCommand.ts +++ b/private/smithy-rpcv2-cbor/src/commands/SparseNullsOperationCommand.ts @@ -1,8 +1,7 @@ // smithy-typescript generated code import { RpcV2ProtocolClientResolvedConfig, ServiceInputTypes, ServiceOutputTypes } from "../RpcV2ProtocolClient"; import { SparseNullsOperationInputOutput } from "../models/models_0"; -import { de_SparseNullsOperationCommand, se_SparseNullsOperationCommand } from "../protocols/Rpcv2cbor"; -import { getSerdePlugin } from "@smithy/middleware-serde"; +import { SparseNullsOperation } from "../schemas/schemas"; import { Command as $Command } from "@smithy/smithy-client"; import { MetadataBearer as __MetadataBearer } from "@smithy/types"; @@ -63,6 +62,7 @@ export interface SparseNullsOperationCommandOutput extends SparseNullsOperationI * @throws {@link RpcV2ProtocolServiceException} *

Base exception class for all service exceptions from RpcV2Protocol service.

* + * */ export class SparseNullsOperationCommand extends $Command .classBuilder< @@ -73,13 +73,12 @@ export class SparseNullsOperationCommand extends $Command ServiceOutputTypes >() .m(function (this: any, Command: any, cs: any, config: RpcV2ProtocolClientResolvedConfig, o: any) { - return [getSerdePlugin(config, this.serialize, this.deserialize)]; + return []; }) .s("RpcV2Protocol", "SparseNullsOperation", {}) .n("RpcV2ProtocolClient", "SparseNullsOperationCommand") .f(void 0, void 0) - .ser(se_SparseNullsOperationCommand) - .de(de_SparseNullsOperationCommand) + .sc(SparseNullsOperation) .build() { /** @internal type navigation helper, not in runtime. */ protected declare static __types: { diff --git a/private/smithy-rpcv2-cbor/src/runtimeConfig.shared.ts b/private/smithy-rpcv2-cbor/src/runtimeConfig.shared.ts index b9a066d3f36..0f4e730fe1c 100644 --- a/private/smithy-rpcv2-cbor/src/runtimeConfig.shared.ts +++ b/private/smithy-rpcv2-cbor/src/runtimeConfig.shared.ts @@ -1,6 +1,7 @@ // smithy-typescript generated code import { defaultRpcV2ProtocolHttpAuthSchemeProvider } from "./auth/httpAuthSchemeProvider"; import { NoAuthSigner } from "@smithy/core"; +import { SmithyRpcV2CborProtocol } from "@smithy/core/cbor"; import { NoOpLogger } from "@smithy/smithy-client"; import { IdentityProviderConfig } from "@smithy/types"; import { parseUrl } from "@smithy/url-parser"; @@ -28,6 +29,7 @@ export const getRuntimeConfig = (config: RpcV2ProtocolClientConfig) => { }, ], logger: config?.logger ?? new NoOpLogger(), + protocol: config?.protocol ?? new SmithyRpcV2CborProtocol({ defaultNamespace: "smithy.protocoltests.rpcv2Cbor" }), urlParser: config?.urlParser ?? parseUrl, utf8Decoder: config?.utf8Decoder ?? fromUtf8, utf8Encoder: config?.utf8Encoder ?? toUtf8, diff --git a/private/smithy-rpcv2-cbor/src/schemas/schemas.ts b/private/smithy-rpcv2-cbor/src/schemas/schemas.ts new file mode 100644 index 00000000000..1fb1ca853d3 --- /dev/null +++ b/private/smithy-rpcv2-cbor/src/schemas/schemas.ts @@ -0,0 +1,526 @@ +const _B = "Byte"; +const _BL = "BooleanList"; +const _BLl = "BlobList"; +const _Bl = "Blob"; +const _Bo = "Boolean"; +const _CE = "ComplexError"; +const _CNED = "ComplexNestedErrorData"; +const _COD = "ClientOptionalDefaults"; +const _D = "Double"; +const _DBM = "DenseBooleanMap"; +const _DNM = "DenseNumberMap"; +const _DSM = "DenseStringMap"; +const _DSMe = "DenseSetMap"; +const _DSMen = "DenseStructMap"; +const _DT = "DateTime"; +const _De = "Defaults"; +const _EIO = "EmptyInputOutput"; +const _ES = "EmptyStructure"; +const _F = "Float"; +const _FE = "FooEnum"; +const _FEL = "FooEnumList"; +const _FO = "Float16Output"; +const _FS = "FractionalSeconds"; +const _FSO = "FractionalSecondsOutput"; +const _Fl = "Float16"; +const _Fo = "Foo"; +const _GS = "GreetingStruct"; +const _GWE = "GreetingWithErrors"; +const _GWEO = "GreetingWithErrorsOutput"; +const _I = "Integer"; +const _IE = "IntegerEnum"; +const _IEL = "IntegerEnumList"; +const _IG = "InvalidGreeting"; +const _IL = "IntegerList"; +const _L = "Long"; +const _M = "Message"; +const _N = "Nested"; +const _NIO = "NoInputOutput"; +const _NSL = "NestedStringList"; +const _OIO = "OptionalInputOutput"; +const _OWD = "OperationWithDefaults"; +const _OWDI = "OperationWithDefaultsInput"; +const _OWDO = "OperationWithDefaultsOutput"; +const _RS = "RecursiveShapes"; +const _RSIO = "RecursiveShapesInputOutput"; +const _RSION = "RecursiveShapesInputOutputNested1"; +const _RSIONe = "RecursiveShapesInputOutputNested2"; +const _RVCDM = "RpcV2CborDenseMaps"; +const _RVCDMIO = "RpcV2CborDenseMapsInputOutput"; +const _RVCL = "RpcV2CborLists"; +const _RVCLIO = "RpcV2CborListInputOutput"; +const _RVCSM = "RpcV2CborSparseMaps"; +const _RVCSMIO = "RpcV2CborSparseMapsInputOutput"; +const _S = "String"; +const _SBM = "SparseBooleanMap"; +const _SL = "StringList"; +const _SLM = "StructureListMember"; +const _SLt = "StructureList"; +const _SNM = "SparseNumberMap"; +const _SNO = "SparseNullsOperation"; +const _SNOIO = "SparseNullsOperationInputOutput"; +const _SS = "StringSet"; +const _SSL = "SparseStringList"; +const _SSM = "SparseStringMap"; +const _SSMp = "SparseSetMap"; +const _SSMpa = "SparseStructMap"; +const _SSP = "SimpleScalarProperties"; +const _SSS = "SimpleScalarStructure"; +const _SSi = "SimpleStructure"; +const _Sh = "Short"; +const _T = "Timestamp"; +const _TE = "TestEnum"; +const _TIE = "TestIntEnum"; +const _TL = "TimestampList"; +const _TLo = "TopLevel"; +const _TSL = "TestStringList"; +const _TSM = "TestStringMap"; +const _VE = "ValidationException"; +const _VEF = "ValidationExceptionField"; +const _VEFL = "ValidationExceptionFieldList"; +const _a = "a"; +const _b = "bar"; +const _bL = "booleanList"; +const _bLl = "blobList"; +const _bV = "byteValue"; +const _bVl = "blobValue"; +const _b_ = "b"; +const _c = "client"; +const _cOD = "clientOptionalDefaults"; +const _d = "datetime"; +const _dB = "defaultBoolean"; +const _dBM = "denseBooleanMap"; +const _dBe = "defaultBlob"; +const _dBef = "defaultByte"; +const _dD = "defaultDouble"; +const _dE = "defaultEnum"; +const _dF = "defaultFloat"; +const _dI = "defaultInteger"; +const _dIE = "defaultIntEnum"; +const _dL = "defaultList"; +const _dLe = "defaultLong"; +const _dM = "defaultMap"; +const _dNM = "denseNumberMap"; +const _dS = "defaultString"; +const _dSM = "denseStructMap"; +const _dSMe = "denseStringMap"; +const _dSMen = "denseSetMap"; +const _dSe = "defaultShort"; +const _dT = "defaultTimestamp"; +const _dV = "doubleValue"; +const _de = "defaults"; +const _e = "error"; +const _eB = "emptyBlob"; +const _eL = "enumList"; +const _eS = "emptyString"; +const _f = "foo"; +const _fB = "falseBoolean"; +const _fBV = "falseBooleanValue"; +const _fL = "fieldList"; +const _fV = "floatValue"; +const _g = "greeting"; +const _h = "hi"; +const _iEL = "intEnumList"; +const _iL = "integerList"; +const _iV = "integerValue"; +const _lV = "longValue"; +const _m = "message"; +const _me = "member"; +const _n = "nested"; +const _nSL = "nestedStringList"; +const _oTLD = "otherTopLevelDefault"; +const _p = "path"; +const _rM = "recursiveMember"; +const _s = "sparse"; +const _sBM = "sparseBooleanMap"; +const _sL = "stringList"; +const _sLt = "structureList"; +const _sNM = "sparseNumberMap"; +const _sS = "stringSet"; +const _sSL = "sparseStringList"; +const _sSM = "sparseStructMap"; +const _sSMp = "sparseStringMap"; +const _sSMpa = "sparseSetMap"; +const _sV = "shortValue"; +const _sVt = "stringValue"; +const _tBV = "trueBooleanValue"; +const _tL = "timestampList"; +const _tLD = "topLevelDefault"; +const _v = "value"; +const _zB = "zeroByte"; +const _zD = "zeroDouble"; +const _zF = "zeroFloat"; +const _zI = "zeroInteger"; +const _zL = "zeroLong"; +const _zS = "zeroShort"; +const n0 = "smithy.framework"; +const n1 = "smithy.protocoltests.rpcv2Cbor"; +const n2 = "smithy.protocoltests.shared"; + +// smithy-typescript generated code +import { RpcV2ProtocolServiceException as __RpcV2ProtocolServiceException } from "../models/RpcV2ProtocolServiceException"; +import { + ComplexError as __ComplexError, + InvalidGreeting as __InvalidGreeting, + ValidationException as __ValidationException, +} from "../models/index"; +import { error, list, map, op, struct } from "@smithy/core/schema"; + +/* eslint no-var: 0 */ + +export var Unit = "unit" as const; + +export var ValidationException = error( + n0, + _VE, + { + [_e]: _c, + }, + [_m, _fL], + [0, () => ValidationExceptionFieldList], + + __ValidationException +); +export var ValidationExceptionField = struct(n0, _VEF, 0, [_p, _m], [0, 0]); +export var ClientOptionalDefaults = struct(n1, _COD, 0, [_me], [1]); +export var ComplexError = error( + n1, + _CE, + { + [_e]: _c, + }, + [_TLo, _N], + [0, () => ComplexNestedErrorData], + + __ComplexError +); +export var ComplexNestedErrorData = struct(n1, _CNED, 0, [_Fo], [0]); +export var Defaults = struct( + n1, + _De, + 0, + [ + _dS, + _dB, + _dL, + _dT, + _dBe, + _dBef, + _dSe, + _dI, + _dLe, + _dF, + _dD, + _dM, + _dE, + _dIE, + _eS, + _fB, + _eB, + _zB, + _zS, + _zI, + _zL, + _zF, + _zD, + ], + [0, 2, 64 | 0, 4, 21, 1, 1, 1, 1, 1, 1, 128 | 0, 0, 1, 0, 2, 21, 1, 1, 1, 1, 1, 1] +); +export var EmptyStructure = struct(n1, _ES, 0, [], []); +export var Float16Output = struct(n1, _FO, 0, [_v], [1]); +export var FractionalSecondsOutput = struct(n1, _FSO, 0, [_d], [5]); +export var GreetingWithErrorsOutput = struct(n1, _GWEO, 0, [_g], [0]); +export var InvalidGreeting = error( + n1, + _IG, + { + [_e]: _c, + }, + [_M], + [0], + + __InvalidGreeting +); +export var OperationWithDefaultsInput = struct( + n1, + _OWDI, + 0, + [_de, _cOD, _tLD, _oTLD], + [() => Defaults, () => ClientOptionalDefaults, 0, 1] +); +export var OperationWithDefaultsOutput = struct( + n1, + _OWDO, + 0, + [ + _dS, + _dB, + _dL, + _dT, + _dBe, + _dBef, + _dSe, + _dI, + _dLe, + _dF, + _dD, + _dM, + _dE, + _dIE, + _eS, + _fB, + _eB, + _zB, + _zS, + _zI, + _zL, + _zF, + _zD, + ], + [0, 2, 64 | 0, 4, 21, 1, 1, 1, 1, 1, 1, 128 | 0, 0, 1, 0, 2, 21, 1, 1, 1, 1, 1, 1] +); +export var RecursiveShapesInputOutput = struct(n1, _RSIO, 0, [_n], [() => RecursiveShapesInputOutputNested1]); +export var RecursiveShapesInputOutputNested1 = struct( + n1, + _RSION, + 0, + [_f, _n], + [0, () => RecursiveShapesInputOutputNested2] +); +export var RecursiveShapesInputOutputNested2 = struct( + n1, + _RSIONe, + 0, + [_b, _rM], + [0, () => RecursiveShapesInputOutputNested1] +); +export var RpcV2CborDenseMapsInputOutput = struct( + n1, + _RVCDMIO, + 0, + [_dSM, _dNM, _dBM, _dSMe, _dSMen], + [() => DenseStructMap, 128 | 1, 128 | 2, 128 | 0, map(n1, _DSMe, 0, 0, 64 | 0)] +); +export var RpcV2CborListInputOutput = struct( + n1, + _RVCLIO, + 0, + [_sL, _sS, _iL, _bL, _tL, _eL, _iEL, _nSL, _sLt, _bLl], + [64 | 0, 64 | 0, 64 | 1, 64 | 2, 64 | 4, 64 | 0, 64 | 1, list(n2, _NSL, 0, 64 | 0), () => StructureList, 64 | 21] +); +export var RpcV2CborSparseMapsInputOutput = struct( + n1, + _RVCSMIO, + 0, + [_sSM, _sNM, _sBM, _sSMp, _sSMpa], + [ + [() => SparseStructMap, 0], + [() => SparseNumberMap, 0], + [() => SparseBooleanMap, 0], + [() => SparseStringMap, 0], + [() => SparseSetMap, 0], + ] +); +export var SimpleScalarStructure = struct( + n1, + _SSS, + 0, + [_tBV, _fBV, _bV, _dV, _fV, _iV, _lV, _sV, _sVt, _bVl], + [2, 2, 1, 1, 1, 1, 1, 1, 0, 21] +); +export var SimpleStructure = struct(n1, _SSi, 0, [_v], [0]); +export var SparseNullsOperationInputOutput = struct( + n1, + _SNOIO, + 0, + [_sSL, _sSMp], + [ + [() => SparseStringList, 0], + [() => SparseStringMap, 0], + ] +); +export var StructureListMember = struct(n1, _SLM, 0, [_a, _b_], [0, 0]); +export var GreetingStruct = struct(n2, _GS, 0, [_h], [0]); +export var RpcV2ProtocolServiceException = error( + "awssdkjs.synthetic.smithy.protocoltests.rpcv2Cbor", + "RpcV2ProtocolServiceException", + 0, + [], + [], + __RpcV2ProtocolServiceException +); +export var ValidationExceptionFieldList = list(n0, _VEFL, 0, () => ValidationExceptionField); +export var StructureList = list(n1, _SLt, 0, () => StructureListMember); +export var TestStringList = 64 | 0; + +export var BlobList = 64 | 21; + +export var BooleanList = 64 | 2; + +export var FooEnumList = 64 | 0; + +export var IntegerEnumList = 64 | 1; + +export var IntegerList = 64 | 1; + +export var NestedStringList = list(n2, _NSL, 0, 64 | 0); +export var SparseStringList = list( + n2, + _SSL, + { + [_s]: 1, + }, + 0 +); +export var StringList = 64 | 0; + +export var StringSet = 64 | 0; + +export var TimestampList = 64 | 4; + +export var DenseBooleanMap = 128 | 2; + +export var DenseNumberMap = 128 | 1; + +export var DenseSetMap = map(n1, _DSMe, 0, 0, 64 | 0); +export var DenseStringMap = 128 | 0; + +export var DenseStructMap = map(n1, _DSMen, 0, 0, () => GreetingStruct); +export var SparseBooleanMap = map( + n1, + _SBM, + { + [_s]: 1, + }, + 0, + 2 +); +export var SparseNumberMap = map( + n1, + _SNM, + { + [_s]: 1, + }, + 0, + 1 +); +export var SparseSetMap = map( + n1, + _SSMp, + { + [_s]: 1, + }, + 0, + 64 | 0 +); +export var SparseStructMap = map( + n1, + _SSMpa, + { + [_s]: 1, + }, + 0, + () => GreetingStruct +); +export var TestStringMap = 128 | 0; + +export var SparseStringMap = map( + n2, + _SSM, + { + [_s]: 1, + }, + 0, + 0 +); +export var EmptyInputOutput = op( + n1, + _EIO, + 0, + () => EmptyStructure, + () => EmptyStructure +); +export var Float16 = op( + n1, + _Fl, + 0, + () => Unit, + () => Float16Output +); +export var FractionalSeconds = op( + n1, + _FS, + 0, + () => Unit, + () => FractionalSecondsOutput +); +export var GreetingWithErrors = op( + n1, + _GWE, + 2, + () => Unit, + () => GreetingWithErrorsOutput +); +export var NoInputOutput = op( + n1, + _NIO, + 0, + () => Unit, + () => Unit +); +export var OperationWithDefaults = op( + n1, + _OWD, + 0, + () => OperationWithDefaultsInput, + () => OperationWithDefaultsOutput +); +export var OptionalInputOutput = op( + n1, + _OIO, + 0, + () => SimpleStructure, + () => SimpleStructure +); +export var RecursiveShapes = op( + n1, + _RS, + 0, + () => RecursiveShapesInputOutput, + () => RecursiveShapesInputOutput +); +export var RpcV2CborDenseMaps = op( + n1, + _RVCDM, + 0, + () => RpcV2CborDenseMapsInputOutput, + () => RpcV2CborDenseMapsInputOutput +); +export var RpcV2CborLists = op( + n1, + _RVCL, + 2, + () => RpcV2CborListInputOutput, + () => RpcV2CborListInputOutput +); +export var RpcV2CborSparseMaps = op( + n1, + _RVCSM, + 0, + () => RpcV2CborSparseMapsInputOutput, + () => RpcV2CborSparseMapsInputOutput +); +export var SimpleScalarProperties = op( + n1, + _SSP, + 0, + () => SimpleScalarStructure, + () => SimpleScalarStructure +); +export var SparseNullsOperation = op( + n1, + _SNO, + 0, + () => SparseNullsOperationInputOutput, + () => SparseNullsOperationInputOutput +); diff --git a/private/smithy-rpcv2-cbor/test/functional/rpcv2cbor.spec.ts b/private/smithy-rpcv2-cbor/test/functional/rpcv2cbor.spec.ts index 937bb9928a6..c04743e4aba 100644 --- a/private/smithy-rpcv2-cbor/test/functional/rpcv2cbor.spec.ts +++ b/private/smithy-rpcv2-cbor/test/functional/rpcv2cbor.spec.ts @@ -829,7 +829,7 @@ it.skip("RpcV2CborClientPopulatesDefaultValuesInInput:Request", async () => { expect(r.headers["smithy-protocol"]).toBe("rpc-v2-cbor"); expect(r.body).toBeDefined(); - const bodyString = `v2hkZWZhdWx0c79tZGVmYXVsdFN0cmluZ2JoaW5kZWZhdWx0Qm9vbGVhbvVrZGVmYXVsdExpc3Sf/3BkZWZhdWx0VGltZXN0YW1wwQBrZGVmYXVsdEJsb2JDYWJja2RlZmF1bHRCeXRlAWxkZWZhdWx0U2hvcnQBbmRlZmF1bHRJbnRlZ2VyCmtkZWZhdWx0TG9uZxhkbGRlZmF1bHRGbG9hdPo/gAAAbWRlZmF1bHREb3VibGX6P4AAAGpkZWZhdWx0TWFwv/9rZGVmYXVsdEVudW1jRk9PbmRlZmF1bHRJbnRFbnVtAWtlbXB0eVN0cmluZ2BsZmFsc2VCb29sZWFu9GllbXB0eUJsb2JAaHplcm9CeXRlAGl6ZXJvU2hvcnQAa3plcm9JbnRlZ2VyAGh6ZXJvTG9uZwBpemVyb0Zsb2F0+gAAAABqemVyb0RvdWJsZfoAAAAA//8`; + const bodyString = `v2hkZWZhdWx0c79tZGVmYXVsdFN0cmluZ2JoaW5kZWZhdWx0Qm9vbGVhbvVrZGVmYXVsdExpc3Sf/3BkZWZhdWx0VGltZXN0YW1wwQBrZGVmYXVsdEJsb2JDYWJja2RlZmF1bHRCeXRlAWxkZWZhdWx0U2hvcnQBbmRlZmF1bHRJbnRlZ2VyCmtkZWZhdWx0TG9uZxhkbGRlZmF1bHRGbG9hdPo/gAAAbWRlZmF1bHREb3VibGX6P4AAAGpkZWZhdWx0TWFwv/9rZGVmYXVsdEVudW1jRk9PbmRlZmF1bHRJbnRFbnVtAWtlbXB0eVN0cmluZ2BsZmFsc2VCb29sZWFu9GllbXB0eUJsb2JAaHplcm9CeXRlAGl6ZXJvU2hvcnQAa3plcm9JbnRlZ2VyAGh6ZXJvTG9uZwBpemVyb0Zsb2F0+gAAAABqemVyb0RvdWJsZfoAAAAA//8=`; const unequalParts: any = compareEquivalentCborBodies(bodyString, r.body); expect(unequalParts).toBeUndefined(); } diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/CommandGenerator.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/CommandGenerator.java index 538b39d58e5..2ac73c26bd1 100644 --- a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/CommandGenerator.java +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/CommandGenerator.java @@ -27,6 +27,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.TreeSet; @@ -34,6 +35,8 @@ import java.util.function.Function; import java.util.stream.Collectors; import software.amazon.smithy.build.FileManifest; +import software.amazon.smithy.codegen.core.ReservedWords; +import software.amazon.smithy.codegen.core.ReservedWordsBuilder; import software.amazon.smithy.codegen.core.Symbol; import software.amazon.smithy.codegen.core.SymbolProvider; import software.amazon.smithy.model.Model; @@ -58,6 +61,7 @@ import software.amazon.smithy.typescript.codegen.endpointsV2.RuleSetParameterFinder; import software.amazon.smithy.typescript.codegen.integration.ProtocolGenerator; import software.amazon.smithy.typescript.codegen.integration.RuntimeClientPlugin; +import software.amazon.smithy.typescript.codegen.schema.SchemaGenerationAllowlist; import software.amazon.smithy.typescript.codegen.sections.CommandBodyExtraCodeSection; import software.amazon.smithy.typescript.codegen.sections.CommandConstructorCodeSection; import software.amazon.smithy.typescript.codegen.sections.CommandPropertiesCodeSection; @@ -75,6 +79,7 @@ final class CommandGenerator implements Runnable { static final String COMMANDS_FOLDER = "commands"; + static final String SCHEMAS_FOLDER = "schemas"; private final TypeScriptSettings settings; private final Model model; @@ -90,6 +95,9 @@ final class CommandGenerator implements Runnable { private final ProtocolGenerator protocolGenerator; private final ApplicationProtocol applicationProtocol; private final SensitiveDataFinder sensitiveDataFinder; + private final ReservedWords reservedWords = new ReservedWordsBuilder() + .loadWords(Objects.requireNonNull(TypeScriptClientCodegenPlugin.class.getResource("reserved-words.txt"))) + .build(); CommandGenerator( TypeScriptSettings settings, @@ -493,7 +501,10 @@ private void generateCommandMiddlewareResolver(String configType) { ); { // Add serialization and deserialization plugin. - writer.write("$T(config, this.serialize, this.deserialize),", serde); + if (!SchemaGenerationAllowlist.contains(service.getId())) { + writer.write("$T(config, this.serialize, this.deserialize),", serde); + } + // EndpointsV2 if (service.hasTrait(EndpointRuleSetTrait.class)) { writer.addImport( @@ -661,10 +672,25 @@ private void addCommandSpecificPlugins() { } } + private void writeSchemaSerde() { + String operationSchema = reservedWords.escape(operation.getId().getName()); + writer.addRelativeImport(operationSchema, null, Paths.get( + ".", CodegenUtils.SOURCE_FOLDER, SCHEMAS_FOLDER, "schemas" + )); + writer.write(""" + .sc($L)""", + operationSchema + ); + } + private void writeSerde() { - writer - .write(".ser($L)", getSerdeDispatcher(true)) - .write(".de($L)", getSerdeDispatcher(false)); + if (SchemaGenerationAllowlist.contains(service.getId())) { + writeSchemaSerde(); + } else { + writer + .write(".ser($L)", getSerdeDispatcher(true)) + .write(".de($L)", getSerdeDispatcher(false)); + } } private String getSerdeDispatcher(boolean isInput) { @@ -672,11 +698,11 @@ private String getSerdeDispatcher(boolean isInput) { return "() => { throw new Error(\"No supported protocol was found\"); }"; } else { String serdeFunctionName = isInput - ? ProtocolGenerator.getSerFunctionShortName(symbol) - : ProtocolGenerator.getDeserFunctionShortName(symbol); + ? ProtocolGenerator.getSerFunctionShortName(symbol) + : ProtocolGenerator.getDeserFunctionShortName(symbol); writer.addRelativeImport(serdeFunctionName, null, - Paths.get(".", CodegenUtils.SOURCE_FOLDER, ProtocolGenerator.PROTOCOLS_FOLDER, - ProtocolGenerator.getSanitizedName(protocolGenerator.getName()))); + Paths.get(".", CodegenUtils.SOURCE_FOLDER, ProtocolGenerator.PROTOCOLS_FOLDER, + ProtocolGenerator.getSanitizedName(protocolGenerator.getName()))); return serdeFunctionName; } } diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/DirectedTypeScriptCodegen.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/DirectedTypeScriptCodegen.java index 74fadce5244..c9b1f5d54e4 100644 --- a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/DirectedTypeScriptCodegen.java +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/DirectedTypeScriptCodegen.java @@ -55,6 +55,7 @@ import software.amazon.smithy.typescript.codegen.integration.ProtocolGenerator; import software.amazon.smithy.typescript.codegen.integration.RuntimeClientPlugin; import software.amazon.smithy.typescript.codegen.integration.TypeScriptIntegration; +import software.amazon.smithy.typescript.codegen.schema.SchemaGenerator; import software.amazon.smithy.typescript.codegen.validation.LongValidator; import software.amazon.smithy.typescript.codegen.validation.ReplaceLast; import software.amazon.smithy.utils.MapUtils; @@ -112,10 +113,15 @@ public TypeScriptCodegenContext createContext(CreateContextDirective operation.hasTrait(PaginatedTrait.ID))) { PaginationGenerator.writeIndex(model, service, fileManifest); delegator.useFileWriter(PaginationGenerator.PAGINATION_INTERFACE_FILE, paginationWriter -> diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/ServiceBareBonesClientGenerator.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/ServiceBareBonesClientGenerator.java index 1659b43bb47..7deee72c89a 100644 --- a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/ServiceBareBonesClientGenerator.java +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/ServiceBareBonesClientGenerator.java @@ -36,6 +36,7 @@ import software.amazon.smithy.typescript.codegen.endpointsV2.EndpointsV2Generator; import software.amazon.smithy.typescript.codegen.integration.RuntimeClientPlugin; import software.amazon.smithy.typescript.codegen.integration.TypeScriptIntegration; +import software.amazon.smithy.typescript.codegen.schema.SchemaGenerationAllowlist; import software.amazon.smithy.typescript.codegen.sections.ClientBodyExtraCodeSection; import software.amazon.smithy.typescript.codegen.sections.ClientConfigCodeSection; import software.amazon.smithy.typescript.codegen.sections.ClientConstructorCodeSection; @@ -434,6 +435,15 @@ private void generateConstructor() { writer.write("this.config = $L;", generateConfigVariable(configVariable)); + if (SchemaGenerationAllowlist.contains(service.getId())) { + writer.addImportSubmodule( + "getSchemaSerdePlugin", null, + TypeScriptDependency.SMITHY_CORE, "/schema" + ); + writer.write(""" + this.middlewareStack.use(getSchemaSerdePlugin(this.config));"""); + } + // Add runtime plugins that contain middleware to the middleware stack // of the client. for (RuntimeClientPlugin plugin : runtimePlugins) { diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/AddClientRuntimeConfig.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/AddClientRuntimeConfig.java index ed49fdd3bec..b4841914400 100644 --- a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/AddClientRuntimeConfig.java +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/AddClientRuntimeConfig.java @@ -58,82 +58,82 @@ public final class AddClientRuntimeConfig implements TypeScriptIntegration { @Override public void addConfigInterfaceFields( - TypeScriptSettings settings, - Model model, - SymbolProvider symbolProvider, - TypeScriptWriter writer + TypeScriptSettings settings, + Model model, + SymbolProvider symbolProvider, + TypeScriptWriter writer ) { writer.addImport("Provider", "__Provider", TypeScriptDependency.SMITHY_TYPES); writer.addImport("Logger", "__Logger", TypeScriptDependency.SMITHY_TYPES); writer.writeDocs("Value for how many times a request will be made at most in case of retry.") - .write("maxAttempts?: number | __Provider;\n"); + .write("maxAttempts?: number | __Provider;\n"); writer.writeDocs(""" Specifies which retry algorithm to use. @see https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/Package/-smithy-util-retry/Enum/RETRY_MODES/ """) - .write("retryMode?: string | __Provider;\n"); + .write("retryMode?: string | __Provider;\n"); writer.writeDocs("Optional logger for logging debug/info/warn/error.") - .write("logger?: __Logger;\n"); + .write("logger?: __Logger;\n"); writer.addRelativeImport("RuntimeExtension", null, - Paths.get(".", CodegenUtils.SOURCE_FOLDER, "runtimeExtensions")); + Paths.get(".", CodegenUtils.SOURCE_FOLDER, "runtimeExtensions")); writer.writeDocs("Optional extensions") - .write("extensions?: RuntimeExtension[];\n"); + .write("extensions?: RuntimeExtension[];\n"); } @Override public Map> getRuntimeConfigWriters( - TypeScriptSettings settings, - Model model, - SymbolProvider symbolProvider, - LanguageTarget target + TypeScriptSettings settings, + Model model, + SymbolProvider symbolProvider, + LanguageTarget target ) { switch (target) { case SHARED: return MapUtils.of( - "logger", writer -> { - writer.addImport("NoOpLogger", null, TypeScriptDependency.AWS_SMITHY_CLIENT); - writer.write("new NoOpLogger()"); - } + "logger", writer -> { + writer.addImport("NoOpLogger", null, TypeScriptDependency.AWS_SMITHY_CLIENT); + writer.write("new NoOpLogger()"); + } ); case BROWSER: return MapUtils.of( - "maxAttempts", writer -> { - writer.addDependency(TypeScriptDependency.UTIL_RETRY); - writer.addImport("DEFAULT_MAX_ATTEMPTS", null, TypeScriptDependency.UTIL_RETRY); - writer.write("DEFAULT_MAX_ATTEMPTS"); - }, - "retryMode", writer -> { - writer.addDependency(TypeScriptDependency.UTIL_RETRY); - writer.addImport("DEFAULT_RETRY_MODE", null, TypeScriptDependency.UTIL_RETRY); - writer.write( - "(async () => (await defaultConfigProvider()).retryMode || DEFAULT_RETRY_MODE)"); - } + "maxAttempts", writer -> { + writer.addDependency(TypeScriptDependency.UTIL_RETRY); + writer.addImport("DEFAULT_MAX_ATTEMPTS", null, TypeScriptDependency.UTIL_RETRY); + writer.write("DEFAULT_MAX_ATTEMPTS"); + }, + "retryMode", writer -> { + writer.addDependency(TypeScriptDependency.UTIL_RETRY); + writer.addImport("DEFAULT_RETRY_MODE", null, TypeScriptDependency.UTIL_RETRY); + writer.write( + "(async () => (await defaultConfigProvider()).retryMode || DEFAULT_RETRY_MODE)"); + } ); case NODE: return MapUtils.of( - "maxAttempts", writer -> { - writer.addDependency(TypeScriptDependency.NODE_CONFIG_PROVIDER); - writer.addImport("loadConfig", "loadNodeConfig", - TypeScriptDependency.NODE_CONFIG_PROVIDER); - writer.addImport("NODE_MAX_ATTEMPT_CONFIG_OPTIONS", null, - TypeScriptDependency.MIDDLEWARE_RETRY); - writer.write("loadNodeConfig(NODE_MAX_ATTEMPT_CONFIG_OPTIONS, config)"); - }, - "retryMode", writer -> { - writer.addDependency(TypeScriptDependency.NODE_CONFIG_PROVIDER); - writer.addImport("loadConfig", "loadNodeConfig", - TypeScriptDependency.NODE_CONFIG_PROVIDER); - writer.addDependency(TypeScriptDependency.MIDDLEWARE_RETRY); - writer.addImport("NODE_RETRY_MODE_CONFIG_OPTIONS", null, - TypeScriptDependency.MIDDLEWARE_RETRY); - writer.addImport("DEFAULT_RETRY_MODE", null, TypeScriptDependency.UTIL_RETRY); - writer.openBlock("loadNodeConfig({", "}, config)", () -> { - writer.write("...NODE_RETRY_MODE_CONFIG_OPTIONS,"); - writer.write("default: async () => " - + "(await defaultConfigProvider()).retryMode || DEFAULT_RETRY_MODE,"); - }); - } + "maxAttempts", writer -> { + writer.addDependency(TypeScriptDependency.NODE_CONFIG_PROVIDER); + writer.addImport("loadConfig", "loadNodeConfig", + TypeScriptDependency.NODE_CONFIG_PROVIDER); + writer.addImport("NODE_MAX_ATTEMPT_CONFIG_OPTIONS", null, + TypeScriptDependency.MIDDLEWARE_RETRY); + writer.write("loadNodeConfig(NODE_MAX_ATTEMPT_CONFIG_OPTIONS, config)"); + }, + "retryMode", writer -> { + writer.addDependency(TypeScriptDependency.NODE_CONFIG_PROVIDER); + writer.addImport("loadConfig", "loadNodeConfig", + TypeScriptDependency.NODE_CONFIG_PROVIDER); + writer.addDependency(TypeScriptDependency.MIDDLEWARE_RETRY); + writer.addImport("NODE_RETRY_MODE_CONFIG_OPTIONS", null, + TypeScriptDependency.MIDDLEWARE_RETRY); + writer.addImport("DEFAULT_RETRY_MODE", null, TypeScriptDependency.UTIL_RETRY); + writer.openBlock("loadNodeConfig({", "}, config)", () -> { + writer.write("...NODE_RETRY_MODE_CONFIG_OPTIONS,"); + writer.write("default: async () => " + + "(await defaultConfigProvider()).retryMode || DEFAULT_RETRY_MODE,"); + }); + } ); default: return Collections.emptyMap(); diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/AddProtocolConfig.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/AddProtocolConfig.java new file mode 100644 index 00000000000..f63cf9b144b --- /dev/null +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/AddProtocolConfig.java @@ -0,0 +1,72 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.typescript.codegen.integration; + +import java.util.Collections; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; +import software.amazon.smithy.codegen.core.SymbolProvider; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.protocol.traits.Rpcv2CborTrait; +import software.amazon.smithy.typescript.codegen.LanguageTarget; +import software.amazon.smithy.typescript.codegen.TypeScriptDependency; +import software.amazon.smithy.typescript.codegen.TypeScriptSettings; +import software.amazon.smithy.typescript.codegen.TypeScriptWriter; +import software.amazon.smithy.typescript.codegen.schema.SchemaGenerationAllowlist; +import software.amazon.smithy.utils.MapUtils; +import software.amazon.smithy.utils.SmithyInternalApi; + + +/** + * Adds a protocol implementation to the runtime config. + */ +@SmithyInternalApi +public final class AddProtocolConfig implements TypeScriptIntegration { + + @Override + public void addConfigInterfaceFields( + TypeScriptSettings settings, + Model model, + SymbolProvider symbolProvider, + TypeScriptWriter writer + ) { + // the {{ protocol?: Protocol }} type field is provided + // by the smithy client config interface. + } + + @Override + public Map> getRuntimeConfigWriters( + TypeScriptSettings settings, + Model model, + SymbolProvider symbolProvider, + LanguageTarget target + ) { + if (!SchemaGenerationAllowlist.contains(settings.getService())) { + return Collections.emptyMap(); + } + + String namespace = settings.getService().getNamespace(); + + switch (target) { + case SHARED: + if (Objects.equals(settings.getProtocol(), Rpcv2CborTrait.ID)) { + return MapUtils.of( + "protocol", writer -> { + writer.addImportSubmodule( + "SmithyRpcV2CborProtocol", null, + TypeScriptDependency.SMITHY_CORE, "/cbor"); + writer.write("new SmithyRpcV2CborProtocol({ defaultNamespace: $S })", namespace); + } + ); + } + case BROWSER: + case NODE: + default: + return Collections.emptyMap(); + } + } +} diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/schema/SchemaGenerationAllowlist.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/schema/SchemaGenerationAllowlist.java new file mode 100644 index 00000000000..ad235536ee2 --- /dev/null +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/schema/SchemaGenerationAllowlist.java @@ -0,0 +1,38 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.typescript.codegen.schema; + +import java.util.HashSet; +import java.util.Set; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.utils.SmithyInternalApi; + + +/** + * + * Controls rollout of schema generation. + * + */ +@SmithyInternalApi +public abstract class SchemaGenerationAllowlist { + private static final Set ALLOWED = new HashSet<>(); + + static { + ALLOWED.add("smithy.protocoltests.rpcv2Cbor#RpcV2Protocol"); + } + + public static boolean contains(String serviceShapeId) { + return ALLOWED.contains(serviceShapeId); + } + + public static boolean contains(ShapeId serviceShapeId) { + return ALLOWED.contains(serviceShapeId.toString()); + } + + public static void allow(String serviceShapeId) { + ALLOWED.add(serviceShapeId); + } +} diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/schema/SchemaGenerator.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/schema/SchemaGenerator.java new file mode 100644 index 00000000000..0d0139b0b83 --- /dev/null +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/schema/SchemaGenerator.java @@ -0,0 +1,587 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.typescript.codegen.schema; + +import java.nio.file.Paths; +import java.util.HashSet; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Collectors; +import software.amazon.smithy.build.FileManifest; +import software.amazon.smithy.codegen.core.ReservedWords; +import software.amazon.smithy.codegen.core.ReservedWordsBuilder; +import software.amazon.smithy.codegen.core.SymbolProvider; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.knowledge.TopDownIndex; +import software.amazon.smithy.model.shapes.CollectionShape; +import software.amazon.smithy.model.shapes.MapShape; +import software.amazon.smithy.model.shapes.MemberShape; +import software.amazon.smithy.model.shapes.OperationShape; +import software.amazon.smithy.model.shapes.ServiceShape; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ShapeType; +import software.amazon.smithy.model.shapes.StructureShape; +import software.amazon.smithy.model.shapes.UnionShape; +import software.amazon.smithy.model.traits.ErrorTrait; +import software.amazon.smithy.model.traits.StreamingTrait; +import software.amazon.smithy.model.traits.TimestampFormatTrait; +import software.amazon.smithy.typescript.codegen.CodegenUtils; +import software.amazon.smithy.typescript.codegen.TypeScriptClientCodegenPlugin; +import software.amazon.smithy.typescript.codegen.TypeScriptDependency; +import software.amazon.smithy.typescript.codegen.TypeScriptSettings; +import software.amazon.smithy.typescript.codegen.TypeScriptWriter; +import software.amazon.smithy.typescript.codegen.util.StringStore; +import software.amazon.smithy.utils.SmithyInternalApi; + +/** + * Generates schema objects used to define shape (de)serialization. + */ +@SmithyInternalApi +public class SchemaGenerator implements Runnable { + public static final String SCHEMAS_FOLDER = "schemas"; + private final SchemaReferenceIndex elision; + private final TypeScriptSettings settings; + private final SymbolProvider symbolProvider; + private final Model model; + private final FileManifest fileManifest; + private final StringStore stringStore = new StringStore(); + private final TypeScriptWriter writer = new TypeScriptWriter(""); + + private final Set loadShapesVisited = new HashSet<>(); + + private final Set structureShapes = new TreeSet<>(); + private final Set collectionShapes = new TreeSet<>(); + private final Set mapShapes = new TreeSet<>(); + private final Set unionShapes = new TreeSet<>(); + private final Set operationShapes = new TreeSet<>(); + private final Set simpleShapes = new TreeSet<>(); + + private final Set existsAsSchema = new HashSet<>(); + private final Set requiresNamingDeconfliction = new HashSet<>(); + + private final ReservedWords reservedWords = new ReservedWordsBuilder() + .loadWords(Objects.requireNonNull(TypeScriptClientCodegenPlugin.class.getResource("reserved-words.txt"))) + .build(); + + public SchemaGenerator(Model model, + FileManifest fileManifest, + TypeScriptSettings settings, SymbolProvider symbolProvider) { + this.model = model; + this.fileManifest = fileManifest; + elision = SchemaReferenceIndex.of(model); + this.settings = settings; + this.symbolProvider = symbolProvider; + writer.write( + """ + /* eslint no-var: 0 */ + """ + ); + } + + /** + * Writes all schemas for the model to a schemas.ts file. + */ + @Override + public void run() { + for (ServiceShape service : model.getServiceShapes()) { + if (!SchemaGenerationAllowlist.contains(service.getId())) { + return; + } + for (OperationShape operation : TopDownIndex.of(model).getContainedOperations(service)) { + operation.getInput().ifPresent(inputShape -> { + loadShapes(model.expectShape(inputShape)); + }); + operation.getOutput().ifPresent(outputShape -> { + loadShapes(model.expectShape(outputShape)); + }); + operation.getErrors().forEach(error -> { + loadShapes(model.expectShape(error)); + }); + operationShapes.add(operation); + existsAsSchema.add(operation); + } + } + deconflictSchemaVarNames(); + + simpleShapes.forEach(this::writeSimpleSchema); + structureShapes.forEach(this::writeStructureSchema); + writeBaseError(); + collectionShapes.forEach(this::writeListSchema); + mapShapes.forEach(this::writeMapSchema); + unionShapes.forEach(this::writeUnionSchema); + operationShapes.forEach(this::writeOperationSchema); + + String stringVariables = stringStore.flushVariableDeclarationCode(); + fileManifest.writeFile( + Paths.get(CodegenUtils.SOURCE_FOLDER, SCHEMAS_FOLDER, "schemas.ts").toString(), + stringVariables + "\n" + writer + ); + } + + /** + * Identifies repeated strings among the schemas to use in StringStore. + */ + private void loadShapes(Shape shape) { + String absoluteName = shape.getId().toString(); + String name = shape.getId().getName(); + + if (shape.isMemberShape()) { + loadShapes(model.expectShape(shape.asMemberShape().get().getTarget())); + return; + } + + if (loadShapesVisited.contains(absoluteName)) { + return; + } + + if (!elision.isReferenceSchema(shape)) { + stringStore.var(name); + } + loadShapesVisited.add(absoluteName); + + switch (shape.getType()) { + case LIST -> { + collectionShapes.add(shape.asListShape().get()); + existsAsSchema.add(shape); + } + case SET -> { + collectionShapes.add(shape.asSetShape().get()); + existsAsSchema.add(shape); + } + case MAP -> { + mapShapes.add(shape.asMapShape().get()); + existsAsSchema.add(shape); + } + case STRUCTURE -> { + structureShapes.add(shape.asStructureShape().get()); + existsAsSchema.add(shape); + } + case UNION -> { + unionShapes.add(shape.asUnionShape().get()); + existsAsSchema.add(shape); + } + case BYTE, INT_ENUM, SHORT, INTEGER, LONG, FLOAT, DOUBLE, BIG_INTEGER, BIG_DECIMAL, BOOLEAN, STRING, + TIMESTAMP, DOCUMENT, ENUM, BLOB -> { + if (elision.traits.hasSchemaTraits(shape)) { + existsAsSchema.add(shape); + } + simpleShapes.add(shape); + } + default -> { + // ... + } + } + + Set memberTargetShapes = shape.getAllMembers().values().stream() + .map(MemberShape::getTarget) + .map(model::expectShape) + .collect(Collectors.toSet()); + + for (Shape memberTargetShape : memberTargetShapes) { + loadShapes(memberTargetShape); + } + } + + private void deconflictSchemaVarNames() { + Set observedShapeNames = new HashSet<>(); + for (Shape shape : existsAsSchema) { + if (observedShapeNames.contains(shape.getId().getName())) { + requiresNamingDeconfliction.add(shape); + } else { + observedShapeNames.add(shape.getId().getName()); + } + } + } + + /** + * @return variable name of the shape's schema, with deconfliction for multiple namespaces with the same + * unqualified name. + */ + private String getShapeVariableName(Shape shape) { + String symbolName = reservedWords.escape(shape.getId().getName()); + if (requiresNamingDeconfliction.contains(shape)) { + symbolName += "_" + stringStore.var(shape.getId().getNamespace(), "n"); + } + return symbolName; + } + + private void writeSimpleSchema(Shape shape) { + if (elision.traits.hasSchemaTraits(shape)) { + writer.addImportSubmodule("sim", "sim", TypeScriptDependency.SMITHY_CORE, "/schema"); + writer.write(""" + export var $L = sim($L, $L, $L,""", + getShapeVariableName(shape), + stringStore.var(shape.getId().getNamespace(), "n"), + stringStore.var(shape.getId().getName()), + resolveSimpleSchema(shape) + ); + writeTraits(shape); + writer.write(");"); + } + } + + private void writeStructureSchema(StructureShape shape) { + checkedWriteSchema(shape, () -> { + String symbolName = reservedWords.escape(shape.getId().getName()); + if (shape.hasTrait(ErrorTrait.class)) { + String exceptionCtorSymbolName = "__" + symbolName; + writer.addImportSubmodule("error", "error", TypeScriptDependency.SMITHY_CORE, "/schema"); + writer.addRelativeImport( + symbolName, + exceptionCtorSymbolName, + Paths.get("..", "models", "index") + ); + writer.openBlock(""" + export var $L = error($L, $L,""", + "", + getShapeVariableName(shape), + stringStore.var(shape.getId().getNamespace(), "n"), + stringStore.var(shape.getId().getName()), + () -> doWithMembers(shape) + ); + writer.writeInline(",$L", exceptionCtorSymbolName); + writer.write(");"); + } else { + writer.addImportSubmodule("struct", "struct", TypeScriptDependency.SMITHY_CORE, "/schema"); + writer.openBlock(""" + export var $L = struct($L, $L,""", + ");", + getShapeVariableName(shape), + stringStore.var(shape.getId().getNamespace(), "n"), + stringStore.var(shape.getId().getName()), + () -> doWithMembers(shape) + ); + } + }); + } + + private void writeBaseError() { + String serviceName = CodegenUtils.getServiceName(settings, model, symbolProvider); + String serviceExceptionName = CodegenUtils.getServiceExceptionName(serviceName); + String namespace = model.getServiceShapes().stream().findFirst().get().getId().getNamespace(); + + String exceptionCtorSymbolName = "__" + serviceExceptionName; + writer.addImportSubmodule("error", "error", TypeScriptDependency.SMITHY_CORE, "/schema"); + writer.addRelativeImport( + serviceExceptionName, + exceptionCtorSymbolName, + Paths.get("..", "models", serviceExceptionName) + ); + writer.write(""" + export var $L = error($S, $S, 0, [], []""", + serviceExceptionName, + "awssdkjs.synthetic." + namespace, + serviceExceptionName + ); + writer.writeInline(",$L", exceptionCtorSymbolName); + writer.write(");"); + } + + private void writeUnionSchema(UnionShape shape) { + checkedWriteSchema(shape, () -> { + writer.addImportSubmodule("struct", "uni", TypeScriptDependency.SMITHY_CORE, "/schema"); + writer.openBlock(""" + export var $L = uni($L, $L,""", + ");", + getShapeVariableName(shape), + stringStore.var(shape.getId().getNamespace(), "n"), + stringStore.var(shape.getId().getName()), + () -> doWithMembers(shape) + ); + }); + } + + /** + * Handles the member entries for unions/structures. + */ + private void doWithMembers(Shape shape) { + writeTraits(shape); + + writer.write(", [ "); + shape.getAllMembers().forEach((memberName, member) -> { + writer.write("$L,", stringStore.var(memberName)); + }); + writer.write(" ], ["); + shape.getAllMembers().forEach((memberName, member) -> { + String ref = resolveSchema(member); + if (elision.traits.hasSchemaTraits(member)) { + writer.openBlock(""" + [$L,\s""", + "],", + ref, + () -> { + writeTraits(member); + } + ); + } else { + writer.write("$L,", ref); + } + }); + writer.write("]"); + } + + private void writeListSchema(CollectionShape shape) { + checkedWriteSchema(shape, () -> { + writer.addImportSubmodule("list", "list", TypeScriptDependency.SMITHY_CORE, "/schema"); + writer.openBlock(""" + export var $L = list($L, $L,""", + ");", + getShapeVariableName(shape), + stringStore.var(shape.getId().getNamespace(), "n"), + stringStore.var(shape.getId().getName()), + () -> this.doWithMember( + shape, + shape.getMember() + ) + ); + }); + } + + private void writeMapSchema(MapShape shape) { + checkedWriteSchema(shape, () -> { + writer.addImportSubmodule("map", "map", TypeScriptDependency.SMITHY_CORE, "/schema"); + writer.openBlock(""" + export var $L = map($L, $L,""", + ");", + getShapeVariableName(shape), + stringStore.var(shape.getId().getNamespace(), "n"), + stringStore.var(shape.getId().getName()), + () -> this.doWithMember( + shape, + shape.getKey(), + shape.getValue() + ) + ); + }); + } + + /** + * Write member schema insertion for lists. + */ + private void doWithMember(Shape shape, MemberShape memberShape) { + writeTraits(shape); + String ref = resolveSchema(memberShape); + if (elision.traits.hasSchemaTraits(memberShape)) { + writer.openBlock( + ", [$L, ", + "]", + ref, + () -> { + writeTraits(memberShape); + } + ); + } else { + writer.write(", $L", ref); + } + } + + /** + * Write member schema insertion for maps. + */ + private void doWithMember(Shape shape, MemberShape keyShape, MemberShape memberShape) { + writeTraits(shape); + String keyRef = resolveSchema(keyShape); + String valueRef = resolveSchema(memberShape); + if (elision.traits.hasSchemaTraits(memberShape) || elision.traits.hasSchemaTraits(keyShape)) { + writer.openBlock( + ", [$L, ", + "]", + keyRef, + () -> { + writeTraits(keyShape); + } + ); + writer.openBlock( + ", [$L, ", + "]", + valueRef, + () -> { + writeTraits(memberShape); + } + ); + } else { + writer.write(", $L, $L", keyRef, valueRef); + } + } + + private void writeOperationSchema(OperationShape shape) { + writer.addImportSubmodule("op", "op", TypeScriptDependency.SMITHY_CORE, "/schema"); + writer.openBlock(""" + export var $L = op($L, $L,""", + ");", + getShapeVariableName(shape), + stringStore.var(shape.getId().getNamespace(), "n"), + stringStore.var(shape.getId().getName()), + () -> { + writeTraits(shape); + writer.write(""" + , () => $L, () => $L""", + getShapeVariableName(model.expectShape(shape.getInputShape())), + getShapeVariableName(model.expectShape(shape.getOutputShape())) + ); + } + ); + } + + private void writeTraits(Shape shape) { + writer.write( + new SchemaTraitWriter(shape, elision, stringStore).toString() + ); + } + + /** + * Checks whether ok to write minimized schema. + */ + private void checkedWriteSchema(Shape shape, Runnable schemaWriteFn) { + if (shape.getId().getNamespace().equals("smithy.api") + && shape.getId().getName().equals("Unit")) { + // special signal value for operation input/output. + writer.write(""" + export var Unit = "unit" as const; + """); + } else if (!elision.isReferenceSchema(shape) && !elision.traits.hasSchemaTraits(shape)) { + String sentinel = this.resolveSchema(shape); + + writer.write( + """ + export var $L = $L; + """, + getShapeVariableName(shape), + sentinel + ); + } else { + schemaWriteFn.run(); + } + } + + /** + * @return generally the symbol name of the target shape, but sometimes a sentinel value for special types like + * blob and timestamp. + */ + private String resolveSchema(Shape shape) { + MemberShape memberShape = null; + if (shape instanceof MemberShape ms) { + memberShape = ms; + shape = model.expectShape(memberShape.getTarget()); + } + + boolean isReference = elision.isReferenceSchema(shape); + boolean hasTraits = elision.traits.hasSchemaTraits(shape); + + if (!hasTraits) { + try { + return resolveSimpleSchema(memberShape != null ? memberShape : shape); + } catch (IllegalArgumentException ignored) { + // + } + } + + return (isReference || hasTraits ? "() => " : "") + getShapeVariableName(shape); + } + + private String resolveSimpleSchema(Shape shape) { + MemberShape memberShape = null; + if (shape instanceof MemberShape ms) { + memberShape = ms; + shape = model.expectShape(memberShape.getTarget()); + } + + ShapeType type = shape.getType(); + + switch (type) { + case BOOLEAN -> { + return "2"; + } + case STRING, ENUM -> { + return "0"; + } + case TIMESTAMP -> { + Optional trait = shape.getTrait(TimestampFormatTrait.class); + if (memberShape != null && memberShape.hasTrait(TimestampFormatTrait.class)) { + trait = memberShape.getTrait(TimestampFormatTrait.class); + } + return trait.map(timestampFormatTrait -> switch (timestampFormatTrait.getValue()) { + case "date-time" -> "5"; + case "http-date" -> "6"; + case "epoch-seconds" -> "7"; + default -> "4"; + }).orElse("4"); + } + case BLOB -> { + if (shape.hasTrait(StreamingTrait.class)) { + return "42"; + } + return "21"; + } + case BYTE, SHORT, INTEGER, INT_ENUM, LONG, FLOAT, DOUBLE -> { + return "1"; + } + case DOCUMENT -> { + return "15"; + } + case BIG_DECIMAL -> { + return "17"; + } + case BIG_INTEGER -> { + return "19"; + } + case LIST, SET, MAP -> { + return resolveSimpleSchemaNestedContainer(shape, writer, stringStore); + } + default -> { + // + } + } + throw new IllegalArgumentException("shape is not simple"); + } + + private String resolveSimpleSchemaNestedContainer(Shape shape, TypeScriptWriter writer, StringStore stringStore) { + Shape contained; + String factory; + String sentinel; + String keyMemberSchema; + switch (shape.getType()) { + case LIST -> { + contained = shape.asListShape().get().getMember(); + factory = "list"; + keyMemberSchema = ""; + sentinel = "64"; + } + case MAP -> { + contained = shape.asMapShape().get().getValue(); + factory = "map"; + keyMemberSchema = this.resolveSimpleSchema(shape.asMapShape().get().getKey()) + ", "; + sentinel = "128"; + } + default -> { + throw new IllegalArgumentException( + "call to resolveSimpleSchemaNestedContainer with incompatible shape type." + ); + } + } + if (contained.isMemberShape()) { + contained = model.expectShape(contained.asMemberShape().get().getTarget()); + } + + if (contained.isListShape()) { + writer.addImportSubmodule(factory, factory, TypeScriptDependency.SMITHY_CORE, "/schema"); + String schemaVarName = stringStore.var(shape.getId().getName()); + return factory + "(" + stringStore.var(shape.getId().getNamespace(), "n") + ", " + schemaVarName + ", 0, " + + keyMemberSchema + + this.resolveSimpleSchema(contained) + ")"; + } else if (contained.isMapShape()) { + writer.addImportSubmodule(factory, factory, TypeScriptDependency.SMITHY_CORE, "/schema"); + String schemaVarName = stringStore.var(shape.getId().getName()); + return factory + "(" + stringStore.var(shape.getId().getNamespace(), "n") + ", " + schemaVarName + ", 0, " + + keyMemberSchema + + this.resolveSimpleSchema(contained) + ")"; + } else { + return sentinel + "|" + this.resolveSimpleSchema(contained); + } + } +} diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/schema/SchemaReferenceIndex.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/schema/SchemaReferenceIndex.java new file mode 100644 index 00000000000..4262b978a02 --- /dev/null +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/schema/SchemaReferenceIndex.java @@ -0,0 +1,68 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.typescript.codegen.schema; + +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.knowledge.KnowledgeIndex; +import software.amazon.smithy.model.shapes.CollectionShape; +import software.amazon.smithy.model.shapes.MapShape; +import software.amazon.smithy.model.shapes.MemberShape; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ShapeType; +import software.amazon.smithy.utils.SmithyInternalApi; + +/** + * Can determine whether a Schema can be defined by a sentinel value. + */ +@SmithyInternalApi +final class SchemaReferenceIndex implements KnowledgeIndex { + public final SchemaTraitFilterIndex traits; + private final Model model; + + SchemaReferenceIndex(Model model) { + this.model = model; + traits = SchemaTraitFilterIndex.of(model); + } + + public static SchemaReferenceIndex of(Model model) { + return model.getKnowledge(SchemaReferenceIndex.class, SchemaReferenceIndex::new); + } + + /** + * A reference shape is a function pointer to a shape that doesn't have a constant numeric + * sentinel value. + * Simple non-aggregate types and lists/maps of those types are considered non-reference + * in TypeScript. + * + * @return whether shape is a reference shape. + */ + public boolean isReferenceSchema(Shape shape) { + Shape targetShape = shape; + if (shape instanceof MemberShape member) { + targetShape = model.expectShape(member.getTarget()); + } + ShapeType type = targetShape.getType(); + switch (type) { + case BOOLEAN, STRING, BYTE, DOUBLE, FLOAT, INTEGER, LONG, SHORT, ENUM, INT_ENUM -> { + return false; + } + case TIMESTAMP, BLOB -> { + return false; + } + case LIST, SET, MAP -> { + if (shape instanceof CollectionShape collection) { + return isReferenceSchema(collection.getMember()); + } else if (shape instanceof MapShape map) { + return isReferenceSchema(map.getValue()); + } + return true; + } + default -> { + return true; + } + } + } +} diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/schema/SchemaTraitFilterIndex.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/schema/SchemaTraitFilterIndex.java new file mode 100644 index 00000000000..a108d7d84b9 --- /dev/null +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/schema/SchemaTraitFilterIndex.java @@ -0,0 +1,157 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.typescript.codegen.schema; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.knowledge.KnowledgeIndex; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.traits.EndpointTrait; +import software.amazon.smithy.model.traits.ErrorTrait; +import software.amazon.smithy.model.traits.EventHeaderTrait; +import software.amazon.smithy.model.traits.EventPayloadTrait; +import software.amazon.smithy.model.traits.HostLabelTrait; +import software.amazon.smithy.model.traits.HttpErrorTrait; +import software.amazon.smithy.model.traits.HttpHeaderTrait; +import software.amazon.smithy.model.traits.HttpLabelTrait; +import software.amazon.smithy.model.traits.HttpPayloadTrait; +import software.amazon.smithy.model.traits.HttpPrefixHeadersTrait; +import software.amazon.smithy.model.traits.HttpQueryParamsTrait; +import software.amazon.smithy.model.traits.HttpQueryTrait; +import software.amazon.smithy.model.traits.HttpResponseCodeTrait; +import software.amazon.smithy.model.traits.HttpTrait; +import software.amazon.smithy.model.traits.IdempotencyTokenTrait; +import software.amazon.smithy.model.traits.JsonNameTrait; +import software.amazon.smithy.model.traits.MediaTypeTrait; +import software.amazon.smithy.model.traits.ProtocolDefinitionTrait; +import software.amazon.smithy.model.traits.RequiresLengthTrait; +import software.amazon.smithy.model.traits.SensitiveTrait; +import software.amazon.smithy.model.traits.SparseTrait; +import software.amazon.smithy.model.traits.StreamingTrait; +import software.amazon.smithy.model.traits.Trait; +import software.amazon.smithy.model.traits.TraitDefinition; +import software.amazon.smithy.model.traits.XmlAttributeTrait; +import software.amazon.smithy.model.traits.XmlFlattenedTrait; +import software.amazon.smithy.model.traits.XmlNameTrait; +import software.amazon.smithy.model.traits.XmlNamespaceTrait; +import software.amazon.smithy.utils.SetUtils; +import software.amazon.smithy.utils.SmithyInternalApi; + +@SmithyInternalApi +final class SchemaTraitFilterIndex implements KnowledgeIndex { + private final Set> includedTraits = new HashSet<>( + SetUtils.of( + SparseTrait.class, + // excluded by special schema handling. + // TimestampFormatTrait.class, + SensitiveTrait.class, + IdempotencyTokenTrait.class, + JsonNameTrait.class, + MediaTypeTrait.class, + XmlAttributeTrait.class, + XmlFlattenedTrait.class, + XmlNameTrait.class, + XmlNamespaceTrait.class, + EventHeaderTrait.class, + EventPayloadTrait.class, + StreamingTrait.class, + RequiresLengthTrait.class, + EndpointTrait.class, + HttpErrorTrait.class, + HttpHeaderTrait.class, + HttpQueryTrait.class, + HttpLabelTrait.class, + HttpPayloadTrait.class, + HttpPrefixHeadersTrait.class, + HttpQueryParamsTrait.class, + HttpResponseCodeTrait.class, + HostLabelTrait.class, + ErrorTrait.class, + HttpTrait.class + ) + ); + private final Map cache = new HashMap<>(); + private final Model model; + + SchemaTraitFilterIndex(Model model) { + Set shapesWithTrait = model.getShapesWithTrait(ProtocolDefinitionTrait.class); + for (Shape shape : shapesWithTrait) { + System.out.println("shape having authDef: " + shape.getId().getName()); + shape.getTrait(ProtocolDefinitionTrait.class).ifPresent(protocolDefinitionTrait -> { + protocolDefinitionTrait.getTraits().forEach(traitShapeId -> { + Shape traitShape = model.expectShape(traitShapeId); + TraitDefinition traitDefinition = model.getTraitDefinition(traitShapeId).get(); + System.out.println("\t trait shape: " + traitShapeId.getName()); + }); + }); + } + + this.model = model; + for (Shape shape : model.toSet()) { + cache.put(shape, hasSchemaTraits(shape)); + } + } + + public static SchemaTraitFilterIndex of(Model model) { + return model.getKnowledge(SchemaTraitFilterIndex.class, SchemaTraitFilterIndex::new); + } + + /** + * @param shape - structure or member, usually. + * @return whether it has at least 1 trait that is needed in a schema. + */ + public boolean hasSchemaTraits(Shape shape) { + return hasSchemaTraits(shape, 0); + } + + public boolean hasSchemaTraits(Shape shape, int depth) { + if (cache.containsKey(shape)) { + return cache.get(shape); + } + if (depth > 20) { + return false; + } + boolean hasSchemaTraits = shape.getAllTraits() + .values() + .stream() + .map(Trait::getClass) + .anyMatch(this::includeTrait); + + if (hasSchemaTraits) { + cache.put(shape, true); + return true; + } + + boolean membersHaveSchemaTraits = shape.getAllMembers().values().stream() + .anyMatch(ms -> hasSchemaTraits(ms, depth + 1)); + boolean targetHasSchemaTraits = shape.asMemberShape() + .map(ms -> hasSchemaTraits(model.expectShape(ms.getTarget()), depth + 1)) + .orElse(false); + + cache.put(shape, membersHaveSchemaTraits || targetHasSchemaTraits); + return cache.get(shape); + } + + /** + * @param traitClass - query. + * @return whether trait should be included in schema generation. + */ + public boolean includeTrait(Class traitClass) { + return includedTraits.contains(traitClass); + } + + /** + * Adds a trait class to the set of traits that will return true when calling + * {@link #includeTrait(Class)}. + * @param trait - to be added to inclusion list. + */ + public void addTrait(Class trait) { + includedTraits.add(trait); + } +} diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/schema/SchemaTraitGenerator.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/schema/SchemaTraitGenerator.java new file mode 100644 index 00000000000..7cfae156ace --- /dev/null +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/schema/SchemaTraitGenerator.java @@ -0,0 +1,127 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.typescript.codegen.schema; + +import java.util.Objects; +import java.util.Set; +import software.amazon.smithy.model.traits.AnnotationTrait; +import software.amazon.smithy.model.traits.EndpointTrait; +import software.amazon.smithy.model.traits.ErrorTrait; +import software.amazon.smithy.model.traits.EventHeaderTrait; +import software.amazon.smithy.model.traits.EventPayloadTrait; +import software.amazon.smithy.model.traits.HostLabelTrait; +import software.amazon.smithy.model.traits.HttpErrorTrait; +import software.amazon.smithy.model.traits.HttpHeaderTrait; +import software.amazon.smithy.model.traits.HttpLabelTrait; +import software.amazon.smithy.model.traits.HttpPayloadTrait; +import software.amazon.smithy.model.traits.HttpPrefixHeadersTrait; +import software.amazon.smithy.model.traits.HttpQueryParamsTrait; +import software.amazon.smithy.model.traits.HttpQueryTrait; +import software.amazon.smithy.model.traits.HttpResponseCodeTrait; +import software.amazon.smithy.model.traits.HttpTrait; +import software.amazon.smithy.model.traits.IdempotencyTokenTrait; +import software.amazon.smithy.model.traits.JsonNameTrait; +import software.amazon.smithy.model.traits.MediaTypeTrait; +import software.amazon.smithy.model.traits.RequiresLengthTrait; +import software.amazon.smithy.model.traits.SensitiveTrait; +import software.amazon.smithy.model.traits.SparseTrait; +import software.amazon.smithy.model.traits.StreamingTrait; +import software.amazon.smithy.model.traits.StringTrait; +import software.amazon.smithy.model.traits.TimestampFormatTrait; +import software.amazon.smithy.model.traits.Trait; +import software.amazon.smithy.model.traits.XmlAttributeTrait; +import software.amazon.smithy.model.traits.XmlFlattenedTrait; +import software.amazon.smithy.model.traits.XmlNameTrait; +import software.amazon.smithy.model.traits.XmlNamespaceTrait; +import software.amazon.smithy.typescript.codegen.util.StringStore; +import software.amazon.smithy.utils.SetUtils; +import software.amazon.smithy.utils.SmithyInternalApi; + + +/** + * Creates the string representing a trait's data. + * For presence-based trait, essentially boolean, a 1 or 2 will be used. + */ +@SmithyInternalApi +public class SchemaTraitGenerator { + private static final String ANNOTATION_TRAIT_VALUE = "1"; + private static final Set> ANNOTATION_TRAITS = SetUtils.of( + XmlAttributeTrait.class, + XmlFlattenedTrait.class, + EventHeaderTrait.class, + EventPayloadTrait.class, + StreamingTrait.class, + RequiresLengthTrait.class, + HttpLabelTrait.class, + HttpPayloadTrait.class, + HttpQueryParamsTrait.class, + HttpResponseCodeTrait.class, + HostLabelTrait.class, + SparseTrait.class, + SensitiveTrait.class, + IdempotencyTokenTrait.class + ); + + /** + * Data traits are traits with one or more fields of data. + * To allow for the possibility of the traits adding new fields, + * the generated schema object MUST be an array with consistent ordering and size + * for the fields' data. + */ + private static final Set> DATA_TRAITS = SetUtils.of( + HttpErrorTrait.class, + HttpTrait.class, + EndpointTrait.class, + XmlNamespaceTrait.class + ); + + private static final Set> STRING_TRAITS = SetUtils.of( + TimestampFormatTrait.class, + JsonNameTrait.class, + MediaTypeTrait.class, + XmlNameTrait.class, + HttpHeaderTrait.class, + HttpQueryTrait.class, + HttpPrefixHeadersTrait.class, + ErrorTrait.class + ); + + public String serializeTraitData(Trait trait, StringStore stringStore) { + if (trait instanceof TimestampFormatTrait) { + // this is overridden by {@link SchemaGenerator::resolveSchema} + return ""; + } else if (STRING_TRAITS.contains(trait.getClass()) && trait instanceof StringTrait strTrait) { + return stringStore.var(strTrait.getValue()); + } else if (ANNOTATION_TRAITS.contains(trait.getClass()) && trait instanceof AnnotationTrait) { + return ANNOTATION_TRAIT_VALUE; + } else if (trait instanceof HttpErrorTrait httpError) { + return Objects.toString(httpError.getCode()); + } else if (trait instanceof HttpTrait httpTrait) { + return """ + ["%s", "%s", %s] + """.formatted( + httpTrait.getMethod(), + httpTrait.getUri(), + httpTrait.getCode() + ); + } else if (DATA_TRAITS.contains(trait.getClass())) { + if (trait instanceof EndpointTrait endpointTrait) { + return """ + ["%s"] + """.formatted(endpointTrait.getHostPrefix()); + } else if (trait instanceof XmlNamespaceTrait xmlNamespaceTrait) { + return """ + [%s, %s] + """.formatted( + stringStore.var(xmlNamespaceTrait.getPrefix().orElse("")), + stringStore.var(xmlNamespaceTrait.getUri()) + ); + } + } + return """ + /* unhandled trait \s""" + "`" + trait.getClass().getSimpleName() + "` */"; + } +} diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/schema/SchemaTraitWriter.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/schema/SchemaTraitWriter.java new file mode 100644 index 00000000000..bba39b13b4f --- /dev/null +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/schema/SchemaTraitWriter.java @@ -0,0 +1,99 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.typescript.codegen.schema; + +import java.util.List; +import java.util.Objects; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.traits.AnnotationTrait; +import software.amazon.smithy.model.traits.HttpLabelTrait; +import software.amazon.smithy.model.traits.HttpPayloadTrait; +import software.amazon.smithy.model.traits.HttpQueryParamsTrait; +import software.amazon.smithy.model.traits.HttpResponseCodeTrait; +import software.amazon.smithy.model.traits.IdempotencyTokenTrait; +import software.amazon.smithy.model.traits.IdempotentTrait; +import software.amazon.smithy.model.traits.SensitiveTrait; +import software.amazon.smithy.model.traits.Trait; +import software.amazon.smithy.typescript.codegen.util.StringStore; + + +class SchemaTraitWriter { + private final Shape shape; + private final SchemaReferenceIndex elision; + private final StringStore stringStore; + private final StringBuilder buffer = new StringBuilder(); + private final List> compressTraits = List.of( + HttpLabelTrait.class, + IdempotentTrait.class, + IdempotencyTokenTrait.class, + SensitiveTrait.class, + HttpPayloadTrait.class, + HttpResponseCodeTrait.class, + HttpQueryParamsTrait.class + ); + private final SchemaTraitGenerator traitGenerator = new SchemaTraitGenerator(); + + SchemaTraitWriter( + Shape shape, + SchemaReferenceIndex elision, + StringStore stringStore + ) { + this.shape = shape; + this.elision = elision; + this.stringStore = stringStore; + } + + /** + * @return either the numeric bitvector or object representation of + * the traits on the input shape. + */ + @Override + public String toString() { + if (mayUseCompressedTraits()) { + writeTraitsBitVector(); + } else { + writeTraitsObject(); + } + return buffer.toString(); + } + + private boolean mayUseCompressedTraits() { + return shape.getAllTraits() + .values() + .stream() + .map(Trait::getClass) + .filter(elision.traits::includeTrait) + .allMatch(compressTraits::contains); + } + + private void writeTraitsBitVector() { + int bits = 0; + for (int i = 0; i < compressTraits.size(); ++i) { + if (shape.hasTrait(compressTraits.get(i))) { + bits |= (1 << i); + } + } + buffer.append(Objects.toString(bits)); + } + + private void writeTraitsObject() { + buffer.append("{\n"); + + shape.getAllTraits().forEach((shapeId, trait) -> { + if (!elision.traits.includeTrait(trait.getClass())) { + return; + } + buffer.append(""" + [%s]: %s,""".formatted( + stringStore.var(shapeId.getName()), + traitGenerator.serializeTraitData(trait, stringStore) + ) + ); + }); + + buffer.append("}"); + } +} diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/util/StringStore.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/util/StringStore.java index 458f571a76f..d5721dfb876 100644 --- a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/util/StringStore.java +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/util/StringStore.java @@ -5,16 +5,21 @@ package software.amazon.smithy.typescript.codegen.util; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedList; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Queue; import java.util.Set; import java.util.TreeMap; import java.util.function.Function; +import java.util.regex.MatchResult; +import java.util.regex.Pattern; +import java.util.stream.Collectors; import software.amazon.smithy.utils.SmithyInternalApi; /** @@ -25,6 +30,11 @@ */ @SmithyInternalApi public final class StringStore { + /** + * Words are the component strings found within `camelCaseWords` or `header-dashed-words`. + */ + private static final Pattern FIND_WORDS = Pattern.compile("(x-amz)|(-\\w{3,})|(^[a-z]{3,})|([A-Z][a-z]{2,})"); + // order doesn't matter for this map. private final Map literalToVariable = new HashMap<>(); @@ -34,6 +44,16 @@ public final class StringStore { // controls incremental output. private final Set writelog = new HashSet<>(); + private final WordTrie wordTrie = new WordTrie(); + + private boolean useCompounds = false; + + public StringStore() {} + + public StringStore(boolean useCompounds) { + this.useCompounds = useCompounds; + } + /** * @param literal - a literal string value. * @return the variable name assigned for that string, which may have been encountered before. @@ -43,6 +63,24 @@ public String var(String literal) { return literalToVariable.computeIfAbsent(literal, this::assignKey); } + /** + * @param literal - a literal string value. + * @param preferredPrefix - a preferred rather than derived variable name. + * @return allocates the variable with the preferred prefix. + */ + public String var(String literal, String preferredPrefix) { + Objects.requireNonNull(literal); + return literalToVariable.computeIfAbsent(literal, (String key) -> assignPreferredKey(key, preferredPrefix)); + } + + /** + * @param literal - query. + * @return whether the literal has already been assigned. + */ + public boolean hasVar(String literal) { + return literalToVariable.containsKey(literal); + } + /** * Outputs the generated code for any constants that have been * allocated but not yet retrieved. @@ -50,14 +88,144 @@ public String var(String literal) { public String flushVariableDeclarationCode() { StringBuilder sourceCode = new StringBuilder(); - for (Map.Entry entry : variableToLiteral.entrySet()) { - String v = entry.getKey(); - String l = entry.getValue(); - if (writelog.add(v)) { - sourceCode.append(String.format("const %s = \"%s\";%n", v, l)); + if (useCompounds) { +// word to count. + Map wordCount = new HashMap<>(); + // whether to use concatenation to write the variable's value. + Set writeLiteralUsingStoredWords = new HashSet<>(); + Map> literalToWords = new HashMap<>(); + + Set> variableToLiteralEntries = variableToLiteral.entrySet(); + List writeWords = new ArrayList<>(); + List writeCompounds = new ArrayList<>(); + List writeStrings = new ArrayList<>(); + + // get word count. + for (Map.Entry entry : variableToLiteralEntries) { + String literal = entry.getValue(); + List words = FIND_WORDS.matcher(literal) + .results() + .map(MatchResult::group) + .toList(); + literalToWords.put(literal, words); + wordTrie.recordWords(words); + boolean wordsContiguousInLiteral = literal.contains(String.join("", words)); + for (String word : words) { + if (wordsContiguousInLiteral) { + wordCount.compute(word, (k, v) -> v == null ? 1 : v + 1); + } + } + } + // determine which literals to write using words. + for (Map.Entry entry : variableToLiteralEntries) { + String literal = entry.getValue(); + String[] words = literalToWords.get(literal).toArray(new String[0]); + boolean wordsContiguousInLiteral = literal.contains(String.join("", words)); + if (wordsContiguousInLiteral) { + writeLiteralUsingStoredWords.add(literal); + } + } + // write words. + for (Map.Entry entry : wordCount.entrySet()) { + String word = entry.getKey(); + String variable = var(word); + if (writelog.add(variable)) { + writeWords.add(String.format("const %s = \"%s\";%n", variable, word)); + } + } + + int compoundOrder = 0; + Map compoundVars = new HashMap<>(); + + // write stored strings. + for (Map.Entry entry : variableToLiteral.entrySet()) { + String variable = entry.getKey(); + String literal = entry.getValue(); + if (writelog.add(variable)) { + String[] words = literalToWords.get(literal).toArray(new String[0]); + int compoundIndex = wordTrie.bestIndex(literalToWords.get(literal)); + boolean useCompoundExpression = compoundIndex >= 2; + String compoundVar = null; + + if (useCompoundExpression) { + List prefix = literalToWords.get(literal).subList(0, compoundIndex); + String compoundConcatInCode = prefix + .stream() + .map(literalToVariable::get) + .collect(Collectors.joining("+")); + if (!compoundVars.containsKey(compoundConcatInCode)) { + compoundVars.put(compoundConcatInCode, "_c" + compoundOrder); + compoundOrder += 1; + writeCompounds.add(""" + const %s = %s;%n + """.formatted(compoundVars.get(compoundConcatInCode), compoundConcatInCode)); + } + compoundVar = compoundVars.get(compoundConcatInCode); + } + + if (writeLiteralUsingStoredWords.contains(literal)) { + String wordsConcat = String.join("", words); + List segments = Arrays.stream(literal.split(wordsConcat)) + .filter(s -> !s.isEmpty()) + .toList(); + + if (segments.size() <= 2) { + String wordsConcatInCode = Arrays.stream(words) + .map(literalToVariable::get) + .collect(Collectors.joining("+")); + if (useCompoundExpression && compoundVar != null) { + List wordsList = literalToWords.get(literal); + String nonCompoundSuffix = wordsList + .subList(compoundIndex, wordsList.size()) + .stream() + .map(literalToVariable::get) + .collect(Collectors.joining("+")); + if (nonCompoundSuffix.isEmpty()) { + wordsConcatInCode = compoundVar; + } else { + wordsConcatInCode = compoundVar + "+" + nonCompoundSuffix; + } + } + + String concatenationExpression; + if (segments.isEmpty() || literal.equals(wordsConcat)) { + concatenationExpression = wordsConcatInCode; + } else if (segments.size() == 1) { + if (literal.startsWith(wordsConcat)) { + concatenationExpression = """ + %s + "%s\"""".formatted(wordsConcatInCode, segments.get(0)); + } else { + concatenationExpression = """ + "%s" + %s""".formatted(segments.get(0), wordsConcatInCode); + } + } else /*2*/ { + concatenationExpression = """ + "%s" + %s + "%s\"""".formatted(segments.get(0), wordsConcatInCode, segments.get(1)); + } + + writeStrings.add(String.format( + "const %s = %s as %s;%n", variable, concatenationExpression, "\"" + literal + "\"")); + } else { + writeStrings.add(String.format("const %s = \"%s\";%n", variable, literal)); + } + } else { + writeStrings.add(String.format("const %s = \"%s\";%n", variable, literal)); + } + } } - } + writeWords.forEach(sourceCode::append); + writeCompounds.forEach(sourceCode::append); + writeStrings.forEach(sourceCode::append); + } else { + for (Map.Entry entry : variableToLiteral.entrySet()) { + String variable = entry.getKey(); + String literal = entry.getValue(); + if (writelog.add(variable)) { + sourceCode.append(String.format("const %s = \"%s\";%n", variable, literal)); + } + } + } return sourceCode.toString(); } @@ -73,6 +241,20 @@ private String assignKey(String literal) { return variable; } + /** + * Allocates a variable name for a given string literal. + */ + private String assignPreferredKey(String literal, String preferredPrefix) { + int numericSuffix = 0; + String candidate = preferredPrefix + numericSuffix; + while (variableToLiteral.containsKey(candidate)) { + numericSuffix += 1; + candidate = preferredPrefix + numericSuffix; + } + variableToLiteral.put(candidate, literal); + return candidate; + } + /** * Assigns a unique variable using the letters from the literal. * Prefers the uppercase or word-starting letters. diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/util/WordTrie.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/util/WordTrie.java new file mode 100644 index 00000000000..ac3377ff94f --- /dev/null +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/util/WordTrie.java @@ -0,0 +1,77 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.typescript.codegen.util; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeSet; + +public class WordTrie { + private static final int MINIMUM_FOR_REUSE = 3; + private final Map path = new HashMap<>(); + private int count = 0; + + public WordTrie() {} + + /** + * Records a count of all prefixes in the word list. + */ + public void recordWords(List words) { + WordTrie trie; + Map cursor = path; + count += 1; + for (String word : words) { + cursor.computeIfAbsent(word, (w) -> new WordTrie()); + trie = cursor.get(word); + trie.count += 1; + cursor = trie.path; + } + } + + /** + * @return index of words where a prefix up to that point can be reused. + */ + public int bestIndex(List words) { + WordTrie trie; + Map cursor = path; + int index = 0; + for (String word : words) { + if (!cursor.containsKey(word)) { + return -1; + } + trie = cursor.get(word); + if (trie.count < MINIMUM_FOR_REUSE) { + break; + } + index += 1; + cursor = trie.path; + } + return index; + } + + + public int count() { + return count; + } + + public TreeSet words() { + return new TreeSet<>(path.keySet()); + } + + public WordTrie get(List words) { + WordTrie trie = this; + Map cursor = path; + for (String word : words) { + if (!cursor.containsKey(word)) { + return null; + } + trie = cursor.get(word); + cursor = trie.path; + } + return trie; + } +} diff --git a/smithy-typescript-codegen/src/main/resources/META-INF/services/software.amazon.smithy.typescript.codegen.integration.TypeScriptIntegration b/smithy-typescript-codegen/src/main/resources/META-INF/services/software.amazon.smithy.typescript.codegen.integration.TypeScriptIntegration index 76c28ba977c..8caa2b09f53 100644 --- a/smithy-typescript-codegen/src/main/resources/META-INF/services/software.amazon.smithy.typescript.codegen.integration.TypeScriptIntegration +++ b/smithy-typescript-codegen/src/main/resources/META-INF/services/software.amazon.smithy.typescript.codegen.integration.TypeScriptIntegration @@ -1,5 +1,6 @@ software.amazon.smithy.typescript.codegen.integration.AddBuiltinPlugins software.amazon.smithy.typescript.codegen.integration.AddClientRuntimeConfig +software.amazon.smithy.typescript.codegen.integration.AddProtocolConfig software.amazon.smithy.typescript.codegen.integration.AddEventStreamDependency software.amazon.smithy.typescript.codegen.integration.AddChecksumRequiredDependency software.amazon.smithy.typescript.codegen.integration.AddDefaultsModeDependency diff --git a/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/CommandGeneratorTest.java b/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/CommandGeneratorTest.java index 5872dd5ffde..9ca42dc0ebf 100644 --- a/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/CommandGeneratorTest.java +++ b/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/CommandGeneratorTest.java @@ -3,6 +3,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.containsString; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import software.amazon.smithy.build.MockManifest; import software.amazon.smithy.build.PluginContext; @@ -34,6 +35,18 @@ public void writesDeserializer() { ); } + /** + * todo(schema) enable when switched over. + */ + @Disabled + @Test + public void writesOperationSchemaRef() { + testCommandCodegen( + "output-structure.smithy", + new String[] {".sc("} + ); + } + @Test public void writesOperationContextParamValues() { testCommandCodegen( diff --git a/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/util/StringStoreTest.java b/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/util/StringStoreTest.java index dd0339a24ee..652aaf4207f 100644 --- a/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/util/StringStoreTest.java +++ b/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/util/StringStoreTest.java @@ -1,7 +1,6 @@ package software.amazon.smithy.typescript.codegen.util; import org.junit.jupiter.api.Test; - import java.util.Objects; import static org.junit.jupiter.api.Assertions.*; @@ -122,4 +121,62 @@ void var() { ); } } + + @Test + void construction() { + StringStore subject = new StringStore(true); + subject.var("ListWordsCamelCase"); + subject.var("list-words-hyphenated"); + subject.var("ListWordsCamelCaseButWithTooShortWordOk"); + subject.var("list-words-hyphenated-with-too-short-word-ok"); + + subject.var("LongRepeatedPrefixBoolean"); + subject.var("LongRepeatedPrefixBlob"); + subject.var("LongRepeatedPrefixByte"); + subject.var("LongRepeatedPrefixInteger"); + subject.var("LongRepeatedPrefixList"); + subject.var("LongRepeatedPrefixMap"); + + assertEquals( + """ + const _s = "-short"; + const _B = "But"; + const _w = "-with"; + const _Bl = "Blob"; + const _W = "Word"; + const _wo = "-word"; + const _C = "Camel"; + const _P = "Prefix"; + const _R = "Repeated"; + const _l = "list"; + const _Wi = "With"; + const _Ca = "Case"; + const _I = "Integer"; + const _t = "-too"; + const _h = "-hyphenated"; + const _T = "Too"; + const _wor = "-words"; + const _Wo = "Words"; + const _By = "Byte"; + const _L = "Long"; + const _Li = "List"; + const _Bo = "Boolean"; + const _M = "Map"; + const _S = "Short"; + const _c0 = _L+_R+_P; + + const _LRPB = _c0+_Bo as "LongRepeatedPrefixBoolean"; + const _LRPBo = _c0+_Bl as "LongRepeatedPrefixBlob"; + const _LRPBon = _c0+_By as "LongRepeatedPrefixByte"; + const _LRPI = _c0+_I as "LongRepeatedPrefixInteger"; + const _LRPL = _c0+_Li as "LongRepeatedPrefixList"; + const _LRPM = _c0+_M as "LongRepeatedPrefixMap"; + const _LWCC = _Li+_Wo+_C+_Ca as "ListWordsCamelCase"; + const _LWCCBWTSWO = _Li+_Wo+_C+_Ca+_B+_Wi+_T+_S+_W + "Ok" as "ListWordsCamelCaseButWithTooShortWordOk"; + const _lwh = _l+_wor+_h as "list-words-hyphenated"; + const _lwhwtswo = _l+_wor+_h+_w+_t+_s+_wo + "-ok" as "list-words-hyphenated-with-too-short-word-ok"; + """, + subject.flushVariableDeclarationCode() + ); + } } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 4c96eae56b4..3d4987629d8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2402,6 +2402,7 @@ __metadata: "@smithy/middleware-serde": "workspace:^" "@smithy/protocol-http": "workspace:^" "@smithy/types": "workspace:^" + "@smithy/util-base64": "workspace:^" "@smithy/util-body-length-browser": "workspace:^" "@smithy/util-middleware": "workspace:^" "@smithy/util-stream": "workspace:^"