diff --git a/.changeset/grumpy-cobras-melt.md b/.changeset/grumpy-cobras-melt.md new file mode 100644 index 00000000000..211ded3d244 --- /dev/null +++ b/.changeset/grumpy-cobras-melt.md @@ -0,0 +1,6 @@ +--- +"@smithy/core": minor +"@smithy/middleware-serde": patch +--- + +add schema classes diff --git a/packages/core/package.json b/packages/core/package.json index a932decc468..235633a1347 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -12,8 +12,8 @@ "lint": "npx eslint -c ../../.eslintrc.js \"src/**/*.ts\" --fix && node ./scripts/lint", "format": "prettier --config ../../prettier.config.js --ignore-path ../../.prettierignore --write \"**/*.{ts,md,json}\"", "extract:docs": "api-extractor run --local", - "test": "yarn g:vitest run", "test:cbor:perf": "node ./scripts/cbor-perf.mjs", + "test": "yarn g:vitest run", "test:watch": "yarn g:vitest watch" }, "main": "./dist-cjs/index.js", @@ -53,6 +53,13 @@ "import": "./dist-es/submodules/serde/index.js", "require": "./dist-cjs/submodules/serde/index.js", "types": "./dist-types/submodules/serde/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" } }, "author": { @@ -86,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/schema/TypeRegistry.spec.ts b/packages/core/src/submodules/schema/TypeRegistry.spec.ts new file mode 100644 index 00000000000..32cca2f100a --- /dev/null +++ b/packages/core/src/submodules/schema/TypeRegistry.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, test as it } from "vitest"; + +import { error } from "./schemas/ErrorSchema"; +import { list } from "./schemas/ListSchema"; +import { map } from "./schemas/MapSchema"; +import { struct } from "./schemas/StructureSchema"; +import { TypeRegistry } from "./TypeRegistry"; + +describe(TypeRegistry.name, () => { + const [List, Map, Struct] = [list("ack", "List", { sparse: 1 }, 0), map("ack", "Map", 0, 0, 1), () => schema]; + const schema = struct("ack", "Structure", {}, ["list", "map", "struct"], [List, Map, Struct]); + + const tr = TypeRegistry.for("ack"); + + it("stores and retrieves schema objects", () => { + expect(tr.getSchema("List")).toBe(List); + expect(tr.getSchema("Map")).toBe(Map); + expect(tr.getSchema("Structure")).toBe(schema); + }); + + it("has a helper method to retrieve a synthetic base exception", () => { + // the service namespace is appended to the synthetic prefix. + const err = error("smithyts.client.synthetic.ack", "UhOhServiceException", 0, [], [], Error); + const tr = TypeRegistry.for("smithyts.client.synthetic.ack"); + expect(tr.getBaseException()).toEqual(err); + }); +}); diff --git a/packages/core/src/submodules/schema/TypeRegistry.ts b/packages/core/src/submodules/schema/TypeRegistry.ts new file mode 100644 index 00000000000..0712b2b79fd --- /dev/null +++ b/packages/core/src/submodules/schema/TypeRegistry.ts @@ -0,0 +1,101 @@ +import type { Schema as ISchema } from "@smithy/types"; + +import { ErrorSchema } from "./schemas/ErrorSchema"; + +/** + * A way to look up schema by their ShapeId values. + * + * @alpha + */ +export class TypeRegistry { + 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)!; + } + + /** + * Adds the given schema to a type registry with the same namespace. + * + * @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)!; + } + + /** + * The smithy-typescript code generator generates a synthetic (i.e. unmodeled) base exception, + * because generated SDKs before the introduction of schemas have the notion of a ServiceBaseException, which + * is unique per service/model. + * + * This is generated under a unique prefix that is combined with the service namespace, and this + * method is used to retrieve it. + * + * The base exception synthetic schema is used when an error is returned by a service, but we cannot + * determine what existing schema to use to deserialize it. + * + * @returns the synthetic base exception of the service namespace associated with this registry instance. + */ + public getBaseException(): ErrorSchema | undefined { + for (const [id, schema] of this.schemas.entries()) { + if (id.startsWith("smithyts.client.synthetic.") && id.endsWith("ServiceException")) { + return schema as ErrorSchema; + } + } + return undefined; + } + + /** + * @param predicate - criterion. + * @returns a schema in this registry matching the predicate. + */ + public find(predicate: (schema: ISchema) => boolean) { + return [...this.schemas.values()].find(predicate); + } + + /** + * Unloads the current TypeRegistry. + */ + 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..770bff9d626 --- /dev/null +++ b/packages/core/src/submodules/schema/deref.ts @@ -0,0 +1,12 @@ +import type { Schema, SchemaRef } from "@smithy/types"; + +/** + * Dereferences a SchemaRef 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..994b18403a2 --- /dev/null +++ b/packages/core/src/submodules/schema/index.ts @@ -0,0 +1,12 @@ +export * from "./deref"; +export * from "./middleware/getSchemaSerdePlugin"; +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/getSchemaSerdePlugin.ts b/packages/core/src/submodules/schema/middleware/getSchemaSerdePlugin.ts new file mode 100644 index 00000000000..b26af488bc1 --- /dev/null +++ b/packages/core/src/submodules/schema/middleware/getSchemaSerdePlugin.ts @@ -0,0 +1,49 @@ +import { + DeserializeHandlerOptions, + MetadataBearer, + MiddlewareStack, + Pluggable, + SerdeFunctions, + SerializeHandlerOptions, +} from "@smithy/types"; + +import { PreviouslyResolved } from "./schema-middleware-types"; +import { schemaDeserializationMiddleware } from "./schemaDeserializationMiddleware"; +import { schemaSerializationMiddleware } from "./schemaSerializationMiddleware"; + +/** + * @internal + */ +export const deserializerMiddlewareOption: DeserializeHandlerOptions = { + name: "deserializerMiddleware", + step: "deserialize", + tags: ["DESERIALIZER"], + override: true, +}; + +/** + * @internal + */ +export const serializerMiddlewareOption: SerializeHandlerOptions = { + name: "serializerMiddleware", + step: "serialize", + tags: ["SERIALIZER"], + override: true, +}; + +/** + * @internal + */ +export function getSchemaSerdePlugin( + config: PreviouslyResolved +): 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 as SerdeFunctions); + }, + }; +} diff --git a/packages/core/src/submodules/schema/middleware/schema-middleware-types.ts b/packages/core/src/submodules/schema/middleware/schema-middleware-types.ts new file mode 100644 index 00000000000..885b50873cf --- /dev/null +++ b/packages/core/src/submodules/schema/middleware/schema-middleware-types.ts @@ -0,0 +1,12 @@ +import type { ClientProtocol, SerdeContext, UrlParser } from "@smithy/types"; + +/** + * @internal + */ +export type PreviouslyResolved = Omit< + SerdeContext & { + urlParser: UrlParser; + protocol: ClientProtocol; + }, + "endpoint" +>; diff --git a/packages/core/src/submodules/schema/middleware/schemaDeserializationMiddleware.spec.ts b/packages/core/src/submodules/schema/middleware/schemaDeserializationMiddleware.spec.ts new file mode 100644 index 00000000000..73fe5a54cb5 --- /dev/null +++ b/packages/core/src/submodules/schema/middleware/schemaDeserializationMiddleware.spec.ts @@ -0,0 +1,228 @@ +import { HttpResponse } from "@smithy/protocol-http"; +import { SchemaRef } from "@smithy/types"; +import { afterEach, beforeEach, describe, expect, test as it, vi } from "vitest"; + +import { schemaDeserializationMiddleware } from "./schemaDeserializationMiddleware"; + +describe(schemaDeserializationMiddleware.name, () => { + const mockNext = vi.fn(); + const mockDeserializer = vi.fn(); + + const mockProtocol = { + deserializeResponse: mockDeserializer, + }; + + const mockOptions = { + endpoint: () => + Promise.resolve({ + protocol: "protocol", + hostname: "hostname", + path: "path", + }), + protocol: mockProtocol, + } as any; + + const mockOptionsDeserializationError = { + ...mockOptions, + protocol: { + async deserializeResponse() { + JSON.parse(`this isn't JSON`); + }, + } as any, + } as any; + + const mockArgs = { + input: { + inputKey: "inputValue", + }, + request: { + method: "GET", + headers: {}, + }, + }; + + const mockOutput = { + $metadata: { + statusCode: 200, + requestId: "requestId", + }, + outputKey: "outputValue", + }; + + const mockNextResponse = { + response: { + statusCode: 200, + headers: {}, + }, + $metadata: { + httpStatusCode: 200, + requestId: undefined, + extendedRequestId: undefined, + cfId: undefined, + }, + }; + + const mockResponse = { + response: mockNextResponse.response, + output: mockOutput, + }; + + beforeEach(() => { + mockNext.mockResolvedValueOnce(mockNextResponse); + mockDeserializer.mockResolvedValueOnce(mockOutput); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("calls deserializer and populates response object", async () => { + await expect(schemaDeserializationMiddleware(mockOptions)(mockNext, {})(mockArgs)).resolves.toStrictEqual( + mockResponse + ); + + expect(mockNext).toHaveBeenCalledTimes(1); + expect(mockNext).toHaveBeenCalledWith(mockArgs); + expect(mockDeserializer).toHaveBeenCalledTimes(1); + expect(mockDeserializer).toHaveBeenCalledWith( + undefined as SchemaRef, + { + ...mockOptions, + __smithy_context: {}, + }, + mockNextResponse.response + ); + }); + + it("injects non-enumerable $response reference to deserializing exceptions", async () => { + const exception = Object.assign(new Error("MockException"), mockNextResponse.response); + mockDeserializer.mockReset(); + mockDeserializer.mockRejectedValueOnce(exception); + try { + await schemaDeserializationMiddleware(mockOptions)(mockNext, {})(mockArgs); + fail("DeserializerMiddleware should throw"); + } catch (e) { + expect(e).toMatchObject(exception); + expect(e.$response).toEqual(mockNextResponse.response); + expect(Object.keys(e)).not.toContain("$response"); + } + }); + + it("adds a hint about $response to the message of the thrown error", async () => { + const exception = Object.assign(new Error("MockException"), mockNextResponse.response, { + $response: { + body: "", + }, + $responseBodyText: "oh no", + }); + mockDeserializer.mockReset(); + mockDeserializer.mockRejectedValueOnce(exception); + try { + await schemaDeserializationMiddleware(mockOptions)(mockNext, {})(mockArgs); + fail("DeserializerMiddleware should throw"); + } catch (e) { + expect(e.message).toContain( + "to see the raw response, inspect the hidden field {error}.$response on this object." + ); + expect(e.$response.body).toEqual("oh no"); + } + }); + + it("handles unwritable error.message", async () => { + const exception = Object.assign({}, mockNextResponse.response, { + $response: { + body: "", + }, + $responseBodyText: "oh no", + }); + + Object.defineProperty(exception, "message", { + set() { + throw new Error("may not call setter"); + }, + get() { + return "MockException"; + }, + }); + + const sink = vi.fn(); + + mockDeserializer.mockReset(); + mockDeserializer.mockRejectedValueOnce(exception); + try { + await schemaDeserializationMiddleware(mockOptions)(mockNext, { + logger: { + debug: sink, + info: sink, + warn: sink, + error: sink, + }, + })(mockArgs); + fail("DeserializerMiddleware should throw"); + } catch (e) { + expect(sink).toHaveBeenCalledWith( + `Deserialization error: to see the raw response, inspect the hidden field {error}.$response on this object.` + ); + expect(e.message).toEqual("MockException"); + expect(e.$response.body).toEqual("oh no"); + } + }); + + describe("metadata", () => { + it("assigns metadata from the response in the event of a deserializer failure", async () => { + const midware = schemaDeserializationMiddleware(mockOptionsDeserializationError); + const handler = midware( + async () => ({ + response: new HttpResponse({ + headers: { + "x-namespace-requestid": "requestid", + "x-namespace-id-2": "id2", + "x-namespace-cf-id": "cf", + }, + statusCode: 503, + }), + }), + {} + ); + try { + await handler(mockArgs); + fail("DeserializerMiddleware should throw"); + } catch (e) { + expect(e.$metadata).toEqual({ + httpStatusCode: 503, + requestId: "requestid", + extendedRequestId: "id2", + cfId: "cf", + }); + } + expect.assertions(1); + }); + + it("assigns any available metadata from the response in the event of a deserializer failure", async () => { + const midware = schemaDeserializationMiddleware(mockOptionsDeserializationError); + const handler = midware( + async () => ({ + response: new HttpResponse({ + statusCode: 301, + headers: { + "x-namespace-requestid": "requestid", + }, + }), + }), + {} + ); + try { + await handler(mockArgs); + fail("DeserializerMiddleware should throw"); + } catch (e) { + expect(e.$metadata).toEqual({ + httpStatusCode: 301, + requestId: "requestid", + extendedRequestId: undefined, + cfId: undefined, + }); + } + expect.assertions(1); + }); + }); +}); diff --git a/packages/core/src/submodules/schema/middleware/schemaDeserializationMiddleware.ts b/packages/core/src/submodules/schema/middleware/schemaDeserializationMiddleware.ts new file mode 100644 index 00000000000..e7f7a5fb9de --- /dev/null +++ b/packages/core/src/submodules/schema/middleware/schemaDeserializationMiddleware.ts @@ -0,0 +1,95 @@ +import { HttpResponse } from "@smithy/protocol-http"; +import { + DeserializeHandler, + DeserializeHandlerArguments, + HandlerExecutionContext, + MetadataBearer, + OperationSchema, +} from "@smithy/types"; +import { getSmithyContext } from "@smithy/util-middleware"; + +import { PreviouslyResolved } from "./schema-middleware-types"; + +/** + * @internal + */ +export const schemaDeserializationMiddleware = + (config: PreviouslyResolved) => + (next: DeserializeHandler, context: HandlerExecutionContext) => + async (args: DeserializeHandlerArguments) => { + const { response } = await next(args); + const { operationSchema } = getSmithyContext(context) as { + operationSchema: OperationSchema; + }; + 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; + } + } + + try { + // if the deserializer failed, then $metadata may still be set + // by taking information from the response. + if (HttpResponse.isInstance(response)) { + const { headers = {} } = response; + const headerEntries = Object.entries(headers); + (error as MetadataBearer).$metadata = { + httpStatusCode: response.statusCode, + requestId: findHeader(/^x-[\w-]+-request-?id$/, headerEntries), + extendedRequestId: findHeader(/^x-[\w-]+-id-2$/, headerEntries), + cfId: findHeader(/^x-[\w-]+-cf-id$/, headerEntries), + }; + } + } catch (e) { + // ignored, error object was not writable. + } + } + + throw error; + } + }; + +/** + * @internal + * @returns header value where key matches regex. + */ +const findHeader = (pattern: RegExp, headers: [string, string][]): string | undefined => { + return (headers.find(([k]) => { + return k.match(pattern); + }) || [void 0, void 1])[1]; +}; diff --git a/packages/core/src/submodules/schema/middleware/schemaSerializationMiddleware.spec.ts b/packages/core/src/submodules/schema/middleware/schemaSerializationMiddleware.spec.ts new file mode 100644 index 00000000000..dd91eb33ce0 --- /dev/null +++ b/packages/core/src/submodules/schema/middleware/schemaSerializationMiddleware.spec.ts @@ -0,0 +1,69 @@ +import { SchemaRef } from "@smithy/types"; +import { beforeEach, describe, expect, test as it, vi } from "vitest"; + +import { schemaSerializationMiddleware } from "./schemaSerializationMiddleware"; + +describe(schemaSerializationMiddleware.name, () => { + const mockNext = vi.fn(); + const mockSerializer = vi.fn(); + + const mockProtocol = { + serializeRequest: mockSerializer, + }; + + const mockOptions = { + endpoint: () => + Promise.resolve({ + protocol: "protocol", + hostname: "hostname", + path: "path", + }), + protocol: mockProtocol, + } as any; + + const mockRequest = { + method: "GET", + headers: {}, + }; + + const mockResponse = { + statusCode: 200, + headers: {}, + }; + + const mockOutput = { + $metadata: { + statusCode: 200, + requestId: "requestId", + }, + outputKey: "outputValue", + }; + + const mockReturn = { + response: mockResponse, + output: mockOutput, + }; + + const mockArgs = { + input: { + inputKey: "inputValue", + }, + }; + + beforeEach(() => { + mockNext.mockResolvedValueOnce(mockReturn); + mockSerializer.mockResolvedValueOnce(mockRequest); + }); + + it("calls serializer and populates request object", async () => { + await expect(schemaSerializationMiddleware(mockOptions)(mockNext, {})(mockArgs)).resolves.toStrictEqual(mockReturn); + + expect(mockSerializer).toHaveBeenCalledTimes(1); + expect(mockSerializer).toHaveBeenCalledWith(undefined as SchemaRef, mockArgs.input, { + ...mockOptions, + __smithy_context: {}, + }); + expect(mockNext).toHaveBeenCalledTimes(1); + expect(mockNext).toHaveBeenCalledWith({ ...mockArgs, request: mockRequest }); + }); +}); diff --git a/packages/core/src/submodules/schema/middleware/schemaSerializationMiddleware.ts b/packages/core/src/submodules/schema/middleware/schemaSerializationMiddleware.ts new file mode 100644 index 00000000000..fc4cb35d655 --- /dev/null +++ b/packages/core/src/submodules/schema/middleware/schemaSerializationMiddleware.ts @@ -0,0 +1,39 @@ +import { + Endpoint, + EndpointBearer, + HandlerExecutionContext, + OperationSchema as IOperationSchema, + Provider, + SerializeHandler, + SerializeHandlerArguments, +} from "@smithy/types"; +import { getSmithyContext } from "@smithy/util-middleware"; + +import { PreviouslyResolved } from "./schema-middleware-types"; + +/** + * @internal + */ +export const schemaSerializationMiddleware = + (config: PreviouslyResolved) => + (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 as unknown as EndpointBearer).endpoint!; + + const request = await config.protocol.serializeRequest(operationSchema, args.input, { + ...config, + ...context, + endpoint, + }); + return next({ + ...args, + request, + }); + }; 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..2487f4f4b77 --- /dev/null +++ b/packages/core/src/submodules/schema/schemas/ErrorSchema.ts @@ -0,0 +1,52 @@ +import type { SchemaRef, SchemaTraits } from "@smithy/types"; + +import { TypeRegistry } from "../TypeRegistry"; +import { StructureSchema } from "./StructureSchema"; + +/** + * A schema for a structure shape having the error trait. These represent enumerated operation errors. + * Because Smithy-TS SDKs use classes for exceptions, whereas plain objects are used for all other data, + * and have an existing notion of a XYZServiceBaseException, the ErrorSchema differs from a StructureSchema + * by additionally holding the class reference for the corresponding ServiceException class. + * + * @alpha + */ +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); + } +} + +/** + * Factory for ErrorSchema, to reduce codegen output and register the schema. + * + * @internal + * + * @param namespace - shapeId namespace. + * @param name - shapeId name. + * @param traits - shape level serde traits. + * @param memberNames - list of member names. + * @param memberList - list of schemaRef corresponding to each + * @param ctor - class reference for the existing Error extending class. + */ +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..5b1f1727098 --- /dev/null +++ b/packages/core/src/submodules/schema/schemas/ListSchema.ts @@ -0,0 +1,35 @@ +import type { ListSchema as IListSchema, SchemaRef, SchemaTraits } from "@smithy/types"; + +import { TypeRegistry } from "../TypeRegistry"; +import { Schema } from "./Schema"; + +/** + * A schema with a single member schema. + * The deprecated Set type may be represented as a list. + * + * @alpha + */ +export class ListSchema extends Schema implements IListSchema { + public constructor( + public name: string, + public traits: SchemaTraits, + public valueSchema: SchemaRef + ) { + super(name, traits); + } +} + +/** + * Factory for ListSchema. + * + * @internal + */ +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..18ff575616d --- /dev/null +++ b/packages/core/src/submodules/schema/schemas/MapSchema.ts @@ -0,0 +1,43 @@ +import type { MapSchema as IMapSchema, SchemaRef, SchemaTraits } from "@smithy/types"; + +import { TypeRegistry } from "../TypeRegistry"; +import { Schema } from "./Schema"; + +/** + * A schema with a key schema and value schema. + * @alpha + */ +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); + } +} + +/** + * Factory for MapSchema. + * @internal + */ +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.spec.ts b/packages/core/src/submodules/schema/schemas/NormalizedSchema.spec.ts new file mode 100644 index 00000000000..649565cf739 --- /dev/null +++ b/packages/core/src/submodules/schema/schemas/NormalizedSchema.spec.ts @@ -0,0 +1,198 @@ +import { MemberSchema } from "@smithy/types"; +import { describe, expect, test as it } from "vitest"; + +import { list } from "./ListSchema"; +import { map } from "./MapSchema"; +import { NormalizedSchema } from "./NormalizedSchema"; +import { SCHEMA } from "./sentinels"; +import { sim } from "./SimpleSchema"; +import { struct } from "./StructureSchema"; + +describe(NormalizedSchema.name, () => { + const [List, Map, Struct] = [list("ack", "List", { sparse: 1 }, 0), map("ack", "Map", 0, 0, 1), () => schema]; + const schema = struct("ack", "Structure", {}, ["list", "map", "struct"], [List, Map, Struct]); + + const ns = new NormalizedSchema(() => schema); + const nsFromIndirect = NormalizedSchema.of([() => ns, 0]); + + it("has a static constructor", () => { + expect(NormalizedSchema.of(ns)).toBeInstanceOf(NormalizedSchema); + }); + + it("has a name", () => { + expect(ns.getName()).toEqual("Structure"); + expect(ns.getName(true)).toEqual("ack#Structure"); + }); + + describe("inner schema", () => { + it("has an inner schema", () => { + // intentional reference equality comparison. + expect(ns.getSchema()).toBe(schema); + }); + it("peels NormalizedSchema from its input schemaRef", () => { + const layered = NormalizedSchema.of( + NormalizedSchema.of(NormalizedSchema.of(NormalizedSchema.of(NormalizedSchema.of(nsFromIndirect)))) + ); + // intentional reference equality comparison. + expect(layered.getSchema()).toBe(schema); + }); + }); + + it("translates a bitvector of traits to a traits object", () => { + expect(NormalizedSchema.translateTraits(0b0000_0000)).toEqual({}); + expect(NormalizedSchema.translateTraits(0b0000_0001)).toEqual({ + httpLabel: 1, + }); + expect(NormalizedSchema.translateTraits(0b0000_0011)).toEqual({ + httpLabel: 1, + idempotent: 1, + }); + expect(NormalizedSchema.translateTraits(0b0000_0110)).toEqual({ + idempotent: 1, + idempotencyToken: 1, + }); + expect(NormalizedSchema.translateTraits(0b0000_1100)).toEqual({ + idempotencyToken: 1, + sensitive: 1, + }); + expect(NormalizedSchema.translateTraits(0b0001_1000)).toEqual({ + sensitive: 1, + httpPayload: 1, + }); + expect(NormalizedSchema.translateTraits(0b0011_0000)).toEqual({ + httpPayload: 1, + httpResponseCode: 1, + }); + expect(NormalizedSchema.translateTraits(0b0110_0000)).toEqual({ + httpResponseCode: 1, + httpQueryParams: 1, + }); + }); + + describe("member schema", () => { + const member = ns.getMemberSchema("list"); + + it("can represent a member schema", () => { + expect(member).toBeInstanceOf(NormalizedSchema); + expect(member.isMemberSchema()).toBe(true); + expect(member.isListSchema()).toBe(true); + expect(member.getSchema()).toBe(List); + expect(member.getMemberName()).toBe("list"); + }); + + it("throws when treating a non-member schema as a member schema", () => { + expect(() => { + ns.getMemberName(); + }).toThrow(); + }); + }); + + describe("traversal and type identifiers", () => { + it("type identifiers", () => { + expect(NormalizedSchema.of("unit").isUnitSchema()).toBe(true); + expect(NormalizedSchema.of(SCHEMA.LIST_MODIFIER | SCHEMA.NUMERIC).isListSchema()).toBe(true); + expect(NormalizedSchema.of(SCHEMA.MAP_MODIFIER | SCHEMA.NUMERIC).isMapSchema()).toBe(true); + + expect(NormalizedSchema.of(SCHEMA.DOCUMENT).isDocumentSchema()).toBe(true); + + expect(NormalizedSchema.of(ns.getMemberSchema("struct")).isStructSchema()).toBe(true); + expect(NormalizedSchema.of(SCHEMA.BLOB).isBlobSchema()).toBe(true); + expect(NormalizedSchema.of(SCHEMA.TIMESTAMP_DEFAULT).isTimestampSchema()).toBe(true); + + expect(NormalizedSchema.of(SCHEMA.STRING).isStringSchema()).toBe(true); + expect(NormalizedSchema.of(SCHEMA.BOOLEAN).isBooleanSchema()).toBe(true); + expect(NormalizedSchema.of(SCHEMA.NUMERIC).isNumericSchema()).toBe(true); + expect(NormalizedSchema.of(SCHEMA.BIG_INTEGER).isBigIntegerSchema()).toBe(true); + expect(NormalizedSchema.of(SCHEMA.BIG_DECIMAL).isBigDecimalSchema()).toBe(true); + expect(NormalizedSchema.of(SCHEMA.STREAMING_BLOB).isStreaming()).toBe(true); + expect(NormalizedSchema.of([ns, { streaming: 1 }]).isStreaming()).toBe(true); + }); + + describe("list member", () => { + it("list itself", () => { + const member = ns.getMemberSchema("list"); + expect(member.isMemberSchema()).toBe(true); + expect(member.isListSchema()).toBe(true); + expect(member.getSchema()).toBe(List); + expect(member.getMemberName()).toBe("list"); + }); + it("list value member", () => { + const member = ns.getMemberSchema("list").getValueSchema(); + expect(member.isMemberSchema()).toBe(true); + expect(member.isListSchema()).toBe(false); + expect(member.isStringSchema()).toBe(true); + expect(member.getSchema()).toBe(0); + expect(member.getMemberName()).toBe("member"); + }); + }); + describe("map member", () => { + it("map itself", () => { + const member = ns.getMemberSchema("map"); + expect(member.isMemberSchema()).toBe(true); + expect(member.isMapSchema()).toBe(true); + expect(member.getSchema()).toBe(Map); + expect(member.getMemberName()).toBe("map"); + }); + it("map key member", () => { + const member = ns.getMemberSchema("map").getKeySchema(); + expect(member.isMemberSchema()).toBe(true); + expect(member.isNumericSchema()).toBe(false); + expect(member.isStringSchema()).toBe(true); + expect(member.getSchema()).toBe(0); + expect(member.getMemberName()).toBe("key"); + }); + it("map value member", () => { + const member = ns.getMemberSchema("map").getValueSchema(); + expect(member.isMemberSchema()).toBe(true); + expect(member.isNumericSchema()).toBe(true); + expect(member.isStringSchema()).toBe(false); + expect(member.getSchema()).toBe(1); + expect(member.getMemberName()).toBe("value"); + }); + }); + describe("struct member", () => { + it("struct member", () => { + const member = ns.getMemberSchema("struct"); + expect(member.getName(true)).toBe("ack#Structure"); + expect(member.isMemberSchema()).toBe(true); + expect(member.isListSchema()).toBe(false); + expect(member.isMapSchema()).toBe(false); + expect(member.isStructSchema()).toBe(true); + expect(member.getMemberName()).toBe("struct"); + }); + it("nested recursion", () => { + expect(ns.getMemberSchema("struct").isStructSchema()).toBe(true); + expect(ns.getMemberSchema("struct").getMemberSchema("list").isListSchema()).toBe(true); + expect(ns.getMemberSchema("struct").getMemberSchema("map").isMapSchema()).toBe(true); + expect(ns.getMemberSchema("struct").getMemberSchema("struct").isStructSchema()).toBe(true); + + expect(ns.getMemberSchema("struct").getMemberSchema("struct").getMemberSchema("list").getName(true)).toBe( + ns.getMemberSchema("list").getName(true) + ); + }); + }); + }); + + describe("traits", () => { + const member: MemberSchema = [sim("ack", "SimpleString", 0, { idempotencyToken: 1 }), 0b0000_0001]; + const ns = NormalizedSchema.of(member, "member_name"); + + it("has merged traits", () => { + expect(ns.getMergedTraits()).toEqual({ + idempotencyToken: 1, + httpLabel: 1, + }); + }); + it("has member traits if it is a member", () => { + expect(ns.isMemberSchema()).toBe(true); + expect(ns.getMemberTraits()).toEqual({ + httpLabel: 1, + }); + }); + it("has own traits", () => { + expect(ns.getOwnTraits()).toEqual({ + idempotencyToken: 1, + }); + }); + }); +}); 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..8e0f688af78 --- /dev/null +++ b/packages/core/src/submodules/schema/schemas/NormalizedSchema.ts @@ -0,0 +1,415 @@ +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 both class instances, numeric sentinel values, and member schema pairs. + * Presents a consistent interface for interacting with polymorphic schema representations. + * + * @alpha + */ +export class NormalizedSchema implements INormalizedSchema { + private readonly name: string; + private readonly traits: SchemaTraits; + + private _isMemberSchema: boolean; + private schema: Exclude; + private memberTraits: SchemaTraits; + private normalizedTraits?: SchemaTraitsObject; + + /** + * @param ref - a polymorphic SchemaRef to be dereferenced/normalized. + * @param memberName - optional memberName if this NormalizedSchema should be considered a member schema. + */ + public constructor( + private 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.` + ); + } + } + + /** + * Static constructor that attempts to avoid wrapping a NormalizedSchema within another. + */ + 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; + } + + /** + * Creates a normalized member schema from the given schema and member name. + */ + private static memberFrom( + memberSchema: NormalizedSchema | [SchemaRef, SchemaTraits], + memberName: string + ): NormalizedSchema { + if (memberSchema instanceof NormalizedSchema) { + memberSchema.memberName = memberName; + memberSchema._isMemberSchema = true; + return memberSchema; + } + return new NormalizedSchema(memberSchema, memberName); + } + + /** + * @returns the underlying non-normalized schema. + */ + public getSchema(): Exclude { + if (this.schema instanceof NormalizedSchema) { + return (this.schema = this.schema.getSchema()); + } + if (this.schema instanceof SimpleSchema) { + return deref(this.schema.schemaRef) as Exclude; + } + return deref(this.schema) as Exclude; + } + + /** + * @param withNamespace - qualifies the name. + * @returns e.g. `MyShape` or `com.namespace#MyShape`. + */ + 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; + } + + /** + * @returns the member name if the schema is a member schema. + * @throws Error when the schema isn't a member schema. + */ + 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); + } + + /** + * boolean methods on this class help control flow in shape serialization and deserialization. + */ + public isListSchema(): boolean { + const inner = this.getSchema(); + if (typeof inner === "number") { + return inner >= SCHEMA.LIST_MODIFIER && inner < SCHEMA.MAP_MODIFIER; + } + return inner instanceof ListSchema; + } + + public isMapSchema(): boolean { + const inner = this.getSchema(); + if (typeof inner === "number") { + return inner >= SCHEMA.MAP_MODIFIER && inner <= 0b1111_1111; + } + 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; + } + + /** + * @returns own traits merged with member traits, where member traits of the same trait key take priority. + * This method is cached. + */ + public getMergedTraits(): SchemaTraitsObject { + if (this.normalizedTraits) { + return this.normalizedTraits; + } + this.normalizedTraits = { + ...this.getOwnTraits(), + ...this.getMemberTraits(), + }; + return this.normalizedTraits; + } + + /** + * @returns only the member traits. If the schema is not a member, this returns empty. + */ + public getMemberTraits(): SchemaTraitsObject { + return NormalizedSchema.translateTraits(this.memberTraits); + } + + /** + * @returns only the traits inherent to the shape or member target shape if this schema is a member. + * If there are any member traits they are excluded. + */ + public getOwnTraits(): SchemaTraitsObject { + return NormalizedSchema.translateTraits(this.traits); + } + + /** + * @returns the map's key's schema. Returns a dummy Document schema if this schema is a Document. + * + * @throws Error if the schema is not a Map or Document. + */ + 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"); + } + + /** + * @returns the schema of the map's value or list's member. + * Returns a dummy Document schema if this schema is a Document. + * + * @throws Error if the schema is not a Map, List, nor Document. + */ + 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.`); + } + + /** + * @returns the NormalizedSchema for the given member name. The returned instance will return true for `isMemberSchema()` + * and will have the member name given. + * @param member - which member to retrieve and wrap. + * + * @throws Error if member does not exist or the schema is neither a document nor structure. + * Note that errors are assumed to be structures and unions are considered structures for these purposes. + */ + public getMemberSchema(member: string): NormalizedSchema { + if (this.isStructSchema()) { + const struct = this.getSchema() as StructureSchema; + if (!(member in struct.members)) { + throw new Error( + `@smithy/core/schema - the schema ${this.getName(true)} does not have a member with name=${member}.` + ); + } + 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.`); + } + + /** + * @returns a map of member names to member schemas (normalized). + */ + 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 {}; + } + + /** + * @returns a last-resort human-readable name for the schema if it has no other identifiers. + */ + 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..e698af7804a --- /dev/null +++ b/packages/core/src/submodules/schema/schemas/OperationSchema.ts @@ -0,0 +1,37 @@ +import type { OperationSchema as IOperationSchema, SchemaRef, SchemaTraits } from "@smithy/types"; + +import { TypeRegistry } from "../TypeRegistry"; +import { Schema } from "./Schema"; + +/** + * This is used as a reference container for the input/output pair of schema, and for trait + * detection on the operation that may affect client protocol logic. + * + * @alpha + */ +export class OperationSchema extends Schema implements IOperationSchema { + public constructor( + public name: string, + public traits: SchemaTraits, + public input: SchemaRef, + public output: SchemaRef + ) { + super(name, traits); + } +} + +/** + * Factory for OperationSchema. + * @internal + */ +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..916e5644599 --- /dev/null +++ b/packages/core/src/submodules/schema/schemas/Schema.ts @@ -0,0 +1,13 @@ +import type { SchemaTraits, TraitsSchema } from "@smithy/types"; + +/** + * Abstract base for class-based Schema except NormalizedSchema. + * + * @alpha + */ +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..e1fe8be954a --- /dev/null +++ b/packages/core/src/submodules/schema/schemas/SimpleSchema.ts @@ -0,0 +1,31 @@ +import { SchemaRef, SchemaTraits, TraitsSchema } from "@smithy/types"; + +import { TypeRegistry } from "../TypeRegistry"; +import { Schema } from "./Schema"; + +/** + * Although numeric values exist for most simple schema, this class is used for cases where traits are + * attached to those schema, since a single number cannot easily represent both a schema and its traits. + * + * @alpha + */ +export class SimpleSchema extends Schema implements TraitsSchema { + public constructor( + public name: string, + public schemaRef: SchemaRef, + public traits: SchemaTraits + ) { + super(name, traits); + } +} + +/** + * Factory for simple schema class objects. + * + * @internal + */ +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..0e3a8465f7e --- /dev/null +++ b/packages/core/src/submodules/schema/schemas/StructureSchema.ts @@ -0,0 +1,44 @@ +import type { MemberSchema, SchemaRef, SchemaTraits, StructureSchema as IStructureSchema } from "@smithy/types"; + +import { TypeRegistry } from "../TypeRegistry"; +import { Schema } from "./Schema"; + +/** + * A structure schema has a known list of members. This is also used for unions. + * + * @alpha + */ +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]; + } + } +} + +/** + * Factory for StructureSchema. + * + * @internal + */ +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/schemas.spec.ts b/packages/core/src/submodules/schema/schemas/schemas.spec.ts new file mode 100644 index 00000000000..1762688867c --- /dev/null +++ b/packages/core/src/submodules/schema/schemas/schemas.spec.ts @@ -0,0 +1,140 @@ +import { SchemaRef, SchemaTraits } from "@smithy/types"; +import { describe, expect, test as it } from "vitest"; + +import { TypeRegistry } from "../TypeRegistry"; +import { error, ErrorSchema } from "./ErrorSchema"; +import { list, ListSchema } from "./ListSchema"; +import { map, MapSchema } from "./MapSchema"; +import { op, OperationSchema } from "./OperationSchema"; +import { Schema } from "./Schema"; +import { SCHEMA } from "./sentinels"; +import { sim, SimpleSchema } from "./SimpleSchema"; +import { struct, StructureSchema } from "./StructureSchema"; + +describe("schemas", () => { + describe("sentinels", () => { + it("should be constant", () => { + expect(SCHEMA).toEqual({ + 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 + }); + }); + }); + + describe(ErrorSchema.name, () => { + const schema = new ErrorSchema("ack#Error", 0, [], [], Error); + + it("is a StructureSchema", () => { + expect(schema).toBeInstanceOf(StructureSchema); + }); + + it("additionally defines an error constructor", () => { + expect(schema.ctor).toBeInstanceOf(Function); + expect(new schema.ctor()).toBeInstanceOf(schema.ctor); + }); + + it("has a factory and the factory registers the schema", () => { + expect(error("ack", "Error", 0, [], [], Error)).toEqual(schema); + expect(TypeRegistry.for("ack").getSchema(schema.name)).toEqual(schema); + }); + }); + describe(ListSchema.name, () => { + const schema = new ListSchema("ack#List", 0, 0); + it("is a Schema", () => { + expect(schema).toBeInstanceOf(Schema); + }); + it("has a value schema", () => { + expect(schema.valueSchema).toBe(0 as SchemaRef); + }); + it("has a factory and the factory registers the schema", () => { + expect(list("ack", "List", 0, 0)).toEqual(schema); + expect(TypeRegistry.for("ack").getSchema(schema.name)).toEqual(schema); + }); + }); + describe(MapSchema.name, () => { + const schema = new MapSchema("ack#Map", 0, 0, 1); + it("is a Schema", () => { + expect(schema).toBeInstanceOf(Schema); + }); + it("has a key and value schema", () => { + expect(schema.keySchema).toBe(0 as SchemaRef); + expect(schema.valueSchema).toBe(1 as SchemaRef); + }); + it("has a factory and the factory registers the schema", () => { + expect(map("ack", "Map", 0, 0, 1)).toEqual(schema); + expect(TypeRegistry.for("ack").getSchema(schema.name)).toEqual(schema); + }); + }); + describe(OperationSchema.name, () => { + const schema = new OperationSchema("ack#Operation", 0, "unit", "unit"); + it("is a Schema", () => { + expect(schema).toBeInstanceOf(Schema); + }); + it("has an input and output schema", () => { + expect(schema.input).toEqual("unit"); + expect(schema.output).toEqual("unit"); + }); + it("has a factory and the factory registers the schema", () => { + expect(op("ack", "Operation", 0, "unit", "unit")).toEqual(schema); + expect(TypeRegistry.for("ack").getSchema(schema.name)).toEqual(schema); + }); + }); + describe(Schema.name, () => { + const schema = new (class extends Schema { + public constructor(name: string, traits: SchemaTraits) { + super(name, traits); + } + })("ack#Abstract", { + a: 0, + b: 1, + }); + it("has a name", () => { + expect(schema.name).toBe("ack#Abstract"); + }); + it("has traits", () => { + expect(schema.traits).toEqual({ + a: 0, + b: 1, + }); + }); + }); + describe(SimpleSchema.name, () => { + const schema = new SimpleSchema("ack#Simple", 0, 0); + it("is a Schema", () => { + expect(schema).toBeInstanceOf(Schema); + }); + it("has a factory and the factory registers the schema", () => { + expect(sim("ack", "Simple", 0, 0)).toEqual(schema); + expect(TypeRegistry.for("ack").getSchema(schema.name)).toEqual(schema); + }); + }); + describe(StructureSchema.name, () => { + const schema = new StructureSchema("ack#Structure", 0, ["a", "b", "c"], [0, 1, 2]); + it("is a Schema", () => { + expect(schema).toBeInstanceOf(Schema); + }); + it("has member schemas", () => { + expect(schema.members).toEqual({ + a: [0 as SchemaRef, 0 as SchemaTraits], + b: [1 as SchemaRef, 0 as SchemaTraits], + c: [2 as SchemaRef, 0 as SchemaTraits], + }); + }); + it("has a factory and the factory registers the schema", () => { + expect(struct("ack", "Structure", 0, ["a", "b", "c"], [0, 1, 2])).toEqual(schema); + expect(TypeRegistry.for("ack").getSchema(schema.name)).toEqual(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..76089e1e77d --- /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. + * @alpha + */ +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/tsconfig.cjs.json b/packages/core/tsconfig.cjs.json index a5bbf2547b8..5804ea19cf1 100644 --- a/packages/core/tsconfig.cjs.json +++ b/packages/core/tsconfig.cjs.json @@ -6,7 +6,8 @@ "paths": { "@smithy/core/cbor": ["./src/submodules/cbor/index.ts"], "@smithy/core/protocols": ["./src/submodules/protocols/index.ts"], - "@smithy/core/serde": ["./src/submodules/serde/index.ts"] + "@smithy/core/serde": ["./src/submodules/serde/index.ts"], + "@smithy/core/schema": ["./src/submodules/schema/index.ts"] } }, "extends": "../../tsconfig.cjs.json", diff --git a/packages/core/tsconfig.es.json b/packages/core/tsconfig.es.json index 95225ee3300..6cb3631b45e 100644 --- a/packages/core/tsconfig.es.json +++ b/packages/core/tsconfig.es.json @@ -7,7 +7,8 @@ "paths": { "@smithy/core/cbor": ["./src/submodules/cbor/index.ts"], "@smithy/core/protocols": ["./src/submodules/protocols/index.ts"], - "@smithy/core/serde": ["./src/submodules/serde/index.ts"] + "@smithy/core/serde": ["./src/submodules/serde/index.ts"], + "@smithy/core/schema": ["./src/submodules/schema/index.ts"] } }, "extends": "../../tsconfig.es.json", diff --git a/packages/core/tsconfig.types.json b/packages/core/tsconfig.types.json index 5400575ea0b..42ce68798f6 100644 --- a/packages/core/tsconfig.types.json +++ b/packages/core/tsconfig.types.json @@ -6,7 +6,8 @@ "paths": { "@smithy/core/cbor": ["./src/submodules/cbor/index.ts"], "@smithy/core/protocols": ["./src/submodules/protocols/index.ts"], - "@smithy/core/serde": ["./src/submodules/serde/index.ts"] + "@smithy/core/serde": ["./src/submodules/serde/index.ts"], + "@smithy/core/schema": ["./src/submodules/schema/index.ts"] } }, "extends": "../../tsconfig.types.json", diff --git a/packages/middleware-serde/src/deserializerMiddleware.ts b/packages/middleware-serde/src/deserializerMiddleware.ts index 5346375d472..6e11ee84f12 100644 --- a/packages/middleware-serde/src/deserializerMiddleware.ts +++ b/packages/middleware-serde/src/deserializerMiddleware.ts @@ -13,6 +13,7 @@ import { /** * @internal + * @deprecated will be replaced by schemaSerdePlugin from core/schema. */ export const deserializerMiddleware = ( diff --git a/packages/middleware-serde/src/serdePlugin.ts b/packages/middleware-serde/src/serdePlugin.ts index cda2369799c..fb40c1d502d 100644 --- a/packages/middleware-serde/src/serdePlugin.ts +++ b/packages/middleware-serde/src/serdePlugin.ts @@ -16,6 +16,9 @@ import { import { deserializerMiddleware } from "./deserializerMiddleware"; import { serializerMiddleware } from "./serializerMiddleware"; +/** + * @deprecated will be replaced by schemaSerdePlugin from core/schema. + */ export const deserializerMiddlewareOption: DeserializeHandlerOptions = { name: "deserializerMiddleware", step: "deserialize", @@ -23,6 +26,9 @@ export const deserializerMiddlewareOption: DeserializeHandlerOptions = { override: true, }; +/** + * @deprecated will be replaced by schemaSerdePlugin from core/schema. + */ export const serializerMiddlewareOption: SerializeHandlerOptions = { name: "serializerMiddleware", step: "serialize", @@ -46,7 +52,7 @@ export type V1OrV2Endpoint = { /** * @internal - * + * @deprecated will be replaced by schemaSerdePlugin from core/schema. */ export function getSerdePlugin< InputType extends object = any, diff --git a/packages/middleware-serde/src/serializerMiddleware.ts b/packages/middleware-serde/src/serializerMiddleware.ts index ce8aea725dc..917ff41841d 100644 --- a/packages/middleware-serde/src/serializerMiddleware.ts +++ b/packages/middleware-serde/src/serializerMiddleware.ts @@ -15,6 +15,7 @@ import type { V1OrV2Endpoint } from "./serdePlugin"; /** * @internal + * @deprecated will be replaced by schemaSerdePlugin from core/schema. */ export const serializerMiddleware = ( diff --git a/packages/types/src/schema/schema.ts b/packages/types/src/schema/schema.ts index 86be66ad52a..a75d35bff3c 100644 --- a/packages/types/src/schema/schema.ts +++ b/packages/types/src/schema/schema.ts @@ -195,11 +195,9 @@ export interface OperationSchema extends TraitsSchema { /** * Normalization wrapper for various schema data objects. - * @internal + * @public */ -export interface NormalizedSchema extends TraitsSchema { - name: string; - traits: SchemaTraits; +export interface NormalizedSchema { getSchema(): Schema; getName(): string | undefined; isMemberSchema(): boolean;