diff --git a/dev/typescript/presets.ts b/dev/typescript/presets.ts index 1b7703f9f..6ca9bda5e 100644 --- a/dev/typescript/presets.ts +++ b/dev/typescript/presets.ts @@ -10,10 +10,6 @@ export const presets = { zod({ metadata: true }), tanstackReactQuery({ queryKeys: { tags: true } }), ], - minimal: () => [ - /** Just types, nothing else */ - typescript(), - ], sdk: () => [ /** SDK with types */ typescript(), @@ -31,6 +27,10 @@ export const presets = { sdk(), tanstackReactQuery({ queryKeys: { tags: true } }), ], + types: () => [ + /** Just types, nothing else */ + typescript(), + ], validated: () => [ /** SDK + Zod validation */ typescript(), diff --git a/packages/openapi-python/src/plugins/pydantic/shared/meta.ts b/packages/openapi-python/src/plugins/pydantic/shared/meta.ts index f103a31c5..4d66e3500 100644 --- a/packages/openapi-python/src/plugins/pydantic/shared/meta.ts +++ b/packages/openapi-python/src/plugins/pydantic/shared/meta.ts @@ -17,8 +17,6 @@ export function defaultMeta(schema: IR.SchemaObject): PydanticMeta { /** * Composes metadata from child results. * - * Automatically propagates hasForwardReference, nullable, readonly from children. - * * @param children - Results from walking child schemas * @param overrides - Explicit overrides (e.g., from parent schema) */ diff --git a/packages/openapi-python/src/plugins/pydantic/shared/processor.ts b/packages/openapi-python/src/plugins/pydantic/shared/processor.ts index 4054f076e..d95ebf915 100644 --- a/packages/openapi-python/src/plugins/pydantic/shared/processor.ts +++ b/packages/openapi-python/src/plugins/pydantic/shared/processor.ts @@ -1,17 +1,17 @@ -import type { - IR, - NamingConfig, - SchemaProcessorContext, - SchemaProcessorResult, -} from '@hey-api/shared'; +import type { IR, NamingConfig, SchemaProcessorContext } from '@hey-api/shared'; import type { PydanticPlugin } from '../types'; +import type { PydanticFinal } from './types'; export type ProcessorContext = SchemaProcessorContext & { + /** Whether to export the result (default: true) */ + export?: boolean; naming: NamingConfig; /** The plugin instance. */ plugin: PydanticPlugin['Instance']; schema: IR.SchemaObject; }; -export type ProcessorResult = SchemaProcessorResult; +export type ProcessorResult = { + process: (ctx: ProcessorContext) => PydanticFinal | void; +}; diff --git a/packages/openapi-python/src/plugins/pydantic/v2/processor.ts b/packages/openapi-python/src/plugins/pydantic/v2/processor.ts index 32da0f01c..2150856c4 100644 --- a/packages/openapi-python/src/plugins/pydantic/v2/processor.ts +++ b/packages/openapi-python/src/plugins/pydantic/v2/processor.ts @@ -42,10 +42,12 @@ export function createProcessor(plugin: PydanticPlugin['Instance']): ProcessorRe return ctx.schema; } - function process(ctx: ProcessorContext): void { + function process(ctx: ProcessorContext): PydanticFinal | void { if (!processor.markEmitted(ctx.path)) return; - processor.withContext({ anchor: ctx.namingAnchor, tags: ctx.tags }, () => { + const shouldExport = ctx.export !== false; + + return processor.withContext({ anchor: ctx.namingAnchor, tags: ctx.tags }, () => { const visitor = createVisitor({ schemaExtractor: extractor }); const walk = createSchemaWalker(visitor); @@ -59,7 +61,12 @@ export function createProcessor(plugin: PydanticPlugin['Instance']): ProcessorRe plugin, }) as PydanticFinal; - exportAst({ ...ctx, final, plugin }); + if (shouldExport) { + exportAst({ ...ctx, final, plugin }); + return; + } + + return final; }); } diff --git a/packages/openapi-ts/src/plugins/@hey-api/sdk/shared/operation.ts b/packages/openapi-ts/src/plugins/@hey-api/sdk/shared/operation.ts index a103ac78f..b1486cc05 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/sdk/shared/operation.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/sdk/shared/operation.ts @@ -1,5 +1,4 @@ import type { SymbolMeta } from '@hey-api/codegen-core'; -import { refs } from '@hey-api/codegen-core'; import type { IR } from '@hey-api/shared'; import { statusCodeToGroup } from '@hey-api/shared'; @@ -108,15 +107,9 @@ export function operationParameters({ isParametersRequired = true; } flatParams.prop(parameter.name, (p) => - p.required(parameter.isRequired).type( - pluginTypeScript.api.schemaToType({ - plugin: pluginTypeScript, - schema: parameter.schema, - state: refs({ - path: [], - }), - }), - ), + p + .required(parameter.isRequired) + .type(pluginTypeScript.api.schemaToType(pluginTypeScript, parameter.schema)), ); } diff --git a/packages/openapi-ts/src/plugins/@hey-api/typescript/api.ts b/packages/openapi-ts/src/plugins/@hey-api/typescript/api.ts index d1edbe6a9..15c08db07 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/typescript/api.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/typescript/api.ts @@ -1,12 +1,38 @@ -import type { MaybeTsDsl, TypeTsDsl } from '../../../ts-dsl'; -import { irSchemaToAstV1 } from './v1/api'; +import type { IR } from '@hey-api/shared'; + +import { $ } from '../../../ts-dsl'; +import type { TypeScriptResult } from './shared/types'; +import type { HeyApiTypeScriptPlugin } from './types'; +import { createProcessor } from './v1/processor'; export type IApi = { - schemaToType: (args: Parameters[0]) => MaybeTsDsl; + schemaToType: ( + plugin: HeyApiTypeScriptPlugin['Instance'], + schema: IR.SchemaObject, + ) => TypeScriptResult['type']; }; export class Api implements IApi { - schemaToType(args: Parameters[0]): MaybeTsDsl { - return irSchemaToAstV1(args); + schemaToType( + plugin: HeyApiTypeScriptPlugin['Instance'], + schema: IR.SchemaObject, + ): TypeScriptResult['type'] { + const processor = createProcessor(plugin); + const result = processor.process({ + export: false, + meta: { + resource: 'definition', + resourceId: '', + }, + naming: plugin.config.definitions, + path: [], + plugin, + schema, + }); + + if (!result) { + return $.type(plugin.config.topType); + } + return result.type; } } diff --git a/packages/openapi-ts/src/plugins/@hey-api/typescript/shared/export.ts b/packages/openapi-ts/src/plugins/@hey-api/typescript/shared/export.ts index c36478054..45ca6705b 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/typescript/shared/export.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/typescript/shared/export.ts @@ -1,209 +1,223 @@ -import { fromRef } from '@hey-api/codegen-core'; import type { IR } from '@hey-api/shared'; -import { applyNaming, toCase } from '@hey-api/shared'; -import { pathToJsonPointer, refToName } from '@hey-api/shared'; +import { applyNaming, pathToName, toCase } from '@hey-api/shared'; +import { pathToJsonPointer } from '@hey-api/shared'; import { createSchemaComment } from '../../../../plugins/shared/utils/schema'; -import type { MaybeTsDsl, TypeTsDsl } from '../../../../ts-dsl'; import { $, regexp } from '../../../../ts-dsl'; import type { HeyApiTypeScriptPlugin } from '../types'; -import type { IrSchemaToAstOptions } from './types'; +import type { ProcessorContext } from './processor'; +import type { TypeScriptFinal } from './types'; -const schemaToEnumObject = ({ +function resolveEnumKey({ + baseName, + duplicateAttempt, plugin, - schema, }: { + baseName: string; + duplicateAttempt: number; plugin: HeyApiTypeScriptPlugin['Instance']; - schema: IR.SchemaObject; -}) => { - const keyCounts: Record = {}; - const typeofItems: Array< - 'bigint' | 'boolean' | 'function' | 'number' | 'object' | 'string' | 'symbol' | 'undefined' - > = []; - - const obj = (schema.items ?? []).map((item, index) => { - const typeOfItemConst = typeof item.const; - - if (!typeofItems.includes(typeOfItemConst)) { - // track types of enum values because some modes support - // only enums with string and number types - typeofItems.push(typeOfItemConst); - } +}): string { + let key = toCase(baseName, plugin.config.enums.case, { + stripLeadingSeparators: false, + }); - let key: string | undefined; - if (item.title) { - key = item.title; - } else if (typeOfItemConst === 'number' || typeOfItemConst === 'string') { - key = `${item.const}`; - } else if (typeOfItemConst === 'boolean') { - key = item.const ? 'true' : 'false'; - } else if (item.const === null) { - key = 'null'; - } else { - key = `${index}`; - } + regexp.number.lastIndex = 0; + if ( + regexp.number.test(key) && + plugin.config.enums.enabled && + (plugin.config.enums.mode === 'typescript' || plugin.config.enums.mode === 'typescript-const') + ) { + key = `_${key}`; + } - if (key) { - key = toCase(key, plugin.config.enums.case, { - stripLeadingSeparators: false, + if (duplicateAttempt > 0) { + const nameConflictResolver = plugin.context.config.output?.nameConflictResolver; + if (nameConflictResolver) { + const resolvedName = nameConflictResolver({ + attempt: duplicateAttempt, + baseName: key, }); - - regexp.number.lastIndex = 0; - // TypeScript enum keys cannot be numbers - if ( - regexp.number.test(key) && - plugin.config.enums.enabled && - (plugin.config.enums.mode === 'typescript' || - plugin.config.enums.mode === 'typescript-const') - ) { - key = `_${key}`; - } - - const keyCount = (keyCounts[key] ?? 0) + 1; - keyCounts[key] = keyCount; - - // avoid collision - if (keyCount > 1) { - const nameConflictResolver = plugin.context.config.output?.nameConflictResolver; - if (nameConflictResolver) { - const resolvedName = nameConflictResolver({ - attempt: keyCount - 1, // 0-based index - baseName: key, - }); - if (resolvedName !== null) { - key = resolvedName; - } else { - key = `${key}${keyCount}`; - } - } else { - key = `${key}${keyCount}`; - } + if (resolvedName !== null) { + key = resolvedName; + } else { + key = `${key}${duplicateAttempt + 1}`; } + } else { + key = `${key}${duplicateAttempt + 1}`; } - return { - key, - schema: item, - }; - }); + } - return { - obj, - typeofItems, - }; -}; + return key; +} -export const exportType = ({ +function buildEnumExport({ + enumData, + name, plugin, + resourceId, schema, - state, - type, -}: IrSchemaToAstOptions & { +}: { + enumData: TypeScriptFinal['enumData']; + name: string; + plugin: HeyApiTypeScriptPlugin['Instance']; + resourceId: string; schema: IR.SchemaObject; - type: MaybeTsDsl; -}) => { - const $ref = pathToJsonPointer(fromRef(state.path)); - - // root enums have an additional export - if (schema.type === 'enum' && plugin.config.enums.enabled) { - const enumObject = schemaToEnumObject({ plugin, schema }); - - if (plugin.config.enums.mode === 'javascript') { - // JavaScript enums might want to ignore null values - if (plugin.config.enums.constantsIgnoreNull && enumObject.typeofItems.includes('object')) { - enumObject.obj = enumObject.obj.filter((item) => item.schema.const !== null); - } +}): boolean { + if (!enumData || enumData.mode === 'type') return false; - const symbolObject = plugin.symbol(applyNaming(refToName($ref), plugin.config.definitions), { - meta: { - category: 'utility', - path: fromRef(state.path), - resource: 'definition', - resourceId: $ref, - tags: fromRef(state.tags), - tool: 'typescript', - }, - }); - const objectNode = $.const(symbolObject) - .export() - .$if(plugin.config.comments && createSchemaComment(schema), (c, v) => c.doc(v)) - .assign( - $.object( - ...enumObject.obj.map((item) => - $.prop({ kind: 'prop', name: item.key }) + const mode = enumData.mode; + const items = enumData.items; + const duplicateCounts: Record = {}; + + const itemsWithAttempts = items.map((item, index) => { + const candidateKey = toCase(item.key, plugin.config.enums.case, { + stripLeadingSeparators: false, + }); + + regexp.number.lastIndex = 0; + const baseKey = + regexp.number.test(candidateKey) && + plugin.config.enums.enabled && + (plugin.config.enums.mode === 'typescript' || plugin.config.enums.mode === 'typescript-const') + ? `_${candidateKey}` + : candidateKey; + + const duplicateAttempt = duplicateCounts[baseKey] ?? 0; + duplicateCounts[baseKey] = duplicateAttempt + 1; + + return { + duplicateAttempt, + index, + item, + }; + }); + + if (mode === 'javascript') { + const filteredItems = + plugin.config.enums.constantsIgnoreNull && items.some((item) => item.schema.const === null) + ? items.filter((item) => item.schema.const !== null) + : items; + + const symbolObject = plugin.symbol(applyNaming(name, plugin.config.definitions), { + meta: { + category: 'utility', + resource: 'definition', + resourceId, + tool: 'typescript', + }, + }); + + const objectNode = $.const(symbolObject) + .export() + .$if(plugin.config.comments && createSchemaComment(schema), (c, v) => c.doc(v)) + .assign( + $.object( + ...itemsWithAttempts + .filter(({ item }) => filteredItems.includes(item)) + .map(({ duplicateAttempt, item }) => + $.prop({ + kind: 'prop' as const, + name: resolveEnumKey({ baseName: item.key, duplicateAttempt, plugin }), + }) .$if(plugin.config.comments && createSchemaComment(item.schema), (p, v) => p.doc(v)) .value($.fromValue(item.schema.const)), ), - ).as('const'), - ); - plugin.node(objectNode); - - const symbol = plugin.symbol(applyNaming(refToName($ref), plugin.config.definitions), { - meta: { - category: 'type', - path: fromRef(state.path), - resource: 'definition', - resourceId: $ref, - tags: fromRef(state.tags), - tool: 'typescript', - }, - }); - const node = $.type - .alias(symbol) - .export() - .$if(plugin.config.comments && createSchemaComment(schema), (t, v) => t.doc(v)) - .type($.type(symbolObject).idx($.type(symbolObject).typeofType().keyof()).typeofType()); - plugin.node(node); - return; - } else if ( - plugin.config.enums.mode === 'typescript' || - plugin.config.enums.mode === 'typescript-const' - ) { - // TypeScript enums support only string and number values - const shouldCreateTypeScriptEnum = !enumObject.typeofItems.some( - (type) => type !== 'number' && type !== 'string', + ).as('const'), ); - if (shouldCreateTypeScriptEnum) { - const symbol = plugin.symbol(applyNaming(refToName($ref), plugin.config.definitions), { - meta: { - category: 'type', - path: fromRef(state.path), - resource: 'definition', - resourceId: $ref, - tags: fromRef(state.tags), - tool: 'typescript', - }, - }); - const enumNode = $.enum(symbol) - .export() - .$if(plugin.config.comments && createSchemaComment(schema), (e, v) => e.doc(v)) - .const(plugin.config.enums.mode === 'typescript-const') - .members( - ...enumObject.obj.map((item) => - $.member(item.key) - .$if(plugin.config.comments && createSchemaComment(item.schema), (m, v) => m.doc(v)) - .value($.fromValue(item.schema.const)), - ), - ); - plugin.node(enumNode); - return; - } - } + plugin.node(objectNode); + + const symbol = plugin.symbol(applyNaming(name, plugin.config.definitions), { + meta: { + category: 'type', + resource: 'definition', + resourceId, + tool: 'typescript', + }, + }); + const node = $.type + .alias(symbol) + .export() + .$if(plugin.config.comments && createSchemaComment(schema), (t, v) => t.doc(v)) + .type($.type(symbolObject).idx($.type(symbolObject).typeofType().keyof()).typeofType()); + plugin.node(node); + return true; } - const symbol = plugin.symbol(applyNaming(refToName($ref), plugin.config.definitions), { + if (mode === 'typescript' || mode === 'typescript-const') { + const hasInvalidTypes = items.some( + (item) => typeof item.schema.const !== 'number' && typeof item.schema.const !== 'string', + ); + if (hasInvalidTypes) return false; + + const symbol = plugin.symbol(applyNaming(name, plugin.config.definitions), { + meta: { + category: 'type', + resource: 'definition', + resourceId, + tool: 'typescript', + }, + }); + const enumNode = $.enum(symbol) + .export() + .$if(plugin.config.comments && createSchemaComment(schema), (e, v) => e.doc(v)) + .const(mode === 'typescript-const') + .members( + ...itemsWithAttempts.map(({ duplicateAttempt, item }) => + $.member(resolveEnumKey({ baseName: item.key, duplicateAttempt, plugin })) + .$if(plugin.config.comments && createSchemaComment(item.schema), (m, v) => m.doc(v)) + .value($.fromValue(item.schema.const)), + ), + ); + plugin.node(enumNode); + return true; + } + + return false; +} + +export function exportAst({ + final, + meta, + naming, + namingAnchor, + path, + plugin, + schema, + tags, +}: ProcessorContext & { + final: TypeScriptFinal; +}): void { + const $ref = meta.resourceId || pathToJsonPointer(path); + const name = pathToName(path, { anchor: namingAnchor }); + + const hasEnumExport = buildEnumExport({ + enumData: final.enumData, + name, + plugin, + resourceId: $ref, + schema, + }); + + // If enum declaration/const object has been emitted, do not emit fallback type alias. + if (hasEnumExport) { + return; + } + + const symbol = plugin.symbol(applyNaming(name, naming), { meta: { category: 'type', - path: fromRef(state.path), + path, resource: 'definition', resourceId: $ref, - tags: fromRef(state.tags), + tags, tool: 'typescript', }, }); + const node = $.type .alias(symbol) .export() .$if(plugin.config.comments && createSchemaComment(schema), (t, v) => t.doc(v)) - .type(type); + .type(final.type); plugin.node(node); -}; +} diff --git a/packages/openapi-ts/src/plugins/@hey-api/typescript/shared/meta.ts b/packages/openapi-ts/src/plugins/@hey-api/typescript/shared/meta.ts new file mode 100644 index 000000000..239aced25 --- /dev/null +++ b/packages/openapi-ts/src/plugins/@hey-api/typescript/shared/meta.ts @@ -0,0 +1,45 @@ +import type { IR } from '@hey-api/shared'; + +import type { TypeScriptMeta, TypeScriptResult } from './types'; + +/** + * Creates default metadata from a schema. + */ +export function defaultMeta(schema: IR.SchemaObject): TypeScriptMeta { + return { + default: schema.default, + readonly: schema.accessScope === 'read', + }; +} + +/** + * Composes metadata from child results. + * + * @param children - Results from walking child schemas + * @param overrides - Explicit overrides (e.g., from parent schema) + */ +export function composeMeta( + children: ReadonlyArray, + overrides?: Partial, +): TypeScriptMeta { + return { + default: overrides?.default, + readonly: overrides?.readonly ?? children.some((c) => c.meta.readonly), + }; +} + +/** + * Merges parent schema metadata with composed child metadata. + * + * @param parent - The parent schema + * @param children - Results from walking child schemas + */ +export function inheritMeta( + parent: IR.SchemaObject, + children: ReadonlyArray, +): TypeScriptMeta { + return composeMeta(children, { + default: parent.default, + readonly: parent.accessScope === 'read', + }); +} diff --git a/packages/openapi-ts/src/plugins/@hey-api/typescript/shared/operation.ts b/packages/openapi-ts/src/plugins/@hey-api/typescript/shared/operation.ts index c59514215..0e2ac86b7 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/typescript/shared/operation.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/typescript/shared/operation.ts @@ -1,12 +1,11 @@ -import { fromRef } from '@hey-api/codegen-core'; import type { IR } from '@hey-api/shared'; import { applyNaming } from '@hey-api/shared'; import { operationResponsesMap } from '@hey-api/shared'; import { deduplicateSchema } from '@hey-api/shared'; import { $ } from '../../../../ts-dsl'; -import { irSchemaToAst } from '../v1/plugin'; -import type { IrSchemaToAstOptions } from './types'; +import type { HeyApiTypeScriptPlugin } from '../types'; +import { createProcessor } from '../v1/processor'; const irParametersToIrSchema = ({ parameters, @@ -44,144 +43,131 @@ const irParametersToIrSchema = ({ return irSchema; }; -const operationToDataType = ({ +export const operationToType = ({ operation, + path, plugin, - state, -}: IrSchemaToAstOptions & { + tags, +}: { operation: IR.OperationObject; -}) => { + path: ReadonlyArray; + plugin: HeyApiTypeScriptPlugin['Instance']; + tags?: ReadonlyArray; +}): void => { + const processor = createProcessor(plugin); + const data: IR.SchemaObject = { + properties: { + body: operation.body?.schema ?? { type: 'never' }, + ...(operation.parameters?.header + ? { + headers: irParametersToIrSchema({ + parameters: operation.parameters.header, + }), + } + : {}), + path: operation.parameters?.path + ? irParametersToIrSchema({ parameters: operation.parameters.path }) + : { type: 'never' }, + query: operation.parameters?.query + ? irParametersToIrSchema({ parameters: operation.parameters.query }) + : { type: 'never' }, + url: { + const: operation.path, + type: 'string', + }, + }, type: 'object', }; - const dataRequired: Array = []; - if (!data.properties) { - data.properties = {}; - } - - if (operation.body) { - data.properties.body = operation.body.schema; + const dataRequired: Array = []; - if (operation.body.required) { - dataRequired.push('body'); - } - } else { - data.properties.body = { - type: 'never', - }; + if (operation.body?.required) { + dataRequired.push('body'); } - // TODO: parser - handle cookie parameters - // do not set headers to never so we can always pass arbitrary values - if (operation.parameters?.header) { - data.properties.headers = irParametersToIrSchema({ - parameters: operation.parameters.header, - }); - - if (data.properties.headers.required) { - dataRequired.push('headers'); - } + if (data.properties!.headers?.required) { + dataRequired.push('headers'); } - if (operation.parameters?.path) { - data.properties.path = irParametersToIrSchema({ - parameters: operation.parameters.path, - }); - - if (data.properties.path.required) { - dataRequired.push('path'); - } - } else { - data.properties.path = { - type: 'never', - }; + if (data.properties!.path!.required) { + dataRequired.push('path'); } - if (operation.parameters?.query) { - data.properties.query = irParametersToIrSchema({ - parameters: operation.parameters.query, - }); - - if (data.properties.query.required) { - dataRequired.push('query'); - } - } else { - data.properties.query = { - type: 'never', - }; + if (data.properties!.query!.required) { + dataRequired.push('query'); } - data.properties.url = { - const: operation.path, - type: 'string', - }; dataRequired.push('url'); - data.required = dataRequired; + if (dataRequired.length > 0) { + data.required = dataRequired; + } + + const dataResult = processor.process({ + export: false, + meta: { + resource: 'operation', + resourceId: operation.id, + }, + naming: plugin.config.definitions, + path: [...path, operation.id, 'data'], + plugin, + schema: data, + }); - const symbol = plugin.symbol(applyNaming(operation.id, plugin.config.requests), { + const dataSymbol = plugin.symbol(applyNaming(operation.id, plugin.config.requests), { meta: { category: 'type', - path: fromRef(state.path), + path, resource: 'operation', resourceId: operation.id, role: 'data', - tags: fromRef(state.tags), + tags, tool: 'typescript', }, }); - const node = $.type - .alias(symbol) + const dataNode = $.type + .alias(dataSymbol) .export() - .type( - irSchemaToAst({ - plugin, - schema: data, - state, - }), - ); - plugin.node(node); -}; - -export const operationToType = ({ - operation, - plugin, - state, -}: IrSchemaToAstOptions & { - operation: IR.OperationObject; -}) => { - operationToDataType({ operation, plugin, state }); + .type(dataResult?.type ?? $.type('never')); + plugin.node(dataNode); const { error, errors, response, responses } = operationResponsesMap(operation); if (errors) { - const symbolErrors = plugin.symbol(applyNaming(operation.id, plugin.config.errors), { + const errorsResult = processor.process({ + export: false, + meta: { + resource: 'operation', + resourceId: operation.id, + }, + naming: plugin.config.definitions, + path: [...path, operation.id, 'errors'], + plugin, + schema: errors, + }); + + const errorsSymbol = plugin.symbol(applyNaming(operation.id, plugin.config.errors), { meta: { category: 'type', - path: fromRef(state.path), + path, resource: 'operation', resourceId: operation.id, role: 'errors', - tags: fromRef(state.tags), + tags, tool: 'typescript', }, }); - const node = $.type - .alias(symbolErrors) + const errorsNode = $.type + .alias(errorsSymbol) .export() - .type( - irSchemaToAst({ - plugin, - schema: errors, - state, - }), - ); - plugin.node(node); + .type(errorsResult?.type ?? $.type('never')); + plugin.node(errorsNode); if (error) { - const symbol = plugin.symbol( + const errorSymbol = plugin.symbol( applyNaming(operation.id, { case: plugin.config.errors.case, name: plugin.config.errors.error, @@ -189,49 +175,55 @@ export const operationToType = ({ { meta: { category: 'type', - path: fromRef(state.path), + path, resource: 'operation', resourceId: operation.id, role: 'error', - tags: fromRef(state.tags), + tags, tool: 'typescript', }, }, ); - const node = $.type - .alias(symbol) + const errorNode = $.type + .alias(errorSymbol) .export() - .type($.type(symbolErrors).idx($.type(symbolErrors).keyof())); - plugin.node(node); + .type($.type(errorsSymbol).idx($.type(errorsSymbol).keyof())); + plugin.node(errorNode); } } if (responses) { - const symbolResponses = plugin.symbol(applyNaming(operation.id, plugin.config.responses), { + const responsesResult = processor.process({ + export: false, + meta: { + resource: 'operation', + resourceId: operation.id, + }, + naming: plugin.config.definitions, + path: [...path, operation.id, 'responses'], + plugin, + schema: responses, + }); + + const responsesSymbol = plugin.symbol(applyNaming(operation.id, plugin.config.responses), { meta: { category: 'type', - path: fromRef(state.path), + path, resource: 'operation', resourceId: operation.id, role: 'responses', - tags: fromRef(state.tags), + tags, tool: 'typescript', }, }); - const node = $.type - .alias(symbolResponses) + const responsesNode = $.type + .alias(responsesSymbol) .export() - .type( - irSchemaToAst({ - plugin, - schema: responses, - state, - }), - ); - plugin.node(node); + .type(responsesResult?.type ?? $.type('never')); + plugin.node(responsesNode); if (response) { - const symbol = plugin.symbol( + const responseSymbol = plugin.symbol( applyNaming(operation.id, { case: plugin.config.responses.case, name: plugin.config.responses.response, @@ -239,20 +231,20 @@ export const operationToType = ({ { meta: { category: 'type', - path: fromRef(state.path), + path, resource: 'operation', resourceId: operation.id, role: 'response', - tags: fromRef(state.tags), + tags, tool: 'typescript', }, }, ); - const node = $.type - .alias(symbol) + const responseNode = $.type + .alias(responseSymbol) .export() - .type($.type(symbolResponses).idx($.type(symbolResponses).keyof())); - plugin.node(node); + .type($.type(responsesSymbol).idx($.type(responsesSymbol).keyof())); + plugin.node(responseNode); } } }; diff --git a/packages/openapi-ts/src/plugins/@hey-api/typescript/shared/processor.ts b/packages/openapi-ts/src/plugins/@hey-api/typescript/shared/processor.ts new file mode 100644 index 000000000..b29f0d2d6 --- /dev/null +++ b/packages/openapi-ts/src/plugins/@hey-api/typescript/shared/processor.ts @@ -0,0 +1,17 @@ +import type { IR, NamingConfig, SchemaProcessorContext } from '@hey-api/shared'; + +import type { HeyApiTypeScriptPlugin } from '../types'; +import type { TypeScriptFinal } from './types'; + +export type ProcessorContext = SchemaProcessorContext & { + /** Whether to export the result (default: true) */ + export?: boolean; + naming: NamingConfig; + /** The plugin instance. */ + plugin: HeyApiTypeScriptPlugin['Instance']; + schema: IR.SchemaObject; +}; + +export type ProcessorResult = { + process: (ctx: ProcessorContext) => TypeScriptFinal | void; +}; diff --git a/packages/openapi-ts/src/plugins/@hey-api/typescript/shared/types.ts b/packages/openapi-ts/src/plugins/@hey-api/typescript/shared/types.ts index 0dc21b299..f4fd70e99 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/typescript/shared/types.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/typescript/shared/types.ts @@ -1,15 +1,35 @@ -import type { Refs, SymbolMeta } from '@hey-api/codegen-core'; -import type { SchemaExtractor } from '@hey-api/shared'; +import type { IR } from '@hey-api/shared'; -import type { HeyApiTypeScriptPlugin } from '../types'; +import type { MaybeTsDsl, TypeTsDsl } from '../../../../ts-dsl'; -export type IrSchemaToAstOptions = { - /** The plugin instance. */ - plugin: HeyApiTypeScriptPlugin['Instance']; - /** Optional schema extractor function. */ - schemaExtractor?: SchemaExtractor; - /** The plugin state references. */ - state: Refs; -}; +export type { HeyApiTypeScriptPlugin } from '../types'; -export type PluginState = Pick, 'path'> & Pick, 'tags'>; +/** + * Metadata that flows through schema walking. + */ +export interface TypeScriptMeta { + /** Default value from schema. */ + default?: unknown; + /** Is this schema read-only? */ + readonly: boolean; +} + +export interface TypeScriptEnumData { + items: Array<{ key: string; schema: IR.SchemaObject }>; + mode: 'javascript' | 'typescript' | 'typescript-const' | 'type'; +} + +/** + * Result from walking a schema node. + */ +export interface TypeScriptResult { + enumData?: TypeScriptEnumData; + meta: TypeScriptMeta; + type: MaybeTsDsl; +} + +/** + * Finalized result after applyModifiers. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface TypeScriptFinal extends Pick {} diff --git a/packages/openapi-ts/src/plugins/@hey-api/typescript/shared/webhook.ts b/packages/openapi-ts/src/plugins/@hey-api/typescript/shared/webhook.ts index d04cae644..c889000fe 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/typescript/shared/webhook.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/typescript/shared/webhook.ts @@ -1,31 +1,28 @@ import type { Symbol } from '@hey-api/codegen-core'; -import { fromRef } from '@hey-api/codegen-core'; import type { IR } from '@hey-api/shared'; import { applyNaming } from '@hey-api/shared'; import { createSchemaComment } from '../../../../plugins/shared/utils/schema'; import { $ } from '../../../../ts-dsl'; -import { irSchemaToAst } from '../v1/plugin'; -import type { IrSchemaToAstOptions } from './types'; +import { createProcessor } from '../v1/processor'; +import type { HeyApiTypeScriptPlugin } from './types'; -const operationToDataType = ({ +export function webhookToType({ operation, + path, plugin, - state, -}: IrSchemaToAstOptions & { + tags, +}: { operation: IR.OperationObject; -}): Symbol => { - const data: IR.SchemaObject = { - type: 'object', - }; - const dataRequired: Array = []; - - if (!data.properties) { - data.properties = {}; - } + path: ReadonlyArray; + plugin: HeyApiTypeScriptPlugin['Instance']; + tags?: ReadonlyArray; +}): Symbol { + const processor = createProcessor(plugin); + let symbolWebhookPayload: Symbol | undefined; if (operation.body) { - const symbolWebhookPayload = plugin.symbol( + symbolWebhookPayload = plugin.symbol( applyNaming(operation.id, { case: plugin.config.webhooks.case, name: plugin.config.webhooks.payload, @@ -33,80 +30,61 @@ const operationToDataType = ({ { meta: { category: 'type', - path: fromRef(state.path), + path, resource: 'webhook', resourceId: operation.id, - role: 'data', - tags: fromRef(state.tags), + role: 'payload', + tags, tool: 'typescript', }, }, ); - const node = $.type + + const payloadResult = processor.process({ + export: false, + meta: { + resource: 'webhook', + resourceId: operation.id, + }, + naming: plugin.config.definitions, + path: [...path, operation.id, 'payload'], + plugin, + schema: operation.body.schema, + }); + + const payloadNode = $.type .alias(symbolWebhookPayload) .export() .$if(plugin.config.comments && createSchemaComment(operation.body.schema), (t, v) => t.doc(v)) - .type( - irSchemaToAst({ - plugin, - schema: operation.body.schema, - state, - }), - ); - plugin.node(node); - - data.properties.body = { symbolRef: symbolWebhookPayload }; - dataRequired.push('body'); - } else { - data.properties.body = { type: 'never' }; + .type(payloadResult?.type ?? $.type('never')); + plugin.node(payloadNode); } - data.properties.key = { - const: operation.path, - type: 'string', - }; - dataRequired.push('key'); - - data.properties.path = { type: 'never' }; - data.properties.query = { type: 'never' }; - - data.required = dataRequired; + const requestType = $.type + .object() + .prop('body', (p) => + p + .required(Boolean(symbolWebhookPayload)) + .type(symbolWebhookPayload ? $.type(symbolWebhookPayload) : $.type('never')), + ) + .prop('key', (p) => p.required(true).type($.type.literal(operation.path))) + .prop('path', (p) => p.required(false).type($.type('never'))) + .prop('query', (p) => p.required(false).type($.type('never'))); - const symbolWebhookRequest = plugin.symbol(applyNaming(operation.id, plugin.config.webhooks), { + const symbol = plugin.symbol(applyNaming(operation.id, plugin.config.webhooks), { meta: { category: 'type', - path: fromRef(state.path), + path, resource: 'webhook', resourceId: operation.id, role: 'data', - tags: fromRef(state.tags), + tags, tool: 'typescript', }, }); - const node = $.type - .alias(symbolWebhookRequest) - .export() - .type( - irSchemaToAst({ - plugin, - schema: data, - state, - }), - ); - plugin.node(node); - return symbolWebhookRequest; -}; + const node = $.type.alias(symbol).export().type(requestType); + plugin.node(node); -export const webhookToType = ({ - operation, - plugin, - state, -}: IrSchemaToAstOptions & { - operation: IR.OperationObject; -}): Symbol => { - const symbol = operationToDataType({ operation, plugin, state }); return symbol; - - // don't handle webhook responses for now, users only need requestBody -}; +} diff --git a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/api.ts b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/api.ts deleted file mode 100644 index 00c0d33f9..000000000 --- a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/api.ts +++ /dev/null @@ -1 +0,0 @@ -export { irSchemaToAst as irSchemaToAstV1 } from './plugin'; diff --git a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/plugin.ts b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/plugin.ts index 223a06b36..87e555be2 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/plugin.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/plugin.ts @@ -1,122 +1,23 @@ import type { Symbol } from '@hey-api/codegen-core'; -import { fromRef, refs } from '@hey-api/codegen-core'; -import type { IR, SchemaWithType } from '@hey-api/shared'; -import { applyNaming, deduplicateSchema, pathToJsonPointer } from '@hey-api/shared'; +import type { IR } from '@hey-api/shared'; +import { applyNaming, pathToJsonPointer } from '@hey-api/shared'; -import type { MaybeTsDsl, TypeTsDsl } from '../../../../ts-dsl'; import { $ } from '../../../../ts-dsl'; import { createClientOptions } from '../shared/clientOptions'; -import { exportType } from '../shared/export'; import { operationToType } from '../shared/operation'; -import type { IrSchemaToAstOptions, PluginState } from '../shared/types'; import { webhookToType } from '../shared/webhook'; import type { HeyApiTypeScriptPlugin } from '../types'; -import { irSchemaWithTypeToAst } from './toAst'; - -export function irSchemaToAst({ - plugin, - schema, - schemaExtractor, - state, -}: IrSchemaToAstOptions & { - schema: IR.SchemaObject; -}): MaybeTsDsl { - if (schemaExtractor && !schema.$ref) { - const extracted = schemaExtractor({ - meta: { - resource: 'definition', - resourceId: pathToJsonPointer(fromRef(state.path)), - }, - path: fromRef(state.path), - schema, - }); - if (extracted !== schema) schema = extracted; - } - - if (schema.symbolRef) { - const baseType = $.type(schema.symbolRef); - if (schema.omit && schema.omit.length > 0) { - // Render as Omit - const omittedKeys = - schema.omit.length === 1 - ? $.type.literal(schema.omit[0]!) - : $.type.or(...schema.omit.map((key) => $.type.literal(key))); - return $.type('Omit').generics(baseType, omittedKeys); - } - return baseType; - } - - if (schema.$ref) { - const symbol = plugin.referenceSymbol({ - category: 'type', - resource: 'definition', - resourceId: schema.$ref, - }); - const baseType = $.type(symbol); - if (schema.omit && schema.omit.length > 0) { - // Render as Omit - const omittedKeys = - schema.omit.length === 1 - ? $.type.literal(schema.omit[0]!) - : $.type.or(...schema.omit.map((key) => $.type.literal(key))); - return $.type('Omit').generics(baseType, omittedKeys); - } - return baseType; - } - - if (schema.type) { - return irSchemaWithTypeToAst({ - plugin, - schema: schema as SchemaWithType, - state, - }); - } - - if (schema.items) { - schema = deduplicateSchema({ detectFormat: false, schema }); - if (schema.items) { - const itemTypes = schema.items.map((item) => irSchemaToAst({ plugin, schema: item, state })); - return schema.logicalOperator === 'and' ? $.type.and(...itemTypes) : $.type.or(...itemTypes); - } - - return irSchemaToAst({ plugin, schema, state }); - } - - // catch-all fallback for failed schemas - return irSchemaWithTypeToAst({ - plugin, - schema: { - type: 'unknown', - }, - state, - }); -} - -function handleComponent({ - plugin, - schema, - state, -}: IrSchemaToAstOptions & { - schema: IR.SchemaObject; -}) { - const type = irSchemaToAst({ plugin, schema, state }); - exportType({ - plugin, - schema, - state, - type, - }); -} +import { createProcessor } from './processor'; export const handlerV1: HeyApiTypeScriptPlugin['Handler'] = ({ plugin }) => { - // reserve node for ClientOptions const nodeClientIndex = plugin.node(null); - // reserve node for Webhooks const nodeWebhooksIndex = plugin.node(null); const servers: Array = []; const webhooks: Array = []; + const processor = createProcessor(plugin); + plugin.forEach( 'operation', 'parameter', @@ -125,37 +26,52 @@ export const handlerV1: HeyApiTypeScriptPlugin['Handler'] = ({ plugin }) => { 'server', 'webhook', (event) => { - const state = refs({ - path: event._path, - tags: event.tags, - }); switch (event.type) { case 'operation': operationToType({ operation: event.operation, + path: event._path, plugin, - state, + tags: event.tags, }); break; case 'parameter': - handleComponent({ + processor.process({ + meta: { + resource: 'definition', + resourceId: pathToJsonPointer(event._path), + }, + naming: plugin.config.definitions, + path: event._path, plugin, schema: event.parameter.schema, - state, + tags: event.tags, }); break; case 'requestBody': - handleComponent({ + processor.process({ + meta: { + resource: 'definition', + resourceId: pathToJsonPointer(event._path), + }, + naming: plugin.config.definitions, + path: event._path, plugin, schema: event.requestBody.schema, - state, + tags: event.tags, }); break; case 'schema': - handleComponent({ + processor.process({ + meta: { + resource: 'definition', + resourceId: pathToJsonPointer(event._path), + }, + naming: plugin.config.definitions, + path: event._path, plugin, schema: event.schema, - state, + tags: event.tags, }); break; case 'server': @@ -165,8 +81,9 @@ export const handlerV1: HeyApiTypeScriptPlugin['Handler'] = ({ plugin }) => { webhooks.push( webhookToType({ operation: event.operation, + path: event._path, plugin, - state, + tags: event.tags, }), ); break; diff --git a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/processor.ts b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/processor.ts new file mode 100644 index 000000000..425869cb4 --- /dev/null +++ b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/processor.ts @@ -0,0 +1,68 @@ +import { ref } from '@hey-api/codegen-core'; +import type { Hooks, IR } from '@hey-api/shared'; +import { createSchemaProcessor, createSchemaWalker, pathToJsonPointer } from '@hey-api/shared'; + +import { exportAst } from '../shared/export'; +import type { ProcessorContext, ProcessorResult } from '../shared/processor'; +import type { TypeScriptFinal } from '../shared/types'; +import type { HeyApiTypeScriptPlugin } from '../types'; +import { createVisitor } from './walker'; + +export function createProcessor(plugin: HeyApiTypeScriptPlugin['Instance']): ProcessorResult { + const processor = createSchemaProcessor(); + + const extractorHooks: ReadonlyArray['shouldExtract']> = [ + plugin.config['~hooks']?.schemas?.shouldExtract, + plugin.context.config.parser.hooks.schemas?.shouldExtract, + ]; + + function extractor(ctx: ProcessorContext): IR.SchemaObject { + if (processor.hasEmitted(ctx.path)) { + return ctx.schema; + } + + for (const hook of extractorHooks) { + const result = hook?.(ctx); + if (result) { + process({ + namingAnchor: processor.context.anchor, + tags: processor.context.tags, + ...ctx, + }); + return { $ref: pathToJsonPointer(ctx.path) }; + } + } + + return ctx.schema; + } + + function process(ctx: ProcessorContext): TypeScriptFinal | void { + if (!processor.markEmitted(ctx.path)) return; + + const shouldExport = ctx.export !== false; + + return processor.withContext({ anchor: ctx.namingAnchor, tags: ctx.tags }, () => { + const visitor = createVisitor({ schemaExtractor: extractor }); + const walk = createSchemaWalker(visitor); + + const result = walk(ctx.schema, { + path: ref(ctx.path), + plugin, + }); + + const final = visitor.applyModifiers(result, { + path: ref(ctx.path), + plugin, + }) as TypeScriptFinal; + + if (shouldExport) { + exportAst({ ...ctx, final, plugin }); + return; + } + + return final; + }); + } + + return { process }; +} diff --git a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/array.ts b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/array.ts index 36eecacf2..97f5491b9 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/array.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/array.ts @@ -1,46 +1,41 @@ -import { fromRef, ref } from '@hey-api/codegen-core'; +import { ref } from '@hey-api/codegen-core'; import type { SchemaWithType } from '@hey-api/shared'; +import type { Walker } from '@hey-api/shared'; import { deduplicateSchema } from '@hey-api/shared'; -import type { MaybeTsDsl, TypeTsDsl } from '../../../../../ts-dsl'; import { $ } from '../../../../../ts-dsl'; -import type { IrSchemaToAstOptions } from '../../shared/types'; -import { irSchemaToAst } from '../plugin'; +import type { HeyApiTypeScriptPlugin } from '../../shared/types'; +import type { TypeScriptResult } from '../../shared/types'; export function arrayToAst({ plugin, schema, - state, -}: IrSchemaToAstOptions & { + walk, +}: { + plugin: HeyApiTypeScriptPlugin['Instance']; schema: SchemaWithType<'array'>; -}): TypeTsDsl { + walk: Walker; +}): TypeScriptResult['type'] { if (!schema.items) { return $.type('Array').generic($.type(plugin.config.topType)); } - schema = deduplicateSchema({ detectFormat: true, schema }); - - const itemTypes: Array> = []; - - if (schema.items) { - schema.items.forEach((item, index) => { - const type = irSchemaToAst({ - plugin, - schema: item, - state: { - ...state, - path: ref([...fromRef(state.path), 'items', index]), - }, - }); - itemTypes.push(type); - }); + const dedupedSchema = deduplicateSchema({ detectFormat: true, schema }); + if (!dedupedSchema.items) { + return $.type('Array').generic($.type(plugin.config.topType)); } - if (itemTypes.length === 1) { - return $.type('Array').generic(itemTypes[0]!); + const itemResults: Array = dedupedSchema.items.map((item) => + walk(item, { + path: ref([]), + plugin, + }), + ); + if (itemResults.length === 1) { + return $.type('Array').generic(itemResults[0]!.type); } - return schema.logicalOperator === 'and' - ? $.type('Array').generic($.type.and(...itemTypes)) - : $.type('Array').generic($.type.or(...itemTypes)); + return dedupedSchema.logicalOperator === 'and' + ? $.type('Array').generic($.type.and(...itemResults.map((r) => r.type))) + : $.type('Array').generic($.type.or(...itemResults.map((r) => r.type))); } diff --git a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/boolean.ts b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/boolean.ts index 4443dfc78..0a735107f 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/boolean.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/boolean.ts @@ -1,16 +1,16 @@ import type { SchemaWithType } from '@hey-api/shared'; -import type { TypeTsDsl } from '../../../../../ts-dsl'; import { $ } from '../../../../../ts-dsl'; -import type { IrSchemaToAstOptions } from '../../shared/types'; +import type { HeyApiTypeScriptPlugin, TypeScriptResult } from '../../shared/types'; export function booleanToAst({ schema, -}: IrSchemaToAstOptions & { +}: { + plugin: HeyApiTypeScriptPlugin['Instance']; schema: SchemaWithType<'boolean'>; -}): TypeTsDsl { +}): TypeScriptResult['type'] { if (schema.const !== undefined) { - return $.type.literal(schema.const as boolean); + return $.type.fromValue(schema.const); } return $.type('boolean'); diff --git a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/enum.ts b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/enum.ts index d47831e10..eecf0f049 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/enum.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/enum.ts @@ -1,23 +1,66 @@ import type { SchemaWithType } from '@hey-api/shared'; -import type { MaybeTsDsl, TypeTsDsl } from '../../../../../ts-dsl'; -import type { IrSchemaToAstOptions } from '../../shared/types'; -import { irSchemaToAst } from '../plugin'; +import { $ } from '../../../../../ts-dsl'; +import type { HeyApiTypeScriptPlugin, TypeScriptResult } from '../../shared/types'; +import type { TypeScriptEnumData } from '../../shared/types'; + +function buildEnumData( + plugin: HeyApiTypeScriptPlugin['Instance'], + schema: SchemaWithType<'enum'>, +): TypeScriptEnumData | undefined { + if (!plugin.config.enums.enabled) { + return undefined; + } + + const items = schema.items ?? []; + const mode = plugin.config.enums.mode; + + return { + items: items.map((item, index) => { + let key: string; + if (item.title) { + key = item.title; + } else if (typeof item.const === 'number' || typeof item.const === 'string') { + key = `${item.const}`; + } else if (typeof item.const === 'boolean') { + key = item.const ? 'true' : 'false'; + } else if (item.const === null) { + key = 'null'; + } else { + key = `${index}`; + } + return { key, schema: item }; + }), + mode, + }; +} export function enumToAst({ plugin, schema, - state, -}: IrSchemaToAstOptions & { +}: { + plugin: HeyApiTypeScriptPlugin['Instance']; schema: SchemaWithType<'enum'>; -}): MaybeTsDsl { - const type = irSchemaToAst({ - plugin, - schema: { - ...schema, - type: undefined, - }, - state, - }); - return type; +}): { + enumData?: TypeScriptEnumData; + type: TypeScriptResult['type']; +} { + const items = schema.items ?? []; + const enumData = buildEnumData(plugin, schema); + + let type: TypeScriptResult['type']; + + if (items.length === 0) { + type = $.type('never'); + } else { + const literalTypes = items + .filter((item) => item.const !== undefined) + .map((item) => $.type.fromValue(item.const)); + type = literalTypes.length > 0 ? $.type.or(...literalTypes) : $.type('string'); + } + + return { + enumData, + type, + }; } diff --git a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/index.ts b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/index.ts deleted file mode 100644 index 716fc31e9..000000000 --- a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/index.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { SchemaWithType } from '@hey-api/shared'; - -import type { MaybeTsDsl, TypeTsDsl } from '../../../../../ts-dsl'; -import type { IrSchemaToAstOptions } from '../../shared/types'; -import { arrayToAst } from './array'; -import { booleanToAst } from './boolean'; -import { enumToAst } from './enum'; -import { neverToAst } from './never'; -import { nullToAst } from './null'; -import { numberToAst } from './number'; -import { objectToAst } from './object'; -import { stringToAst } from './string'; -import { tupleToAst } from './tuple'; -import { undefinedToAst } from './undefined'; -import { unknownToAst } from './unknown'; -import { voidToAst } from './void'; - -export function irSchemaWithTypeToAst({ - schema, - ...args -}: IrSchemaToAstOptions & { - schema: SchemaWithType; -}): MaybeTsDsl { - const transformersPlugin = args.plugin.getPlugin('@hey-api/transformers'); - if (transformersPlugin?.config.typeTransformers) { - for (const typeTransformer of transformersPlugin.config.typeTransformers) { - const typeNode = typeTransformer({ schema }); - if (typeNode) { - return typeNode; - } - } - } - - switch (schema.type) { - case 'array': - return arrayToAst({ - ...args, - schema: schema as SchemaWithType<'array'>, - }); - case 'boolean': - return booleanToAst({ - ...args, - schema: schema as SchemaWithType<'boolean'>, - }); - case 'enum': - return enumToAst({ - ...args, - schema: schema as SchemaWithType<'enum'>, - }); - case 'integer': - case 'number': - return numberToAst({ - ...args, - schema: schema as SchemaWithType<'integer' | 'number'>, - }); - case 'never': - return neverToAst({ - ...args, - schema: schema as SchemaWithType<'never'>, - }); - case 'null': - return nullToAst({ - ...args, - schema: schema as SchemaWithType<'null'>, - }); - case 'object': - return objectToAst({ - ...args, - schema: schema as SchemaWithType<'object'>, - }); - case 'string': - return stringToAst({ - ...args, - schema: schema as SchemaWithType<'string'>, - }); - case 'tuple': - return tupleToAst({ - ...args, - schema: schema as SchemaWithType<'tuple'>, - }); - case 'undefined': - return undefinedToAst({ - ...args, - schema: schema as SchemaWithType<'undefined'>, - }); - case 'unknown': - return unknownToAst({ - ...args, - schema: schema as SchemaWithType<'unknown'>, - }); - case 'void': - return voidToAst({ - ...args, - schema: schema as SchemaWithType<'void'>, - }); - } -} diff --git a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/never.ts b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/never.ts index bc6618feb..0e6579cf8 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/never.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/never.ts @@ -1,15 +1,12 @@ import type { SchemaWithType } from '@hey-api/shared'; -import type { TypeTsDsl } from '../../../../../ts-dsl'; import { $ } from '../../../../../ts-dsl'; -import type { IrSchemaToAstOptions } from '../../shared/types'; +import type { HeyApiTypeScriptPlugin, TypeScriptResult } from '../../shared/types'; -export const neverToAst = ( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _args: IrSchemaToAstOptions & { - schema: SchemaWithType<'never'>; - }, -): TypeTsDsl => { - const node = $.type('never'); - return node; -}; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function neverToAst(args: { + plugin: HeyApiTypeScriptPlugin['Instance']; + schema: SchemaWithType<'never'>; +}): TypeScriptResult['type'] { + return $.type('never'); +} diff --git a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/null.ts b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/null.ts index 123846a89..039a187f0 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/null.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/null.ts @@ -1,15 +1,12 @@ import type { SchemaWithType } from '@hey-api/shared'; -import type { TypeTsDsl } from '../../../../../ts-dsl'; import { $ } from '../../../../../ts-dsl'; -import type { IrSchemaToAstOptions } from '../../shared/types'; +import type { HeyApiTypeScriptPlugin, TypeScriptResult } from '../../shared/types'; -export const nullToAst = ( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _args: IrSchemaToAstOptions & { - schema: SchemaWithType<'null'>; - }, -): TypeTsDsl => { - const node = $.type.literal(null); - return node; -}; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function nullToAst(args: { + plugin: HeyApiTypeScriptPlugin['Instance']; + schema: SchemaWithType<'null'>; +}): TypeScriptResult['type'] { + return $.type.literal(null); +} diff --git a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/number.ts b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/number.ts index 597355f85..fdddc245a 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/number.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/number.ts @@ -1,17 +1,17 @@ import type { SchemaWithType } from '@hey-api/shared'; -import type { TypeTsDsl } from '../../../../../ts-dsl'; import { $ } from '../../../../../ts-dsl'; -import type { IrSchemaToAstOptions } from '../../shared/types'; +import type { HeyApiTypeScriptPlugin, TypeScriptResult } from '../../shared/types'; -export const numberToAst = ({ +export function numberToAst({ plugin, schema, -}: IrSchemaToAstOptions & { +}: { + plugin: HeyApiTypeScriptPlugin['Instance']; schema: SchemaWithType<'integer' | 'number'>; -}): TypeTsDsl => { +}): TypeScriptResult['type'] { if (schema.const !== undefined) { - return $.type.literal(schema.const as number); + return $.type.fromValue(schema.const); } if (schema.type === 'integer' && schema.format === 'int64') { @@ -22,4 +22,4 @@ export const numberToAst = ({ } return $.type('number'); -}; +} diff --git a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/object.ts b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/object.ts index 89ab09450..fd58c3422 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/object.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/object.ts @@ -1,21 +1,21 @@ -import { fromRef, ref } from '@hey-api/codegen-core'; -import type { IR } from '@hey-api/shared'; -import type { SchemaWithType } from '@hey-api/shared'; +import { ref } from '@hey-api/codegen-core'; +import type { IR, SchemaWithType, Walker } from '@hey-api/shared'; +import { deduplicateSchema } from '@hey-api/shared'; import { createSchemaComment } from '../../../../../plugins/shared/utils/schema'; -import type { TypeTsDsl } from '../../../../../ts-dsl'; import { $ } from '../../../../../ts-dsl'; -import type { IrSchemaToAstOptions } from '../../shared/types'; -import { irSchemaToAst } from '../plugin'; +import type { HeyApiTypeScriptPlugin } from '../../shared/types'; +import type { TypeScriptResult } from '../../shared/types'; export function objectToAst({ plugin, schema, - state, -}: IrSchemaToAstOptions & { + walk, +}: { + plugin: HeyApiTypeScriptPlugin['Instance']; schema: SchemaWithType<'object'>; -}): TypeTsDsl { - // TODO: parser - handle constants + walk: Walker; +}): TypeScriptResult['type'] { const shape = $.type.object(); const required = schema.required ?? []; let indexSchemas: Array = []; @@ -23,21 +23,14 @@ export function objectToAst({ for (const name in schema.properties) { const property = schema.properties[name]!; - const propertyType = irSchemaToAst({ - plugin, - schema: property, - state: { - ...state, - path: ref([...fromRef(state.path), 'properties', name]), - }, - }); + const propertyResult = walk(property, { path: ref([]), plugin }); const isRequired = required.includes(name); shape.prop(name, (p) => p .$if(plugin.config.comments && createSchemaComment(property), (p, v) => p.doc(v)) .readonly(property.accessScope === 'read') .required(isRequired) - .type(propertyType), + .type(propertyResult.type), ); indexSchemas.push(property); @@ -46,7 +39,6 @@ export function objectToAst({ } } - // include pattern value schemas into the index union if (schema.patternProperties) { for (const pattern in schema.patternProperties) { const ir = schema.patternProperties[pattern]!; @@ -67,59 +59,39 @@ export function objectToAst({ const addProps = addPropsObj; if (addProps && addProps.type !== 'never') { if (addProps.type === 'unknown') { - // When additionalProperties is unknown (e.g. `{}` or `true`), it already subsumes all - // named property types, so we only need the additionalProperties schema itself (plus any - // patternProperties) in the index signature. Including named property types would produce - // a redundant, noisy union like `unknown | string | null | ...`. const patternSchemas: Array = schema.patternProperties ? Object.values(schema.patternProperties) : []; indexSchemas = [addProps, ...patternSchemas]; } else { - // For typed additionalProperties (e.g. `{ type: 'string' }`), named property types must - // be included so that TypeScript's index signature constraint is satisfied. indexSchemas.unshift(addProps); } } else if (!hasPatterns && !indexSchemas.length && addProps && addProps.type === 'never') { - // keep "never" only when there are NO patterns and NO explicit properties indexSchemas = [addProps]; } - // `unknown` already subsumes `undefined`, so no need to add it explicitly if (hasOptionalProperties && addProps?.type !== 'unknown') { indexSchemas.push({ type: 'undefined' }); } - const type = - indexSchemas.length === 1 - ? irSchemaToAst({ - plugin, - schema: indexSchemas[0]!, - state, - }) - : irSchemaToAst({ - plugin, - schema: { items: indexSchemas, logicalOperator: 'or' }, - state, - }); + if (indexSchemas.length > 0) { + const unionSchema: IR.SchemaObject = + indexSchemas.length === 1 + ? indexSchemas[0]! + : deduplicateSchema({ schema: { items: indexSchemas, logicalOperator: 'or' } }); - if (schema.propertyNames?.$ref) { - return $.type - .mapped('key') - .key( - irSchemaToAst({ - plugin, - schema: { - $ref: schema.propertyNames.$ref, - }, - state, - }), - ) - .optional() - .type(type); - } + const indexType = walk(unionSchema, { path: ref([]), plugin }).type; + + if (schema.propertyNames?.$ref) { + const propertyNamesResult = walk( + { $ref: schema.propertyNames.$ref }, + { path: ref([]), plugin }, + ); + return $.type.mapped('key').key(propertyNamesResult.type).optional().type(indexType); + } - shape.idxSig('key', (i) => i.key('string').type(type)); + shape.idxSig('key', (i) => i.key('string').type(indexType)); + } } return shape; diff --git a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/string.ts b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/string.ts index 4f6caada8..212c73ac6 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/string.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/string.ts @@ -2,18 +2,18 @@ import type { SymbolMeta } from '@hey-api/codegen-core'; import type { SchemaWithType } from '@hey-api/shared'; import { toCase } from '@hey-api/shared'; -import type { TypeTsDsl } from '../../../../../ts-dsl'; import { $ } from '../../../../../ts-dsl'; -import type { IrSchemaToAstOptions } from '../../shared/types'; +import type { HeyApiTypeScriptPlugin, TypeScriptResult } from '../../shared/types'; -export const stringToAst = ({ +export function stringToAst({ plugin, schema, -}: IrSchemaToAstOptions & { +}: { + plugin: HeyApiTypeScriptPlugin['Instance']; schema: SchemaWithType<'string'>; -}): TypeTsDsl => { +}): TypeScriptResult['type'] { if (schema.const !== undefined) { - return $.type.literal(schema.const as string); + return $.type.fromValue(schema.const); } if (schema.format) { @@ -31,25 +31,25 @@ export const stringToAst = ({ if (schema.format === 'typeid' && typeof schema.example === 'string') { const parts = String(schema.example).split('_'); parts.pop(); // remove the ID part - const type = parts.join('_'); + const typeidBase = parts.join('_'); - const query: SymbolMeta = { + const typeidQuery: SymbolMeta = { category: 'type', resource: 'type-id', - resourceId: type, + resourceId: typeidBase, tool: 'typescript', }; - if (!plugin.getSymbol(query)) { - const queryTypeId: SymbolMeta = { + if (!plugin.getSymbol(typeidQuery)) { + const containerQuery: SymbolMeta = { category: 'type', resource: 'type-id', tool: 'typescript', variant: 'container', }; - if (!plugin.getSymbol(queryTypeId)) { + if (!plugin.getSymbol(containerQuery)) { const symbolTypeId = plugin.symbol('TypeID', { - meta: queryTypeId, + meta: containerQuery, }); const nodeTypeId = $.type .alias(symbolTypeId) @@ -59,20 +59,19 @@ export const stringToAst = ({ plugin.node(nodeTypeId); } - const symbolTypeId = plugin.referenceSymbol(queryTypeId); - const symbolTypeName = plugin.symbol(toCase(`${type}_id`, plugin.config.case), { - meta: query, + const refSymbol = plugin.referenceSymbol(containerQuery); + const symbolTypeName = plugin.symbol(toCase(`${typeidBase}_id`, plugin.config.case), { + meta: typeidQuery, }); const node = $.type .alias(symbolTypeName) .export() - .type($.type(symbolTypeId).generic($.type.literal(type))); + .type($.type(refSymbol).generic($.type.literal(typeidBase))); plugin.node(node); } - const symbol = plugin.referenceSymbol(query); - return $.type(symbol); + return $.type(plugin.referenceSymbol(typeidQuery)); } } return $.type('string'); -}; +} diff --git a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/tuple.ts b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/tuple.ts index f8e9be260..91d94ea9c 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/tuple.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/tuple.ts @@ -1,33 +1,28 @@ -import { fromRef, ref } from '@hey-api/codegen-core'; +import { ref } from '@hey-api/codegen-core'; import type { SchemaWithType } from '@hey-api/shared'; +import type { Walker } from '@hey-api/shared'; -import type { MaybeTsDsl, TypeTsDsl } from '../../../../../ts-dsl'; import { $ } from '../../../../../ts-dsl'; -import type { IrSchemaToAstOptions } from '../../shared/types'; -import { irSchemaToAst } from '../plugin'; +import type { HeyApiTypeScriptPlugin } from '../../shared/types'; +import type { TypeScriptResult } from '../../shared/types'; export function tupleToAst({ plugin, schema, - state, -}: IrSchemaToAstOptions & { + walk, +}: { + plugin: HeyApiTypeScriptPlugin['Instance']; schema: SchemaWithType<'tuple'>; -}): MaybeTsDsl { - let itemTypes: Array> = []; + walk: Walker; +}): TypeScriptResult['type'] { + let itemTypes: Array = []; if (schema.const && Array.isArray(schema.const)) { itemTypes = schema.const.map((value) => $.type.fromValue(value)); } else if (schema.items) { - schema.items.forEach((item, index) => { - const type = irSchemaToAst({ - plugin, - schema: item, - state: { - ...state, - path: ref([...fromRef(state.path), 'items', index]), - }, - }); - itemTypes.push(type); + schema.items.forEach((item) => { + const result = walk(item, { path: ref([]), plugin }); + itemTypes.push(result.type); }); } diff --git a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/undefined.ts b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/undefined.ts index b435d0741..279a8381f 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/undefined.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/undefined.ts @@ -1,15 +1,12 @@ import type { SchemaWithType } from '@hey-api/shared'; -import type { TypeTsDsl } from '../../../../../ts-dsl'; import { $ } from '../../../../../ts-dsl'; -import type { IrSchemaToAstOptions } from '../../shared/types'; +import type { HeyApiTypeScriptPlugin, TypeScriptResult } from '../../shared/types'; -export const undefinedToAst = ( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _args: IrSchemaToAstOptions & { - schema: SchemaWithType<'undefined'>; - }, -): TypeTsDsl => { - const node = $.type('undefined'); - return node; -}; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function undefinedToAst(args: { + plugin: HeyApiTypeScriptPlugin['Instance']; + schema: SchemaWithType<'undefined'>; +}): TypeScriptResult['type'] { + return $.type('undefined'); +} diff --git a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/unknown.ts b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/unknown.ts index 43b6f006a..4c1b58377 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/unknown.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/unknown.ts @@ -1,14 +1,13 @@ import type { SchemaWithType } from '@hey-api/shared'; -import type { TypeTsDsl } from '../../../../../ts-dsl'; import { $ } from '../../../../../ts-dsl'; -import type { IrSchemaToAstOptions } from '../../shared/types'; +import type { HeyApiTypeScriptPlugin, TypeScriptResult } from '../../shared/types'; export function unknownToAst({ plugin, -}: IrSchemaToAstOptions & { +}: { + plugin: HeyApiTypeScriptPlugin['Instance']; schema: SchemaWithType<'unknown'>; -}): TypeTsDsl { - const node = $.type(plugin.config.topType); - return node; +}): TypeScriptResult['type'] { + return $.type(plugin.config.topType); } diff --git a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/void.ts b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/void.ts index 054c992cf..d62a29616 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/void.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/toAst/void.ts @@ -1,15 +1,12 @@ import type { SchemaWithType } from '@hey-api/shared'; -import type { TypeTsDsl } from '../../../../../ts-dsl'; import { $ } from '../../../../../ts-dsl'; -import type { IrSchemaToAstOptions } from '../../shared/types'; +import type { HeyApiTypeScriptPlugin, TypeScriptResult } from '../../shared/types'; -export const voidToAst = ( - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _args: IrSchemaToAstOptions & { - schema: SchemaWithType<'void'>; - }, -): TypeTsDsl => { - const node = $.type('void'); - return node; -}; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export function voidToAst(args: { + plugin: HeyApiTypeScriptPlugin['Instance']; + schema: SchemaWithType<'void'>; +}): TypeScriptResult['type'] { + return $.type('void'); +} diff --git a/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/walker.ts b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/walker.ts new file mode 100644 index 000000000..69c9961ce --- /dev/null +++ b/packages/openapi-ts/src/plugins/@hey-api/typescript/v1/walker.ts @@ -0,0 +1,214 @@ +import { fromRef } from '@hey-api/codegen-core'; +import type { SchemaExtractor, SchemaVisitor } from '@hey-api/shared'; +import { pathToJsonPointer } from '@hey-api/shared'; + +import { $ } from '../../../../ts-dsl'; +import { defaultMeta, inheritMeta } from '../shared/meta'; +import type { ProcessorContext } from '../shared/processor'; +import type { HeyApiTypeScriptPlugin, TypeScriptResult } from '../shared/types'; +import { arrayToAst } from './toAst/array'; +import { booleanToAst } from './toAst/boolean'; +import { enumToAst } from './toAst/enum'; +import { neverToAst } from './toAst/never'; +import { nullToAst } from './toAst/null'; +import { numberToAst } from './toAst/number'; +import { objectToAst } from './toAst/object'; +import { stringToAst } from './toAst/string'; +import { tupleToAst } from './toAst/tuple'; +import { undefinedToAst } from './toAst/undefined'; +import { unknownToAst } from './toAst/unknown'; +import { voidToAst } from './toAst/void'; + +export interface VisitorConfig { + /** Optional schema extractor function. */ + schemaExtractor?: SchemaExtractor; +} + +export function createVisitor( + config: VisitorConfig, +): SchemaVisitor { + const { schemaExtractor } = config; + + return { + applyModifiers(result) { + return { + enumData: result.enumData, + type: result.type, + }; + }, + array(schema, ctx, walk) { + const type = arrayToAst({ + plugin: ctx.plugin, + schema, + walk, + }); + return { + meta: defaultMeta(schema), + type, + }; + }, + boolean(schema, ctx) { + const type = booleanToAst({ plugin: ctx.plugin, schema }); + return { + meta: defaultMeta(schema), + type, + }; + }, + enum(schema, ctx) { + const { enumData, type } = enumToAst({ plugin: ctx.plugin, schema }); + return { + enumData, + meta: defaultMeta(schema), + type, + }; + }, + integer(schema, ctx) { + const type = numberToAst({ plugin: ctx.plugin, schema }); + return { + meta: defaultMeta(schema), + type, + }; + }, + intercept(schema, ctx, walk) { + if (schemaExtractor && !schema.$ref) { + const extracted = schemaExtractor({ + meta: { + resource: 'definition', + resourceId: pathToJsonPointer(fromRef(ctx.path)), + }, + naming: ctx.plugin.config.definitions, + path: fromRef(ctx.path), + plugin: ctx.plugin, + schema, + }); + + if (extracted !== schema) { + return walk(extracted, ctx); + } + } + + const transformersPlugin = ctx.plugin.getPlugin('@hey-api/transformers'); + if (transformersPlugin?.config.typeTransformers) { + for (const typeTransformer of transformersPlugin.config.typeTransformers) { + const typeNode = typeTransformer({ schema }); + if (typeNode) { + return { meta: defaultMeta(schema), type: typeNode }; + } + } + } + }, + intersection(items, schemas, parentSchema) { + const type = items.length === 1 ? items[0]!.type : $.type.and(...items.map((r) => r.type)); + + return { + meta: inheritMeta(parentSchema, items), + type, + }; + }, + never(schema, ctx) { + const type = neverToAst({ plugin: ctx.plugin, schema }); + return { + meta: defaultMeta(schema), + type, + }; + }, + null(schema, ctx) { + const type = nullToAst({ plugin: ctx.plugin, schema }); + return { + meta: defaultMeta(schema), + type, + }; + }, + number(schema, ctx) { + const type = numberToAst({ plugin: ctx.plugin, schema }); + return { + meta: defaultMeta(schema), + type, + }; + }, + object(schema, ctx, walk) { + const type = objectToAst({ + plugin: ctx.plugin, + schema, + walk, + }); + return { + meta: defaultMeta(schema), + type, + }; + }, + postProcess(result) { + return result; + }, + reference($ref, schema, ctx) { + const symbol = ctx.plugin.referenceSymbol({ + category: 'type', + resource: 'definition', + resourceId: $ref, + }); + + if (schema.omit && schema.omit.length > 0) { + const omittedKeys = + schema.omit.length === 1 + ? $.type.literal(schema.omit[0]!) + : $.type.or(...schema.omit.map((key) => $.type.literal(key))); + return { + meta: defaultMeta(schema), + type: $.type('Omit').generics($.type(symbol), omittedKeys), + }; + } + + return { + meta: defaultMeta(schema), + type: $.type(symbol), + }; + }, + string(schema, ctx) { + const type = stringToAst({ plugin: ctx.plugin, schema }); + return { + meta: defaultMeta(schema), + type, + }; + }, + tuple(schema, ctx, walk) { + const type = tupleToAst({ + plugin: ctx.plugin, + schema, + walk, + }); + return { + meta: defaultMeta(schema), + type, + }; + }, + undefined(schema, ctx) { + const type = undefinedToAst({ plugin: ctx.plugin, schema }); + return { + meta: defaultMeta(schema), + type, + }; + }, + union(items, schemas, parentSchema) { + const type = items.length === 1 ? items[0]!.type : $.type.or(...items.map((r) => r.type)); + + return { + meta: inheritMeta(parentSchema, items), + type, + }; + }, + unknown(schema, ctx) { + const type = unknownToAst({ plugin: ctx.plugin, schema }); + return { + meta: defaultMeta(schema), + type, + }; + }, + void(schema, ctx) { + const type = voidToAst({ plugin: ctx.plugin, schema }); + return { + meta: defaultMeta(schema), + type, + }; + }, + }; +} diff --git a/packages/openapi-ts/src/plugins/@tanstack/query-core/v5/infiniteQueryOptions.ts b/packages/openapi-ts/src/plugins/@tanstack/query-core/v5/infiniteQueryOptions.ts index 716058fd5..4b124afa2 100644 --- a/packages/openapi-ts/src/plugins/@tanstack/query-core/v5/infiniteQueryOptions.ts +++ b/packages/openapi-ts/src/plugins/@tanstack/query-core/v5/infiniteQueryOptions.ts @@ -1,4 +1,3 @@ -import { ref } from '@hey-api/codegen-core'; import type { IR } from '@hey-api/shared'; import { applyNaming, operationPagination } from '@hey-api/shared'; @@ -156,13 +155,7 @@ export const createInfiniteQueryOptions = ({ ), ); const pluginTypeScript = plugin.getPluginOrThrow('@hey-api/typescript'); - const type = pluginTypeScript.api.schemaToType({ - plugin: pluginTypeScript, - schema: pagination.schema, - state: { - path: ref([]), - }, - }); + const type = pluginTypeScript.api.schemaToType(pluginTypeScript, pagination.schema); const symbolInfiniteQueryKey = plugin.symbol( applyNaming(operation.id, plugin.config.infiniteQueryKeys), diff --git a/packages/openapi-ts/src/plugins/valibot/shared/meta.ts b/packages/openapi-ts/src/plugins/valibot/shared/meta.ts index bd0bb4a21..cfbdef9e8 100644 --- a/packages/openapi-ts/src/plugins/valibot/shared/meta.ts +++ b/packages/openapi-ts/src/plugins/valibot/shared/meta.ts @@ -18,8 +18,6 @@ export function defaultMeta(schema: IR.SchemaObject): ValibotMeta { /** * Composes metadata from child results. * - * Automatically propagates hasLazy, nullable, readonly from children. - * * @param children - Results from walking child schemas * @param overrides - Explicit overrides (e.g., from parent schema) */ diff --git a/packages/openapi-ts/src/plugins/valibot/shared/processor.ts b/packages/openapi-ts/src/plugins/valibot/shared/processor.ts index 8140948d5..5955fc8f2 100644 --- a/packages/openapi-ts/src/plugins/valibot/shared/processor.ts +++ b/packages/openapi-ts/src/plugins/valibot/shared/processor.ts @@ -1,17 +1,17 @@ -import type { - IR, - NamingConfig, - SchemaProcessorContext, - SchemaProcessorResult, -} from '@hey-api/shared'; +import type { IR, NamingConfig, SchemaProcessorContext } from '@hey-api/shared'; import type { ValibotPlugin } from '../types'; +import type { ValibotFinal } from './types'; export type ProcessorContext = SchemaProcessorContext & { + /** Whether to export the result (default: true) */ + export?: boolean; naming: NamingConfig; /** The plugin instance. */ plugin: ValibotPlugin['Instance']; schema: IR.SchemaObject; }; -export type ProcessorResult = SchemaProcessorResult; +export type ProcessorResult = { + process: (ctx: ProcessorContext) => ValibotFinal | void; +}; diff --git a/packages/openapi-ts/src/plugins/valibot/shared/types.ts b/packages/openapi-ts/src/plugins/valibot/shared/types.ts index ef386d7b2..0a0519f98 100644 --- a/packages/openapi-ts/src/plugins/valibot/shared/types.ts +++ b/packages/openapi-ts/src/plugins/valibot/shared/types.ts @@ -37,8 +37,7 @@ export interface ValibotResult { /** * Finalized result after applyModifiers. */ -export interface ValibotFinal { - pipes: Pipes; +export interface ValibotFinal extends Pick { /** Type annotation for schemas requiring explicit typing (e.g., lazy). */ typeName?: string | ts.Identifier; } diff --git a/packages/openapi-ts/src/plugins/valibot/v1/processor.ts b/packages/openapi-ts/src/plugins/valibot/v1/processor.ts index 4337b6e84..ad4b6ee34 100644 --- a/packages/openapi-ts/src/plugins/valibot/v1/processor.ts +++ b/packages/openapi-ts/src/plugins/valibot/v1/processor.ts @@ -36,10 +36,12 @@ export function createProcessor(plugin: ValibotPlugin['Instance']): ProcessorRes return ctx.schema; } - function process(ctx: ProcessorContext): void { + function process(ctx: ProcessorContext): ValibotFinal | void { if (!processor.markEmitted(ctx.path)) return; - processor.withContext({ anchor: ctx.namingAnchor, tags: ctx.tags }, () => { + const shouldExport = ctx.export !== false; + + return processor.withContext({ anchor: ctx.namingAnchor, tags: ctx.tags }, () => { const visitor = createVisitor({ schemaExtractor: extractor }); const walk = createSchemaWalker(visitor); @@ -53,7 +55,12 @@ export function createProcessor(plugin: ValibotPlugin['Instance']): ProcessorRes plugin, }) as ValibotFinal; - exportAst({ ...ctx, final, plugin }); + if (shouldExport) { + exportAst({ ...ctx, final, plugin }); + return; + } + + return final; }); } diff --git a/packages/openapi-ts/src/plugins/zod/mini/processor.ts b/packages/openapi-ts/src/plugins/zod/mini/processor.ts index 9868559f1..f17d630d5 100644 --- a/packages/openapi-ts/src/plugins/zod/mini/processor.ts +++ b/packages/openapi-ts/src/plugins/zod/mini/processor.ts @@ -36,7 +36,7 @@ export function createProcessor(plugin: ZodPlugin['Instance']): ProcessorResult function process(ctx: ProcessorContext): void { if (!processor.markEmitted(ctx.path)) return; - processor.withContext({ anchor: ctx.namingAnchor, tags: ctx.tags }, () => { + return processor.withContext({ anchor: ctx.namingAnchor, tags: ctx.tags }, () => { const state = refs({ hasLazyExpression: false, path: ctx.path, diff --git a/packages/openapi-ts/src/plugins/zod/v3/processor.ts b/packages/openapi-ts/src/plugins/zod/v3/processor.ts index 9868559f1..f17d630d5 100644 --- a/packages/openapi-ts/src/plugins/zod/v3/processor.ts +++ b/packages/openapi-ts/src/plugins/zod/v3/processor.ts @@ -36,7 +36,7 @@ export function createProcessor(plugin: ZodPlugin['Instance']): ProcessorResult function process(ctx: ProcessorContext): void { if (!processor.markEmitted(ctx.path)) return; - processor.withContext({ anchor: ctx.namingAnchor, tags: ctx.tags }, () => { + return processor.withContext({ anchor: ctx.namingAnchor, tags: ctx.tags }, () => { const state = refs({ hasLazyExpression: false, path: ctx.path, diff --git a/packages/openapi-ts/src/plugins/zod/v4/processor.ts b/packages/openapi-ts/src/plugins/zod/v4/processor.ts index 9868559f1..f17d630d5 100644 --- a/packages/openapi-ts/src/plugins/zod/v4/processor.ts +++ b/packages/openapi-ts/src/plugins/zod/v4/processor.ts @@ -36,7 +36,7 @@ export function createProcessor(plugin: ZodPlugin['Instance']): ProcessorResult function process(ctx: ProcessorContext): void { if (!processor.markEmitted(ctx.path)) return; - processor.withContext({ anchor: ctx.namingAnchor, tags: ctx.tags }, () => { + return processor.withContext({ anchor: ctx.namingAnchor, tags: ctx.tags }, () => { const state = refs({ hasLazyExpression: false, path: ctx.path, diff --git a/packages/openapi-ts/src/ts-dsl/type/fromValue.ts b/packages/openapi-ts/src/ts-dsl/type/fromValue.ts index 8ad2c4bb3..b9ddaeb6f 100644 --- a/packages/openapi-ts/src/ts-dsl/type/fromValue.ts +++ b/packages/openapi-ts/src/ts-dsl/type/fromValue.ts @@ -15,7 +15,12 @@ export const fromValue = (input: unknown): TsDsl => { return new TypeLiteralTsDsl(input); } - if (typeof input === 'number' || typeof input === 'boolean' || typeof input === 'string') { + if ( + typeof input === 'number' || + typeof input === 'boolean' || + typeof input === 'string' || + typeof input === 'bigint' + ) { return new TypeLiteralTsDsl(input); } diff --git a/packages/openapi-ts/src/ts-dsl/type/literal.ts b/packages/openapi-ts/src/ts-dsl/type/literal.ts index 263baf07a..35c7ee87e 100644 --- a/packages/openapi-ts/src/ts-dsl/type/literal.ts +++ b/packages/openapi-ts/src/ts-dsl/type/literal.ts @@ -2,7 +2,7 @@ import type { AnalysisContext, NodeScope } from '@hey-api/codegen-core'; import ts from 'typescript'; import { TsDsl } from '../base'; -import { LiteralTsDsl } from '../expr/literal'; +import { LiteralTsDsl, type LiteralValue } from '../expr/literal'; const Mixed = TsDsl; @@ -10,9 +10,9 @@ export class TypeLiteralTsDsl extends Mixed { readonly '~dsl' = 'TypeLiteralTsDsl'; override scope: NodeScope = 'type'; - protected value: string | number | boolean | null; + protected value: LiteralValue; - constructor(value: string | number | boolean | null) { + constructor(value: LiteralValue) { super(); this.value = value; }