UNPKG

@nestia/sdk

Version:

Nestia SDK and Swagger generator

503 lines (472 loc) 19.4 kB
// nestia-internal namespace of utility functions that fill the surface // the legacy `@typia/core` 12.x package exposed at JS runtime. typia v13 // dropped that package because the equivalent logic lives in the Go-side // transform; the nestia native transform now embeds the pre-computed // results next to each metadata blob (see `packages/core/native/cmd/ // ttsc-nestia/sdk_transform.go`), and the helpers here are thin // consumers — no runtime class wrapping, no vendored fork. // // `MetadataComponents.from` is the only utility that performs a real // runtime transformation: it walks the aggregated `IMetadataComponents` // and indexes each entry by name so the analyzer can resolve // cross-reference lookups in O(1). Everything else either reads a // pre-baked field or short-circuits because typia's compile-time // transform already enforces the invariant. import type { IJsonSchemaCollection, IMetadataComponents, IMetadataSchema, IMetadataTypeTag, OpenApi, } from "@typia/interface"; // Module augmentation: typia v13's `IReference` carries only a symbolic // `name` + tags, but `MetadataSchema.from(plain, dictionary)` mutates // each reference to also hold the resolved target type so downstream // sdk code can keep its `ref.type!.value` / `ref.type!.elements` access // pattern from the legacy `@typia/core` `MetadataArray` / `MetadataTuple` // classes. The field is optional because it is only populated after a // `MetadataSchema.from` walk against a dictionary. declare module "@typia/interface" { namespace IMetadataSchema { interface IReference { type?: IArrayType | ITupleType | IObjectType | IAliasType; } } } /** * Plain `IMetadataSchema` augmented with the fields the nestia transform * pre-bakes. `size`, `name`, and `empty` replace the methods the legacy * `MetadataSchema` class exposed; `jsonSchema` is the OpenAPI 3.1 conversion * typia's Go-side produces but does not expose to JS at runtime. * * Fields are declared optional so that nested `IMetadataSchema` values * (`metadata.rest`, `metadata.escaped.original`, `IObjectType.value`, …) — * which are not top-level route inputs and therefore do not carry the pre-baked * overlay — still satisfy the type when passed through legacy utilities. The * utilities read the fields via the optional accessor and fall back when * absent. */ export interface IReflectMetadata extends IMetadataSchema { size?: number; name?: string; empty?: boolean; jsonSchema?: IReflectJsonSchema; } export interface IReflectJsonSchema { version: "3.0" | "3.1"; components: OpenApi.IComponents; schema: OpenApi.IJsonSchema; } /** * Cross-reference dictionary produced by `MetadataComponents.from`. Each map is * keyed by the entry's `.name`, matching the lookup pattern the legacy * `MetadataComponents.dictionary` getter offered. */ export interface IMetadataDictionary { objects: Map<string, IMetadataSchema.IObjectType>; aliases: Map<string, IMetadataSchema.IAliasType>; arrays: Map<string, IMetadataSchema.IArrayType>; tuples: Map<string, IMetadataSchema.ITupleType>; } // --------------------------------------------------------------------- // Type aliases — the legacy class names ↔ typia v13 plain interfaces. // --------------------------------------------------------------------- export type MetadataSchema = IMetadataSchema; export type MetadataComponents = IMetadataComponents & { dictionary: IMetadataDictionary; }; export type MetadataAliasType = IMetadataSchema.IAliasType; export type MetadataArrayType = IMetadataSchema.IArrayType; export type MetadataTupleType = IMetadataSchema.ITupleType; export type MetadataObjectType = IMetadataSchema.IObjectType; export type MetadataAtomic = IMetadataSchema.IAtomic; /** * Flattened constant-value shape: typia v13 splits `IConstant.IValue<T>` by the * atomic discriminator, but sdk's literal writer just needs the runtime `value` * payload, so collapse the union to a single ergonomic shape. */ export interface MetadataConstantValue { value: string | number | bigint | boolean; tags: IMetadataTypeTag[][]; } export type MetadataEscaped = IMetadataSchema.IEscaped; export type MetadataProperty = IMetadataSchema.IProperty; /** * Reference to a named array/tuple/object/alias type. typia v13's plain * `IReference` carries only the symbolic `name` + tags; the legacy class * additionally exposed `.type` as a getter that resolved against the * dictionary. `MetadataSchema.from` walks the metadata tree once and attaches * the resolved `.type` field so downstream sdk code can keep its `ref.type` * access pattern. */ export type MetadataArray = IMetadataSchema.IReference & { type: MetadataArrayType; }; export type MetadataTuple = IMetadataSchema.IReference & { type: MetadataTupleType; }; export type MetadataObject = IMetadataSchema.IReference & { type: MetadataObjectType; }; export type MetadataAlias = IMetadataSchema.IReference & { type: MetadataAliasType; }; // --------------------------------------------------------------------- // Metadata utility functions — read pre-baked fields, no class wrapping. // --------------------------------------------------------------------- /** `MetadataSchema.size()` → reads the pre-baked `size` field. */ export const sizeOf = (m: IMetadataSchema): number => (m as IReflectMetadata).size ?? 0; /** `MetadataSchema.getName()` → reads the pre-baked `name` field. */ export const nameOf = (m: IMetadataSchema): string => (m as IReflectMetadata).name ?? ""; /** `MetadataSchema.empty()` → reads the pre-baked `empty` field. */ export const emptyOf = (m: IMetadataSchema): boolean => (m as IReflectMetadata).empty ?? false; /** * Equivalent of the legacy `MetadataSchema.isSoleLiteral()` method: `true` when * the schema represents exactly one constant literal value and nothing else. * Used by sdk's type printer to fall back to literal emission instead of a * union. */ export const isSoleLiteralOf = (m: IMetadataSchema): boolean => { if (m.any) return false; if (m.nullable) return false; if (m.functions.length !== 0) return false; if (m.atomics.length !== 0) return false; if (m.templates.length !== 0) return false; if (m.arrays.length !== 0) return false; if (m.tuples.length !== 0) return false; if (m.objects.length !== 0) return false; if (m.aliases.length !== 0) return false; if (m.natives.length !== 0) return false; if (m.sets.length !== 0) return false; if (m.maps.length !== 0) return false; if (m.rest !== null) return false; if (m.escaped !== null) return false; if (m.constants.length !== 1) return false; return m.constants[0]!.values.length === 1; }; // --------------------------------------------------------------------- // `MetadataComponents.from(plain)` — namespace utility, not a class. // --------------------------------------------------------------------- export namespace MetadataComponents { export const from = (plain: IMetadataComponents): MetadataComponents => { const dictionary: IMetadataDictionary = { objects: new Map(plain.objects.map((o) => [o.name, o])), aliases: new Map(plain.aliases.map((a) => [a.name, a])), arrays: new Map(plain.arrays.map((a) => [a.name, a])), tuples: new Map(plain.tuples.map((t) => [t.name, t])), }; return Object.assign({}, plain, { dictionary }); }; } // --------------------------------------------------------------------- // `MetadataSchema.from(plain, _dictionary)` — passthrough. // --------------------------------------------------------------------- export namespace MetadataSchema { /** * Walks the metadata tree and attaches the resolved `.type` field to every * `IReference` it encounters, using the supplied dictionary as the lookup * index. This is idempotent — references whose `.type` has already been * resolved are left alone — and mutates the input, matching the in-place * resolution model `@typia/core` 12.x used. */ export const from = ( plain: IMetadataSchema, dictionary?: IMetadataDictionary, ): IMetadataSchema => { if (dictionary !== undefined) { attachTypes(plain, dictionary, new WeakSet()); } return plain; }; } /** * The walk tracks visited _targets_ (the IObjectType / IArrayType / … instances * reached through the dictionary), not the wrapper schemas. Wrapper schemas are * reconstructed on the JS side and are not shared across recursive references, * so a `visited<IMetadataSchema>` set never matches and the walk would recurse * forever on cycles like `interface Node { children: Node[] }`. */ const attachTypes = ( schema: IMetadataSchema | null | undefined, dict: IMetadataDictionary, visited: WeakSet<object>, ): void => { if (schema === null || schema === undefined) return; attachReferences(schema.arrays, dict.arrays, visited, (target) => attachTypes(target.value, dict, visited), ); attachReferences(schema.tuples, dict.tuples, visited, (target) => { for (const elem of target.elements) attachTypes(elem, dict, visited); }); attachReferences(schema.objects, dict.objects, visited, (target) => { for (const prop of target.properties) attachTypes(prop.value, dict, visited); }); attachReferences(schema.aliases, dict.aliases, visited, (target) => attachTypes(target.value, dict, visited), ); for (const fn of schema.functions ?? []) { for (const param of fn.parameters) attachTypes(param.type, dict, visited); attachTypes(fn.output, dict, visited); } for (const set of schema.sets ?? []) attachTypes(set.value, dict, visited); for (const map of schema.maps ?? []) { attachTypes(map.key, dict, visited); attachTypes(map.value, dict, visited); } if (schema.rest) attachTypes(schema.rest, dict, visited); if (schema.escaped) { attachTypes(schema.escaped.original, dict, visited); attachTypes(schema.escaped.returns, dict, visited); } }; const attachReferences = <Target extends { name: string }>( refs: ReadonlyArray<IMetadataSchema.IReference> | undefined, index: Map<string, Target>, visited: WeakSet<object>, walk: (target: Target) => void, ): void => { if (!refs) return; for (const ref of refs) { const mutable = ref as IMetadataSchema.IReference & { type?: Target }; if (mutable.type === undefined) { const target = index.get(ref.name); if (target !== undefined) (mutable as { type?: Target }).type = target; } if (mutable.type !== undefined && visited.has(mutable.type) === false) { visited.add(mutable.type); walk(mutable.type); } } }; // --------------------------------------------------------------------- // Validators — the typia native transform already enforces most of the // invariants the legacy `@typia/core` 12.x helpers re-checked at runtime, // but a few JSON-serialization constraints (bare `bigint` payloads, etc.) // are SDK-policy choices that the SDK still has to flag itself. // --------------------------------------------------------------------- export namespace MetadataFactory { export interface IExplore { object: IMetadataSchema.IObjectType | null; property: string | null; parameter: string | null; output: boolean; } export interface IError { name: string; explore: IExplore; messages: string[]; } export type Validator = (props: { metadata: IMetadataSchema; explore: IExplore; }) => string[]; /** * Walks the metadata tree once, invoking the provided validator on each * visited node, and accumulates the produced messages into `IError` entries. * The walk skips back-edges through references so cyclic structures * terminate. This is a faithful reimplementation of the legacy `@typia/core` * walker, kept lean: the typia native transform has already validated * structural invariants, so the validator is only called for SDK-side policy * checks (JSON-serializability, query/header atomic-only rules, …). */ export const validate = (props: { options?: unknown; functor: Validator; metadata: IMetadataSchema; }): IError[] => { const errors: IError[] = []; // Tracks visited *targets* (IObjectType / IArrayType / IAliasType / // ITupleType) so recursive schemas like `interface Node { children: // Node[] }` terminate. Wrapper IMetadataSchema instances are not // shared across recursive references, so they cannot stand in for // the visit marker. const visited = new WeakSet<object>(); const visit = (metadata: IMetadataSchema, explore: IExplore): void => { const messages = props.functor({ metadata, explore }); if (messages.length) errors.push({ name: nameOf(metadata), explore, messages }); for (const obj of metadata.objects) { const type = ( obj as IMetadataSchema.IReference & { type?: IMetadataSchema.IObjectType; } ).type; if (type === undefined || visited.has(type)) continue; visited.add(type); for (const prop of type.properties) visit(prop.value, { object: type, property: nameOf(prop.key) || String(prop.key.constants[0]?.values[0]?.value ?? ""), parameter: null, output: explore.output, }); } for (const arr of metadata.arrays) { const type = ( arr as IMetadataSchema.IReference & { type?: IMetadataSchema.IArrayType; } ).type; if (type === undefined || visited.has(type)) continue; visited.add(type); visit(type.value, explore); } for (const tuple of metadata.tuples) { const type = ( tuple as IMetadataSchema.IReference & { type?: IMetadataSchema.ITupleType; } ).type; if (type === undefined || visited.has(type)) continue; visited.add(type); for (const elem of type.elements) visit(elem, explore); } for (const alias of metadata.aliases) { const type = ( alias as IMetadataSchema.IReference & { type?: IMetadataSchema.IAliasType; } ).type; if (type === undefined || visited.has(type)) continue; visited.add(type); visit(type.value, explore); } if (metadata.escaped) { visit(metadata.escaped.original, explore); visit(metadata.escaped.returns, explore); } }; visit(props.metadata, { object: null, property: null, parameter: null, output: false, }); return errors; }; } export namespace JsonMetadataFactory { /** * Rejects metadata that cannot be losslessly JSON-serialized. The typia * native runtime already screens out most structurally invalid types, so this * only adds the JSON-policy bans the legacy `@typia/core` walker enforced — * bare `bigint` payloads, function-typed properties, and `Map` / `Set` * containers that have no canonical JSON representation. */ export const validate: MetadataFactory.Validator = (props) => { const messages: string[] = []; if (props.metadata.atomics.some((a) => a.type === "bigint")) messages.push("does not allow bigint type in JSON."); if (props.metadata.functions.length !== 0) messages.push("does not allow function type in JSON."); if (props.metadata.sets.length !== 0) messages.push("does not allow Set type in JSON."); if (props.metadata.maps.length !== 0) messages.push("does not allow Map type in JSON."); return messages; }; } export namespace HttpQueryProgrammer { export const validate: MetadataFactory.Validator = () => []; } export namespace HttpHeadersProgrammer { export const validate: MetadataFactory.Validator = () => []; } export namespace HttpParameterProgrammer { export const validate: MetadataFactory.Validator = () => []; } export namespace HttpFormDataProgrammer { export const validate: MetadataFactory.Validator = () => []; } // --------------------------------------------------------------------- // `JsonSchemasProgrammer.writeSchemas` — consumes the per-metadata // pre-baked `jsonSchema` field the nestia transform emits. // --------------------------------------------------------------------- export namespace JsonSchemasProgrammer { /** * Consumes the per-metadata `jsonSchema` field the nestia transform * pre-bakes. Top-level route metadata (success / parameter / exception) * always carries a baked schema; for nested metadata (object property values * reached by the decomposed-query path), the bake is absent and this function * falls back to a minimal JS-side converter that handles the schema shapes * decompose actually emits — atomics, constants, templates, arrays of those, * named references — without re-implementing the typia native programmer * wholesale. */ export const writeSchemas = (props: { version: "3.0" | "3.1"; metadatas: readonly IMetadataSchema[]; }): IJsonSchemaCollection => { const components: OpenApi.IComponents = { schemas: {} }; const schemas: OpenApi.IJsonSchema[] = []; for (const m of props.metadatas) { const baked = (m as IReflectMetadata).jsonSchema; if (baked !== undefined) { schemas.push(baked.schema); Object.assign( (components.schemas ??= {}), baked.components.schemas ?? {}, ); } else { schemas.push(schemaFromMetadata(m)); } } return { version: props.version, components, schemas, } as IJsonSchemaCollection; }; } const schemaFromMetadata = (m: IMetadataSchema): OpenApi.IJsonSchema => { const union: OpenApi.IJsonSchema[] = []; if (m.nullable) union.push({ type: "null" } as OpenApi.IJsonSchema); for (const atomic of m.atomics) union.push(schemaFromAtomic(atomic) as OpenApi.IJsonSchema); for (const constant of m.constants) for (const value of constant.values) union.push({ const: value.value, } as unknown as OpenApi.IJsonSchema); for (const tpl of m.templates) { union.push({ type: "string" } as OpenApi.IJsonSchema); void tpl; } for (const arr of m.arrays) { const inner = ( arr as IMetadataSchema.IReference & { type?: IMetadataSchema.IArrayType; } ).type; union.push({ type: "array", items: inner ? schemaFromMetadata(inner.value) : ({} as OpenApi.IJsonSchema), } as unknown as OpenApi.IJsonSchema); } for (const obj of m.objects) union.push({ $ref: `#/components/schemas/${obj.name}`, } as unknown as OpenApi.IJsonSchema); for (const alias of m.aliases) union.push({ $ref: `#/components/schemas/${alias.name}`, } as unknown as OpenApi.IJsonSchema); if (m.any || union.length === 0) return {} as OpenApi.IJsonSchema; if (union.length === 1) return union[0]!; return { oneOf: union } as unknown as OpenApi.IJsonSchema; }; const schemaFromAtomic = (atomic: IMetadataSchema.IAtomic): unknown => { if (atomic.type === "boolean") return { type: "boolean" }; if (atomic.type === "bigint" || atomic.type === "number") return { type: atomic.type === "bigint" ? "integer" : "number" }; if (atomic.type === "string") return { type: "string" }; return {}; };