From 27cd91f530bd77da6ea95df1704b2947917b4626 Mon Sep 17 00:00:00 2001 From: Lubos Date: Mon, 9 Feb 2026 01:22:43 +0800 Subject: [PATCH] refactor: allow path in top-level detect function --- .changeset/fluffy-words-dress.md | 5 + .changeset/smart-comics-talk.md | 5 + dev/openapi-python.config.ts | 2 +- dev/openapi-ts.config.ts | 2 +- .../src/plugins/pydantic/shared/types.ts | 6 +- .../src/plugins/pydantic/v2/plugin.ts | 13 ++ .../@hey-api/typescript/shared/types.ts | 5 + .../plugins/@hey-api/typescript/v1/plugin.ts | 21 +- .../src/plugins/arktype/shared/types.ts | 6 +- .../src/plugins/arktype/v2/plugin.ts | 13 ++ .../src/plugins/valibot/resolvers/types.ts | 48 ++--- .../src/plugins/valibot/shared/export.ts | 31 ++- .../valibot/shared/operation-schema.ts | 58 ++++++ .../src/plugins/valibot/shared/operation.ts | 158 +++------------ .../src/plugins/valibot/shared/processor.ts | 16 ++ .../src/plugins/valibot/shared/types.ts | 7 +- .../src/plugins/valibot/shared/webhook.ts | 140 ++----------- .../src/plugins/valibot/v1/plugin.ts | 128 +++++------- .../src/plugins/valibot/v1/processor.ts | 58 ++++++ .../src/plugins/valibot/v1/toAst/array.ts | 29 ++- .../src/plugins/valibot/v1/toAst/enum.ts | 6 +- .../src/plugins/valibot/v1/toAst/never.ts | 6 +- .../src/plugins/valibot/v1/toAst/null.ts | 6 +- .../src/plugins/valibot/v1/toAst/number.ts | 6 +- .../src/plugins/valibot/v1/toAst/object.ts | 24 +-- .../src/plugins/valibot/v1/toAst/string.ts | 6 +- .../src/plugins/valibot/v1/toAst/tuple.ts | 25 ++- .../src/plugins/valibot/v1/toAst/undefined.ts | 6 +- .../src/plugins/valibot/v1/toAst/unknown.ts | 6 +- .../src/plugins/valibot/v1/toAst/void.ts | 6 +- .../openapi-ts/src/plugins/zod/mini/plugin.ts | 126 +++++------- .../src/plugins/zod/mini/processor.ts | 58 ++++++ .../src/plugins/zod/shared/export.ts | 49 +++-- .../plugins/zod/shared/operation-schema.ts | 58 ++++++ .../src/plugins/zod/shared/operation.ts | 186 +++-------------- .../src/plugins/zod/shared/processor.ts | 16 ++ .../src/plugins/zod/shared/types.ts | 15 +- .../src/plugins/zod/shared/webhook.ts | 154 ++------------ packages/openapi-ts/src/plugins/zod/types.ts | 37 +--- .../openapi-ts/src/plugins/zod/v3/plugin.ts | 126 +++++------- .../src/plugins/zod/v3/processor.ts | 58 ++++++ .../openapi-ts/src/plugins/zod/v4/plugin.ts | 126 +++++------- .../src/plugins/zod/v4/processor.ts | 58 ++++++ packages/shared/src/index.ts | 13 +- packages/shared/src/ir/utils.ts | 35 ---- .../shared/src/openApi/2.0.x/parser/schema.ts | 4 +- .../shared/src/openApi/3.0.x/parser/schema.ts | 4 +- .../shared/src/openApi/3.1.x/parser/schema.ts | 4 +- packages/shared/src/parser/hooks.ts | 23 +++ packages/shared/src/plugins/index.ts | 2 - .../shared/src/plugins/schema-processor.ts | 70 +++++++ .../shared/src/utils/__tests__/path.test.ts | 188 ++++++++++++++++++ .../shared/src/utils/__tests__/ref.test.ts | 30 +-- packages/shared/src/utils/path.ts | 142 +++++++++++++ packages/shared/src/utils/ref.ts | 8 +- turbo.json | 8 +- 56 files changed, 1378 insertions(+), 1068 deletions(-) create mode 100644 .changeset/fluffy-words-dress.md create mode 100644 .changeset/smart-comics-talk.md create mode 100644 packages/openapi-ts/src/plugins/valibot/shared/operation-schema.ts create mode 100644 packages/openapi-ts/src/plugins/valibot/shared/processor.ts create mode 100644 packages/openapi-ts/src/plugins/valibot/v1/processor.ts create mode 100644 packages/openapi-ts/src/plugins/zod/mini/processor.ts create mode 100644 packages/openapi-ts/src/plugins/zod/shared/operation-schema.ts create mode 100644 packages/openapi-ts/src/plugins/zod/shared/processor.ts create mode 100644 packages/openapi-ts/src/plugins/zod/v3/processor.ts create mode 100644 packages/openapi-ts/src/plugins/zod/v4/processor.ts delete mode 100644 packages/shared/src/plugins/index.ts create mode 100644 packages/shared/src/plugins/schema-processor.ts create mode 100644 packages/shared/src/utils/__tests__/path.test.ts create mode 100644 packages/shared/src/utils/path.ts diff --git a/.changeset/fluffy-words-dress.md b/.changeset/fluffy-words-dress.md new file mode 100644 index 000000000..216074663 --- /dev/null +++ b/.changeset/fluffy-words-dress.md @@ -0,0 +1,5 @@ +--- +"@hey-api/shared": minor +--- + +**utils**: rename `isTopLevelComponentRef` to `isTopLevelComponent` diff --git a/.changeset/smart-comics-talk.md b/.changeset/smart-comics-talk.md new file mode 100644 index 000000000..1f8fb5553 --- /dev/null +++ b/.changeset/smart-comics-talk.md @@ -0,0 +1,5 @@ +--- +"@hey-api/openapi-ts": patch +--- + +**internal**: use shared schema processor diff --git a/dev/openapi-python.config.ts b/dev/openapi-python.config.ts index ea3f76ebb..91b64d75e 100644 --- a/dev/openapi-python.config.ts +++ b/dev/openapi-python.config.ts @@ -12,7 +12,7 @@ export default defineConfig(() => [ path: './logs', }, output: { - path: path.resolve(__dirname, '..', '.gen', 'python'), + path: path.resolve(__dirname, '.gen', 'python'), }, plugins: getPreset(), }, diff --git a/dev/openapi-ts.config.ts b/dev/openapi-ts.config.ts index ec6f7a6bc..ed64f0c03 100644 --- a/dev/openapi-ts.config.ts +++ b/dev/openapi-ts.config.ts @@ -12,7 +12,7 @@ export default defineConfig(() => [ path: './logs', }, output: { - path: path.resolve(__dirname, '..', '.gen', 'typescript'), + path: path.resolve(__dirname, '.gen', 'typescript'), }, plugins: getPreset(), }, diff --git a/packages/openapi-python/src/plugins/pydantic/shared/types.ts b/packages/openapi-python/src/plugins/pydantic/shared/types.ts index edd19b6e5..76afbb1be 100644 --- a/packages/openapi-python/src/plugins/pydantic/shared/types.ts +++ b/packages/openapi-python/src/plugins/pydantic/shared/types.ts @@ -1,5 +1,5 @@ import type { Refs, Symbol, SymbolMeta } from '@hey-api/codegen-core'; -import type { IR } from '@hey-api/shared'; +import type { IR, SchemaExtractor } from '@hey-api/shared'; import type { $ } from '../../../py-dsl'; import type { PydanticPlugin } from '../types'; @@ -29,7 +29,11 @@ export type Ast = { }; export type IrSchemaToAstOptions = { + /** The plugin instance. */ plugin: PydanticPlugin['Instance']; + /** Optional schema extractor function. */ + schemaExtractor?: SchemaExtractor; + /** The plugin state references. */ state: Refs; }; diff --git a/packages/openapi-python/src/plugins/pydantic/v2/plugin.ts b/packages/openapi-python/src/plugins/pydantic/v2/plugin.ts index 3d7b9d900..617a2829c 100644 --- a/packages/openapi-python/src/plugins/pydantic/v2/plugin.ts +++ b/packages/openapi-python/src/plugins/pydantic/v2/plugin.ts @@ -13,11 +13,24 @@ export function irSchemaToAst({ optional, plugin, schema, + schemaExtractor, state, }: IrSchemaToAstOptions & { optional?: boolean; schema: IR.SchemaObject; }): Ast { + 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.$ref) { const query: SymbolMeta = { category: 'schema', 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 c234dc895..0dc21b299 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,9 +1,14 @@ import type { Refs, SymbolMeta } from '@hey-api/codegen-core'; +import type { SchemaExtractor } from '@hey-api/shared'; import type { HeyApiTypeScriptPlugin } from '../types'; export type IrSchemaToAstOptions = { + /** The plugin instance. */ plugin: HeyApiTypeScriptPlugin['Instance']; + /** Optional schema extractor function. */ + schemaExtractor?: SchemaExtractor; + /** The plugin state references. */ state: Refs; }; 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 fa8bcbf9b..223a06b36 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,9 +1,7 @@ import type { Symbol } from '@hey-api/codegen-core'; -import { refs } from '@hey-api/codegen-core'; -import type { IR } from '@hey-api/shared'; -import type { SchemaWithType } from '@hey-api/shared'; -import { applyNaming } from '@hey-api/shared'; -import { deduplicateSchema } from '@hey-api/shared'; +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 { MaybeTsDsl, TypeTsDsl } from '../../../../ts-dsl'; import { $ } from '../../../../ts-dsl'; @@ -18,10 +16,23 @@ 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) { diff --git a/packages/openapi-ts/src/plugins/arktype/shared/types.ts b/packages/openapi-ts/src/plugins/arktype/shared/types.ts index b8ff97fd8..5679c6c52 100644 --- a/packages/openapi-ts/src/plugins/arktype/shared/types.ts +++ b/packages/openapi-ts/src/plugins/arktype/shared/types.ts @@ -1,5 +1,5 @@ import type { Refs, SymbolMeta } from '@hey-api/codegen-core'; -import type { IR } from '@hey-api/shared'; +import type { IR, SchemaExtractor } from '@hey-api/shared'; import type ts from 'typescript'; import type { $ } from '../../../ts-dsl'; @@ -13,7 +13,11 @@ export type Ast = { }; export type IrSchemaToAstOptions = { + /** The plugin instance. */ plugin: ArktypePlugin['Instance']; + /** Optional schema extractor function. */ + schemaExtractor?: SchemaExtractor; + /** The plugin state references. */ state: Refs; }; diff --git a/packages/openapi-ts/src/plugins/arktype/v2/plugin.ts b/packages/openapi-ts/src/plugins/arktype/v2/plugin.ts index 165511dba..a4757063b 100644 --- a/packages/openapi-ts/src/plugins/arktype/v2/plugin.ts +++ b/packages/openapi-ts/src/plugins/arktype/v2/plugin.ts @@ -13,6 +13,7 @@ export function irSchemaToAst({ // optional, plugin, schema, + schemaExtractor, state, }: IrSchemaToAstOptions & { /** @@ -23,6 +24,18 @@ export function irSchemaToAst({ optional?: boolean; schema: IR.SchemaObject; }): Ast { + 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; + } + let ast: Partial = {}; // const z = plugin.referenceSymbol({ diff --git a/packages/openapi-ts/src/plugins/valibot/resolvers/types.ts b/packages/openapi-ts/src/plugins/valibot/resolvers/types.ts index dd3f1f9c8..49ee9fed3 100644 --- a/packages/openapi-ts/src/plugins/valibot/resolvers/types.ts +++ b/packages/openapi-ts/src/plugins/valibot/resolvers/types.ts @@ -5,8 +5,7 @@ import type { MaybeBigInt, ShouldCoerceToBigInt } from '../../../plugins/shared/ import type { GetIntegerLimit } from '../../../plugins/shared/utils/formats'; import type { $, DollarTsDsl } from '../../../ts-dsl'; import type { Pipe, PipeResult, Pipes, PipesUtils } from '../shared/pipes'; -import type { Ast, PluginState } from '../shared/types'; -import type { ValibotPlugin } from '../types'; +import type { Ast, IrSchemaToAstOptions, PluginState } from '../shared/types'; export type Resolvers = Plugin.Resolvers<{ /** @@ -70,33 +69,30 @@ export type Resolvers = Plugin.Resolvers<{ type ValidatorResolver = (ctx: ValidatorResolverContext) => PipeResult | null | undefined; -interface BaseContext extends DollarTsDsl { - /** - * Functions for working with pipes. - */ - pipes: PipesUtils & { +type BaseContext = DollarTsDsl & + Pick & { /** - * The current pipe. - * - * In Valibot, this represents a list of call expressions ("pipes") - * being assembled to form a schema definition. - * - * Each pipe can be extended, modified, or replaced to customize - * the resulting schema. + * Functions for working with pipes. */ - current: Pipes; - }; - /** - * The plugin instance. - */ - plugin: ValibotPlugin['Instance']; - /** - * Provides access to commonly used symbols within the plugin. - */ - symbols: { - v: Symbol; + pipes: PipesUtils & { + /** + * The current pipe. + * + * In Valibot, this represents a list of call expressions ("pipes") + * being assembled to form a schema definition. + * + * Each pipe can be extended, modified, or replaced to customize + * the resulting schema. + */ + current: Pipes; + }; + /** + * Provides access to commonly used symbols within the plugin. + */ + symbols: { + v: Symbol; + }; }; -} export interface EnumResolverContext extends BaseContext { /** diff --git a/packages/openapi-ts/src/plugins/valibot/shared/export.ts b/packages/openapi-ts/src/plugins/valibot/shared/export.ts index d253e9b18..9b2490449 100644 --- a/packages/openapi-ts/src/plugins/valibot/shared/export.ts +++ b/packages/openapi-ts/src/plugins/valibot/shared/export.ts @@ -1,24 +1,39 @@ -import type { Symbol } from '@hey-api/codegen-core'; -import type { IR } from '@hey-api/shared'; +import { applyNaming, pathToName } from '@hey-api/shared'; import { createSchemaComment } from '../../../plugins/shared/utils/schema'; import { $ } from '../../../ts-dsl'; import { identifiers } from '../v1/constants'; import { pipesToNode } from './pipes'; +import type { ProcessorContext } from './processor'; import type { Ast, IrSchemaToAstOptions } from './types'; export function exportAst({ ast, + meta, + naming, + namingAnchor, + path, plugin, schema, state, - symbol, -}: IrSchemaToAstOptions & { - ast: Ast; - schema: IR.SchemaObject; - symbol: Symbol; -}): void { + tags, +}: Pick & + ProcessorContext & { + ast: Ast; + }): void { const v = plugin.external('valibot.v'); + + const name = pathToName(path, { anchor: namingAnchor }); + const symbol = plugin.symbol(applyNaming(name, naming), { + meta: { + category: 'schema', + path, + tags, + tool: 'valibot', + ...meta, + }, + }); + const statement = $.const(symbol) .export() .$if(plugin.config.comments && createSchemaComment(schema), (c, v) => c.doc(v)) diff --git a/packages/openapi-ts/src/plugins/valibot/shared/operation-schema.ts b/packages/openapi-ts/src/plugins/valibot/shared/operation-schema.ts new file mode 100644 index 000000000..f66658b29 --- /dev/null +++ b/packages/openapi-ts/src/plugins/valibot/shared/operation-schema.ts @@ -0,0 +1,58 @@ +import type { IR } from '@hey-api/shared'; + +export interface OperationSchemaResult { + required: ReadonlyArray; + schema: IR.SchemaObject; +} + +export function buildOperationSchema(operation: IR.OperationObject): OperationSchemaResult { + const requiredProperties = new Set(); + + const schema: IR.SchemaObject = { + properties: { + body: { type: 'never' }, + path: { type: 'never' }, + query: { type: 'never' }, + }, + type: 'object', + }; + + if (operation.parameters) { + // TODO: add support for cookies + + for (const location of ['header', 'path', 'query'] satisfies ReadonlyArray< + keyof typeof operation.parameters + >) { + const params = operation.parameters[location]; + if (!params) continue; + + const properties: Record = {}; + const required: Array = []; + const propKey = location === 'header' ? 'headers' : location; + + for (const key in params) { + const parameter = params[key]!; + properties[parameter.name] = parameter.schema; + if (parameter.required) { + required.push(parameter.name); + requiredProperties.add(propKey); + } + } + + if (Object.keys(properties).length) { + schema.properties![propKey] = { properties, required, type: 'object' }; + } + } + } + + if (operation.body) { + schema.properties!.body = operation.body.schema; + if (operation.body.required) { + requiredProperties.add('body'); + } + } + + schema.required = [...requiredProperties]; + + return { required: schema.required, schema }; +} diff --git a/packages/openapi-ts/src/plugins/valibot/shared/operation.ts b/packages/openapi-ts/src/plugins/valibot/shared/operation.ts index 7050be938..4672ece6f 100644 --- a/packages/openapi-ts/src/plugins/valibot/shared/operation.ts +++ b/packages/openapi-ts/src/plugins/valibot/shared/operation.ts @@ -1,135 +1,36 @@ -import { fromRef } from '@hey-api/codegen-core'; import type { IR } from '@hey-api/shared'; -import { applyNaming, operationResponsesMap } from '@hey-api/shared'; +import { operationResponsesMap } from '@hey-api/shared'; -import { exportAst } from './export'; -import type { Ast, IrSchemaToAstOptions } from './types'; +import { buildOperationSchema } from './operation-schema'; +import type { ProcessorContext, ProcessorResult } from './processor'; +import type { IrSchemaToAstOptions } from './types'; -export const irOperationToAst = ({ - getAst, +export function irOperationToAst({ operation, + path, plugin, - state, -}: IrSchemaToAstOptions & { - getAst: (schema: IR.SchemaObject, path: ReadonlyArray) => Ast; - operation: IR.OperationObject; -}) => { + processor, + tags, +}: Pick & + Pick & { + operation: IR.OperationObject; + processor: ProcessorResult; + }): void { if (plugin.config.requests.enabled) { - const requiredProperties = new Set(); + const { schema } = buildOperationSchema(operation); - const schemaData: IR.SchemaObject = { - properties: { - body: { - type: 'never', - }, - path: { - type: 'never', - }, - query: { - type: 'never', - }, - }, - type: 'object', - }; - - if (operation.parameters) { - // TODO: add support for cookies - - if (operation.parameters.header) { - const properties: Record = {}; - const required: Array = []; - - for (const key in operation.parameters.header) { - const parameter = operation.parameters.header[key]!; - properties[parameter.name] = parameter.schema; - if (parameter.required) { - required.push(parameter.name); - requiredProperties.add('headers'); - } - } - - if (Object.keys(properties).length) { - schemaData.properties!.headers = { - properties, - required, - type: 'object', - }; - } - } - - if (operation.parameters.path) { - const properties: Record = {}; - const required: Array = []; - - for (const key in operation.parameters.path) { - const parameter = operation.parameters.path[key]!; - properties[parameter.name] = parameter.schema; - if (parameter.required) { - required.push(parameter.name); - requiredProperties.add('path'); - } - } - - if (Object.keys(properties).length) { - schemaData.properties!.path = { - properties, - required, - type: 'object', - }; - } - } - - if (operation.parameters.query) { - const properties: Record = {}; - const required: Array = []; - - for (const key in operation.parameters.query) { - const parameter = operation.parameters.query[key]!; - properties[parameter.name] = parameter.schema; - if (parameter.required) { - required.push(parameter.name); - requiredProperties.add('query'); - } - } - - if (Object.keys(properties).length) { - schemaData.properties!.query = { - properties, - required, - type: 'object', - }; - } - } - } - - if (operation.body) { - schemaData.properties!.body = operation.body.schema; - - if (operation.body.required) { - requiredProperties.add('body'); - } - } - - schemaData.required = [...requiredProperties]; - - const ast = getAst(schemaData, fromRef(state.path)); - const symbol = plugin.symbol(applyNaming(operation.id, plugin.config.requests), { + processor.process({ meta: { - category: 'schema', - path: fromRef(state.path), resource: 'operation', resourceId: operation.id, role: 'data', - tags: fromRef(state.tags), - tool: 'valibot', }, - }); - exportAst({ - ast, + naming: plugin.config.requests, + namingAnchor: operation.id, + path, plugin, - schema: schemaData, - state, - symbol, + schema, + tags, }); } @@ -138,27 +39,20 @@ export const irOperationToAst = ({ const { response } = operationResponsesMap(operation); if (response) { - const path = [...fromRef(state.path), 'responses']; - const ast = getAst(response, path); - const symbol = plugin.symbol(applyNaming(operation.id, plugin.config.responses), { + processor.process({ meta: { - category: 'schema', - path, resource: 'operation', resourceId: operation.id, role: 'responses', - tags: fromRef(state.tags), - tool: 'valibot', }, - }); - exportAst({ - ast, + naming: plugin.config.responses, + namingAnchor: operation.id, + path: [...path, 'responses'], plugin, schema: response, - state, - symbol, + tags, }); } } } -}; +} diff --git a/packages/openapi-ts/src/plugins/valibot/shared/processor.ts b/packages/openapi-ts/src/plugins/valibot/shared/processor.ts new file mode 100644 index 000000000..c1eba30cc --- /dev/null +++ b/packages/openapi-ts/src/plugins/valibot/shared/processor.ts @@ -0,0 +1,16 @@ +import type { + IR, + NamingConfig, + SchemaProcessorContext, + SchemaProcessorResult, +} from '@hey-api/shared'; + +import type { IrSchemaToAstOptions } from './types'; + +export type ProcessorContext = Pick & + SchemaProcessorContext & { + naming: NamingConfig; + schema: IR.SchemaObject; + }; + +export type ProcessorResult = SchemaProcessorResult; diff --git a/packages/openapi-ts/src/plugins/valibot/shared/types.ts b/packages/openapi-ts/src/plugins/valibot/shared/types.ts index 49cdc9168..0cb6b4815 100644 --- a/packages/openapi-ts/src/plugins/valibot/shared/types.ts +++ b/packages/openapi-ts/src/plugins/valibot/shared/types.ts @@ -1,9 +1,10 @@ import type { Refs, SymbolMeta } from '@hey-api/codegen-core'; -import type { IR } from '@hey-api/shared'; +import type { IR, SchemaExtractor } from '@hey-api/shared'; import type ts from 'typescript'; import type { ValibotPlugin } from '../types'; import type { Pipes } from './pipes'; +import type { ProcessorContext } from './processor'; export type Ast = { hasLazyExpression?: boolean; @@ -12,7 +13,11 @@ export type Ast = { }; export type IrSchemaToAstOptions = { + /** The plugin instance. */ plugin: ValibotPlugin['Instance']; + /** Optional schema extractor function. */ + schemaExtractor?: SchemaExtractor; + /** The plugin state references. */ state: Refs; }; diff --git a/packages/openapi-ts/src/plugins/valibot/shared/webhook.ts b/packages/openapi-ts/src/plugins/valibot/shared/webhook.ts index d9520c6c1..56e1537cf 100644 --- a/packages/openapi-ts/src/plugins/valibot/shared/webhook.ts +++ b/packages/openapi-ts/src/plugins/valibot/shared/webhook.ts @@ -1,135 +1,35 @@ -import { fromRef } from '@hey-api/codegen-core'; import type { IR } from '@hey-api/shared'; -import { applyNaming } from '@hey-api/shared'; -import { exportAst } from './export'; -import type { Ast, IrSchemaToAstOptions } from './types'; +import { buildOperationSchema } from './operation-schema'; +import type { ProcessorContext, ProcessorResult } from './processor'; +import type { IrSchemaToAstOptions } from './types'; -export const irWebhookToAst = ({ - getAst, +export function irWebhookToAst({ operation, + path, plugin, - state, -}: IrSchemaToAstOptions & { - getAst: (schema: IR.SchemaObject, path: ReadonlyArray) => Ast; - operation: IR.OperationObject; -}) => { + processor, + tags, +}: Pick & + Pick & { + operation: IR.OperationObject; + processor: ProcessorResult; + }): void { if (plugin.config.webhooks.enabled) { - const requiredProperties = new Set(); + const { schema } = buildOperationSchema(operation); - const schemaData: IR.SchemaObject = { - properties: { - body: { - type: 'never', - }, - path: { - type: 'never', - }, - query: { - type: 'never', - }, - }, - type: 'object', - }; - - if (operation.parameters) { - // TODO: add support for cookies - - if (operation.parameters.header) { - const properties: Record = {}; - const required: Array = []; - - for (const key in operation.parameters.header) { - const parameter = operation.parameters.header[key]!; - properties[parameter.name] = parameter.schema; - if (parameter.required) { - required.push(parameter.name); - requiredProperties.add('headers'); - } - } - - if (Object.keys(properties).length) { - schemaData.properties!.headers = { - properties, - required, - type: 'object', - }; - } - } - - if (operation.parameters.path) { - const properties: Record = {}; - const required: Array = []; - - for (const key in operation.parameters.path) { - const parameter = operation.parameters.path[key]!; - properties[parameter.name] = parameter.schema; - if (parameter.required) { - required.push(parameter.name); - requiredProperties.add('path'); - } - } - - if (Object.keys(properties).length) { - schemaData.properties!.path = { - properties, - required, - type: 'object', - }; - } - } - - if (operation.parameters.query) { - const properties: Record = {}; - const required: Array = []; - - for (const key in operation.parameters.query) { - const parameter = operation.parameters.query[key]!; - properties[parameter.name] = parameter.schema; - if (parameter.required) { - required.push(parameter.name); - requiredProperties.add('query'); - } - } - - if (Object.keys(properties).length) { - schemaData.properties!.query = { - properties, - required, - type: 'object', - }; - } - } - } - - if (operation.body) { - schemaData.properties!.body = operation.body.schema; - - if (operation.body.required) { - requiredProperties.add('body'); - } - } - - schemaData.required = [...requiredProperties]; - - const ast = getAst(schemaData, fromRef(state.path)); - const symbol = plugin.symbol(applyNaming(operation.id, plugin.config.webhooks), { + processor.process({ meta: { - category: 'schema', - path: fromRef(state.path), resource: 'webhook', resourceId: operation.id, role: 'data', - tags: fromRef(state.tags), - tool: 'valibot', }, - }); - exportAst({ - ast, + naming: plugin.config.webhooks, + namingAnchor: operation.id, + path, plugin, - schema: schemaData, - state, - symbol, + schema, + tags, }); } -}; +} diff --git a/packages/openapi-ts/src/plugins/valibot/v1/plugin.ts b/packages/openapi-ts/src/plugins/valibot/v1/plugin.ts index 4600e6555..1f8694a76 100644 --- a/packages/openapi-ts/src/plugins/valibot/v1/plugin.ts +++ b/packages/openapi-ts/src/plugins/valibot/v1/plugin.ts @@ -1,30 +1,24 @@ import type { SymbolMeta } from '@hey-api/codegen-core'; -import { fromRef, ref, refs } from '@hey-api/codegen-core'; -import type { IR, SchemaExtractor, SchemaWithType } from '@hey-api/shared'; -import { - applyNaming, - deduplicateSchema, - inlineSchema, - pathToJsonPointer, - refToName, -} from '@hey-api/shared'; +import { fromRef, ref } from '@hey-api/codegen-core'; +import type { IR, SchemaWithType } from '@hey-api/shared'; +import { deduplicateSchema, pathToJsonPointer } from '@hey-api/shared'; import { maybeBigInt } from '../../../plugins/shared/utils/coerce'; import { $ } from '../../../ts-dsl'; -import { exportAst } from '../shared/export'; import { irOperationToAst } from '../shared/operation'; import { pipesToNode } from '../shared/pipes'; -import type { Ast, IrSchemaToAstOptions, PluginState } from '../shared/types'; +import type { Ast, IrSchemaToAstOptions } from '../shared/types'; import { irWebhookToAst } from '../shared/webhook'; import type { ValibotPlugin } from '../types'; import { identifiers } from './constants'; +import { createProcessor } from './processor'; import { irSchemaWithTypeToAst } from './toAst'; export function irSchemaToAst({ optional, plugin, schema, - schemaExtractor = inlineSchema, + schemaExtractor, state, }: IrSchemaToAstOptions & { /** @@ -34,13 +28,19 @@ export function irSchemaToAst({ */ optional?: boolean; schema: IR.SchemaObject; - schemaExtractor?: SchemaExtractor; }): Ast { - if (!schema.$ref) { - const resolved = schemaExtractor({ path: fromRef(state.path), schema }); - if (resolved !== schema) { - schema = resolved; - } + if (schemaExtractor && !schema.$ref) { + const extracted = schemaExtractor({ + meta: { + resource: 'definition', + resourceId: pathToJsonPointer(fromRef(state.path)), + }, + naming: plugin.config.definitions, + path: fromRef(state.path), + plugin, + schema, + }); + if (extracted !== schema) schema = extracted; } const ast: Ast = { @@ -71,6 +71,7 @@ export function irSchemaToAst({ const typeAst = irSchemaWithTypeToAst({ plugin, schema: schema as SchemaWithType, + schemaExtractor, state, }); ast.typeName = typeAst.anyType; @@ -121,6 +122,7 @@ export function irSchemaToAst({ schema: { type: 'unknown', }, + schemaExtractor, state, }); ast.typeName = typeAst.anyType; @@ -152,35 +154,6 @@ export function irSchemaToAst({ return ast as Ast; } -function handleComponent({ - plugin, - schema, - state, -}: IrSchemaToAstOptions & { - schema: IR.SchemaObject; -}): void { - const $ref = pathToJsonPointer(fromRef(state.path)); - const ast = irSchemaToAst({ plugin, schema, state }); - const baseName = refToName($ref); - const symbol = plugin.symbol(applyNaming(baseName, plugin.config.definitions), { - meta: { - category: 'schema', - path: fromRef(state.path), - resource: 'definition', - resourceId: $ref, - tags: fromRef(state.tags), - tool: 'valibot', - }, - }); - exportAst({ - ast, - plugin, - schema, - state, - symbol, - }); -} - export const handlerV1: ValibotPlugin['Handler'] = ({ plugin }) => { plugin.symbol('v', { external: 'valibot', @@ -191,62 +164,65 @@ export const handlerV1: ValibotPlugin['Handler'] = ({ plugin }) => { }, }); + const processor = createProcessor(plugin); + plugin.forEach('operation', 'parameter', 'requestBody', 'schema', 'webhook', (event) => { - const state = refs({ - hasLazyExpression: false, - path: event._path, - tags: event.tags, - }); switch (event.type) { case 'operation': irOperationToAst({ - getAst: (schema, path) => { - const state = refs({ - hasLazyExpression: false, - path, - tags: event.tags, - }); - return irSchemaToAst({ plugin, schema, state }); - }, operation: event.operation, + path: event._path, plugin, - state, + processor, + 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 'webhook': irWebhookToAst({ - getAst: (schema, path) => { - const state = refs({ - hasLazyExpression: false, - path, - tags: event.tags, - }); - return irSchemaToAst({ plugin, schema, state }); - }, operation: event.operation, + path: event._path, plugin, - state, + processor, + tags: event.tags, }); break; } diff --git a/packages/openapi-ts/src/plugins/valibot/v1/processor.ts b/packages/openapi-ts/src/plugins/valibot/v1/processor.ts new file mode 100644 index 000000000..b267cdf79 --- /dev/null +++ b/packages/openapi-ts/src/plugins/valibot/v1/processor.ts @@ -0,0 +1,58 @@ +import { refs } from '@hey-api/codegen-core'; +import type { IR } from '@hey-api/shared'; +import { createSchemaProcessor, pathToJsonPointer } from '@hey-api/shared'; + +import { exportAst } from '../shared/export'; +import type { ProcessorContext, ProcessorResult } from '../shared/processor'; +import type { PluginState } from '../shared/types'; +import type { ValibotPlugin } from '../types'; +import { irSchemaToAst } from './plugin'; + +export function createProcessor(plugin: ValibotPlugin['Instance']): ProcessorResult { + const processor = createSchemaProcessor(); + + const hooks = [plugin.config['~hooks']?.schemas, plugin.context.config.parser.hooks.schemas]; + + function extractor(ctx: ProcessorContext): IR.SchemaObject { + if (processor.hasEmitted(ctx.path)) { + return ctx.schema; + } + + for (const hook of hooks) { + const result = hook?.shouldExtract?.(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): void { + if (!processor.markEmitted(ctx.path)) return; + + processor.withContext({ anchor: ctx.namingAnchor, tags: ctx.tags }, () => { + const state = refs({ + hasLazyExpression: false, + path: ctx.path, + tags: ctx.tags, + }); + + const ast = irSchemaToAst({ + plugin, + schema: ctx.schema, + schemaExtractor: extractor, + state, + }); + + exportAst({ ...ctx, ast, plugin, state }); + }); + } + + return { process }; +} diff --git a/packages/openapi-ts/src/plugins/valibot/v1/toAst/array.ts b/packages/openapi-ts/src/plugins/valibot/v1/toAst/array.ts index 40e69182d..3d80b460b 100644 --- a/packages/openapi-ts/src/plugins/valibot/v1/toAst/array.ts +++ b/packages/openapi-ts/src/plugins/valibot/v1/toAst/array.ts @@ -9,13 +9,14 @@ import { identifiers } from '../constants'; import { irSchemaToAst } from '../plugin'; import { unknownToAst } from './unknown'; -export const arrayToAst = ({ - plugin, - schema, - state, -}: IrSchemaToAstOptions & { - schema: SchemaWithType<'array'>; -}): Omit => { +export function arrayToAst( + options: IrSchemaToAstOptions & { + schema: SchemaWithType<'array'>; + }, +): Omit { + const { plugin } = options; + let { schema } = options; + const result: Omit = { pipes: [], }; @@ -26,11 +27,10 @@ export const arrayToAst = ({ if (!schema.items) { const expression = functionName.call( unknownToAst({ - plugin, + ...options, schema: { type: 'unknown', }, - state, }), ); result.pipes.push(expression); @@ -40,11 +40,11 @@ export const arrayToAst = ({ // at least one item is guaranteed const itemExpressions = schema.items!.map((item, index) => { const itemAst = irSchemaToAst({ - plugin, + ...options, schema: item, state: { - ...state, - path: ref([...fromRef(state.path), 'items', index]), + ...options.state, + path: ref([...fromRef(options.state.path), 'items', index]), }, }); if (itemAst.hasLazyExpression) { @@ -69,11 +69,10 @@ export const arrayToAst = ({ const expression = functionName.call( unknownToAst({ - plugin, + ...options, schema: { type: 'unknown', }, - state, }), ); result.pipes.push(expression); @@ -100,4 +99,4 @@ export const arrayToAst = ({ } return result as Omit; -}; +} diff --git a/packages/openapi-ts/src/plugins/valibot/v1/toAst/enum.ts b/packages/openapi-ts/src/plugins/valibot/v1/toAst/enum.ts index 66a7743a7..56a547850 100644 --- a/packages/openapi-ts/src/plugins/valibot/v1/toAst/enum.ts +++ b/packages/openapi-ts/src/plugins/valibot/v1/toAst/enum.ts @@ -65,13 +65,13 @@ function enumResolver(ctx: EnumResolverContext): PipeResult { return ctx.pipes.current; } -export const enumToAst = ({ +export function enumToAst({ plugin, schema, state, }: IrSchemaToAstOptions & { schema: SchemaWithType<'enum'>; -}): Pipe => { +}): Pipe { const v = plugin.external('valibot.v'); const { enumMembers } = itemsNode({ @@ -118,4 +118,4 @@ export const enumToAst = ({ const resolver = plugin.config['~resolvers']?.enum; const node = resolver?.(ctx) ?? enumResolver(ctx); return ctx.pipes.toNode(node, plugin); -}; +} diff --git a/packages/openapi-ts/src/plugins/valibot/v1/toAst/never.ts b/packages/openapi-ts/src/plugins/valibot/v1/toAst/never.ts index 4efa09323..434650413 100644 --- a/packages/openapi-ts/src/plugins/valibot/v1/toAst/never.ts +++ b/packages/openapi-ts/src/plugins/valibot/v1/toAst/never.ts @@ -4,12 +4,12 @@ import { $ } from '../../../../ts-dsl'; import type { IrSchemaToAstOptions } from '../../shared/types'; import { identifiers } from '../constants'; -export const neverToAst = ({ +export function neverToAst({ plugin, }: IrSchemaToAstOptions & { schema: SchemaWithType<'never'>; -}) => { +}) { const v = plugin.external('valibot.v'); const expression = $(v).attr(identifiers.schemas.never).call(); return expression; -}; +} diff --git a/packages/openapi-ts/src/plugins/valibot/v1/toAst/null.ts b/packages/openapi-ts/src/plugins/valibot/v1/toAst/null.ts index 3d26d9cae..b0d373c34 100644 --- a/packages/openapi-ts/src/plugins/valibot/v1/toAst/null.ts +++ b/packages/openapi-ts/src/plugins/valibot/v1/toAst/null.ts @@ -4,12 +4,12 @@ import { $ } from '../../../../ts-dsl'; import type { IrSchemaToAstOptions } from '../../shared/types'; import { identifiers } from '../constants'; -export const nullToAst = ({ +export function nullToAst({ plugin, }: IrSchemaToAstOptions & { schema: SchemaWithType<'null'>; -}) => { +}) { const v = plugin.external('valibot.v'); const expression = $(v).attr(identifiers.schemas.null).call(); return expression; -}; +} diff --git a/packages/openapi-ts/src/plugins/valibot/v1/toAst/number.ts b/packages/openapi-ts/src/plugins/valibot/v1/toAst/number.ts index 5a91c373d..342012ed7 100644 --- a/packages/openapi-ts/src/plugins/valibot/v1/toAst/number.ts +++ b/packages/openapi-ts/src/plugins/valibot/v1/toAst/number.ts @@ -105,12 +105,12 @@ function numberResolver(ctx: NumberResolverContext): Pipes { return ctx.pipes.current; } -export const numberToNode = ({ +export function numberToNode({ plugin, schema, }: IrSchemaToAstOptions & { schema: SchemaWithType<'integer' | 'number'>; -}): Pipe => { +}): Pipe { const ctx: NumberResolverContext = { $, nodes: { @@ -137,4 +137,4 @@ export const numberToNode = ({ const resolver = plugin.config['~resolvers']?.number; const node = resolver?.(ctx) ?? numberResolver(ctx); return ctx.pipes.toNode(node, plugin); -}; +} diff --git a/packages/openapi-ts/src/plugins/valibot/v1/toAst/object.ts b/packages/openapi-ts/src/plugins/valibot/v1/toAst/object.ts index 184dc1dcf..60468eea3 100644 --- a/packages/openapi-ts/src/plugins/valibot/v1/toAst/object.ts +++ b/packages/openapi-ts/src/plugins/valibot/v1/toAst/object.ts @@ -16,7 +16,7 @@ function additionalPropertiesNode(ctx: ObjectResolverContext): Pipe | null | und if (schema.additionalProperties.type === 'never') return null; const additionalAst = irSchemaToAst({ - plugin, + ...ctx, schema: schema.additionalProperties, state: { ...ctx.utils.state, @@ -64,8 +64,8 @@ function shapeNode(ctx: ObjectResolverContext): ReturnType { const property = schema.properties[name]!; const propertyAst = irSchemaToAst({ + ...ctx, optional: !schema.required?.includes(name), - plugin, schema: property, state: { ...ctx.utils.state, @@ -79,14 +79,14 @@ function shapeNode(ctx: ObjectResolverContext): ReturnType { return shape; } -export const objectToAst = ({ - plugin, - schema, - state, -}: IrSchemaToAstOptions & { - schema: SchemaWithType<'object'>; -}): Omit => { +export function objectToAst( + options: IrSchemaToAstOptions & { + schema: SchemaWithType<'object'>; + }, +): Omit { + const { plugin } = options; const ctx: ObjectResolverContext = { + ...options, $, nodes: { additionalProperties: additionalPropertiesNode, @@ -97,18 +97,16 @@ export const objectToAst = ({ ...pipes, current: [], }, - plugin, - schema, symbols: { v: plugin.external('valibot.v'), }, utils: { ast: {}, - state, + state: options.state, }, }; const resolver = plugin.config['~resolvers']?.object; const node = resolver?.(ctx) ?? objectResolver(ctx); ctx.utils.ast.pipes = [ctx.pipes.toNode(node, plugin)]; return ctx.utils.ast as Omit; -}; +} diff --git a/packages/openapi-ts/src/plugins/valibot/v1/toAst/string.ts b/packages/openapi-ts/src/plugins/valibot/v1/toAst/string.ts index 334269d6f..49f16c4bf 100644 --- a/packages/openapi-ts/src/plugins/valibot/v1/toAst/string.ts +++ b/packages/openapi-ts/src/plugins/valibot/v1/toAst/string.ts @@ -98,12 +98,12 @@ function stringResolver(ctx: StringResolverContext): Pipes { return ctx.pipes.current; } -export const stringToNode = ({ +export function stringToNode({ plugin, schema, }: IrSchemaToAstOptions & { schema: SchemaWithType<'string'>; -}): Pipe => { +}): Pipe { const ctx: StringResolverContext = { $, nodes: { @@ -128,4 +128,4 @@ export const stringToNode = ({ const resolver = plugin.config['~resolvers']?.string; const node = resolver?.(ctx) ?? stringResolver(ctx); return ctx.pipes.toNode(node, plugin); -}; +} diff --git a/packages/openapi-ts/src/plugins/valibot/v1/toAst/tuple.ts b/packages/openapi-ts/src/plugins/valibot/v1/toAst/tuple.ts index 849b1398e..27db7382d 100644 --- a/packages/openapi-ts/src/plugins/valibot/v1/toAst/tuple.ts +++ b/packages/openapi-ts/src/plugins/valibot/v1/toAst/tuple.ts @@ -8,13 +8,13 @@ import { identifiers } from '../constants'; import { irSchemaToAst } from '../plugin'; import { unknownToAst } from './unknown'; -export const tupleToAst = ({ - plugin, - schema, - state, -}: IrSchemaToAstOptions & { - schema: SchemaWithType<'tuple'>; -}): Omit => { +export function tupleToAst( + options: IrSchemaToAstOptions & { + schema: SchemaWithType<'tuple'>; + }, +): Omit { + const { plugin, schema } = options; + const result: Partial> = {}; const v = plugin.external('valibot.v'); @@ -34,11 +34,11 @@ export const tupleToAst = ({ if (schema.items) { const tupleElements = schema.items.map((item, index) => { const schemaPipes = irSchemaToAst({ - plugin, + ...options, schema: item, state: { - ...state, - path: ref([...fromRef(state.path), 'items', index]), + ...options.state, + path: ref([...fromRef(options.state.path), 'items', index]), }, }); if (schemaPipes.hasLazyExpression) { @@ -57,12 +57,11 @@ export const tupleToAst = ({ return { pipes: [ unknownToAst({ - plugin, + ...options, schema: { type: 'unknown', }, - state, }), ], }; -}; +} diff --git a/packages/openapi-ts/src/plugins/valibot/v1/toAst/undefined.ts b/packages/openapi-ts/src/plugins/valibot/v1/toAst/undefined.ts index 8f7dfa6f7..80482d6eb 100644 --- a/packages/openapi-ts/src/plugins/valibot/v1/toAst/undefined.ts +++ b/packages/openapi-ts/src/plugins/valibot/v1/toAst/undefined.ts @@ -4,12 +4,12 @@ import { $ } from '../../../../ts-dsl'; import type { IrSchemaToAstOptions } from '../../shared/types'; import { identifiers } from '../constants'; -export const undefinedToAst = ({ +export function undefinedToAst({ plugin, }: IrSchemaToAstOptions & { schema: SchemaWithType<'undefined'>; -}) => { +}) { const v = plugin.external('valibot.v'); const expression = $(v).attr(identifiers.schemas.undefined).call(); return expression; -}; +} diff --git a/packages/openapi-ts/src/plugins/valibot/v1/toAst/unknown.ts b/packages/openapi-ts/src/plugins/valibot/v1/toAst/unknown.ts index 2bf74da81..188042dae 100644 --- a/packages/openapi-ts/src/plugins/valibot/v1/toAst/unknown.ts +++ b/packages/openapi-ts/src/plugins/valibot/v1/toAst/unknown.ts @@ -4,12 +4,12 @@ import { $ } from '../../../../ts-dsl'; import type { IrSchemaToAstOptions } from '../../shared/types'; import { identifiers } from '../constants'; -export const unknownToAst = ({ +export function unknownToAst({ plugin, }: IrSchemaToAstOptions & { schema: SchemaWithType<'unknown'>; -}) => { +}) { const v = plugin.external('valibot.v'); const expression = $(v).attr(identifiers.schemas.unknown).call(); return expression; -}; +} diff --git a/packages/openapi-ts/src/plugins/valibot/v1/toAst/void.ts b/packages/openapi-ts/src/plugins/valibot/v1/toAst/void.ts index 21bafe48a..dbdf6bd1c 100644 --- a/packages/openapi-ts/src/plugins/valibot/v1/toAst/void.ts +++ b/packages/openapi-ts/src/plugins/valibot/v1/toAst/void.ts @@ -4,12 +4,12 @@ import { $ } from '../../../../ts-dsl'; import type { IrSchemaToAstOptions } from '../../shared/types'; import { identifiers } from '../constants'; -export const voidToAst = ({ +export function voidToAst({ plugin, }: IrSchemaToAstOptions & { schema: SchemaWithType<'void'>; -}) => { +}) { const v = plugin.external('valibot.v'); const expression = $(v).attr(identifiers.schemas.void).call(); return expression; -}; +} diff --git a/packages/openapi-ts/src/plugins/zod/mini/plugin.ts b/packages/openapi-ts/src/plugins/zod/mini/plugin.ts index d949f7f4a..fa1a823c2 100644 --- a/packages/openapi-ts/src/plugins/zod/mini/plugin.ts +++ b/packages/openapi-ts/src/plugins/zod/mini/plugin.ts @@ -1,23 +1,24 @@ import type { SymbolMeta } from '@hey-api/codegen-core'; -import { fromRef, ref, refs } from '@hey-api/codegen-core'; +import { fromRef, ref } from '@hey-api/codegen-core'; import type { IR, SchemaWithType } from '@hey-api/shared'; -import { applyNaming, deduplicateSchema, pathToJsonPointer, refToName } from '@hey-api/shared'; +import { deduplicateSchema, pathToJsonPointer } from '@hey-api/shared'; import { maybeBigInt } from '../../../plugins/shared/utils/coerce'; import { $ } from '../../../ts-dsl'; import { identifiers } from '../constants'; -import { exportAst } from '../shared/export'; import { getZodModule } from '../shared/module'; import { irOperationToAst } from '../shared/operation'; -import type { Ast, IrSchemaToAstOptions, PluginState } from '../shared/types'; +import type { Ast, IrSchemaToAstOptions } from '../shared/types'; import { irWebhookToAst } from '../shared/webhook'; import type { ZodPlugin } from '../types'; +import { createProcessor } from './processor'; import { irSchemaWithTypeToAst } from './toAst'; export function irSchemaToAst({ optional, plugin, schema, + schemaExtractor, state, }: IrSchemaToAstOptions & { /** @@ -28,6 +29,20 @@ export function irSchemaToAst({ optional?: boolean; schema: IR.SchemaObject; }): Ast { + if (schemaExtractor && !schema.$ref) { + const extracted = schemaExtractor({ + meta: { + resource: 'definition', + resourceId: pathToJsonPointer(fromRef(state.path)), + }, + naming: plugin.config.definitions, + path: fromRef(state.path), + plugin, + schema, + }); + if (extracted !== schema) schema = extracted; + } + let ast: Partial = {}; const z = plugin.external('zod.z'); @@ -155,48 +170,6 @@ export function irSchemaToAst({ return ast as Ast; } -function handleComponent({ - plugin, - schema, - state, -}: IrSchemaToAstOptions & { - schema: IR.SchemaObject; -}): void { - const $ref = pathToJsonPointer(fromRef(state.path)); - const ast = irSchemaToAst({ plugin, schema, state }); - const baseName = refToName($ref); - const symbol = plugin.symbol(applyNaming(baseName, plugin.config.definitions), { - meta: { - category: 'schema', - path: fromRef(state.path), - resource: 'definition', - resourceId: $ref, - tags: fromRef(state.tags), - tool: 'zod', - }, - }); - const typeInferSymbol = plugin.config.definitions.types.infer.enabled - ? plugin.symbol(applyNaming(baseName, plugin.config.definitions.types.infer), { - meta: { - category: 'type', - path: fromRef(state.path), - resource: 'definition', - resourceId: $ref, - tags: fromRef(state.tags), - tool: 'zod', - variant: 'infer', - }, - }) - : undefined; - exportAst({ - ast, - plugin, - schema, - symbol, - typeInferSymbol, - }); -} - export const handlerMini: ZodPlugin['Handler'] = ({ plugin }) => { plugin.symbol('z', { external: getZodModule({ plugin }), @@ -207,62 +180,65 @@ export const handlerMini: ZodPlugin['Handler'] = ({ plugin }) => { }, }); + const processor = createProcessor(plugin); + plugin.forEach('operation', 'parameter', 'requestBody', 'schema', 'webhook', (event) => { - const state = refs({ - hasLazyExpression: false, - path: event._path, - tags: event.tags, - }); switch (event.type) { case 'operation': irOperationToAst({ - getAst: (schema, path) => { - const state = refs({ - hasLazyExpression: false, - path, - tags: event.tags, - }); - return irSchemaToAst({ plugin, schema, state }); - }, operation: event.operation, + path: event._path, plugin, - state, + processor, + 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 'webhook': irWebhookToAst({ - getAst: (schema, path) => { - const state = refs({ - hasLazyExpression: false, - path, - tags: event.tags, - }); - return irSchemaToAst({ plugin, schema, state }); - }, operation: event.operation, + path: event._path, plugin, - state, + processor, + tags: event.tags, }); break; } diff --git a/packages/openapi-ts/src/plugins/zod/mini/processor.ts b/packages/openapi-ts/src/plugins/zod/mini/processor.ts new file mode 100644 index 000000000..5b4364774 --- /dev/null +++ b/packages/openapi-ts/src/plugins/zod/mini/processor.ts @@ -0,0 +1,58 @@ +import { refs } from '@hey-api/codegen-core'; +import type { IR } from '@hey-api/shared'; +import { createSchemaProcessor, pathToJsonPointer } from '@hey-api/shared'; + +import { exportAst } from '../shared/export'; +import type { ProcessorContext, ProcessorResult } from '../shared/processor'; +import type { PluginState } from '../shared/types'; +import type { ZodPlugin } from '../types'; +import { irSchemaToAst } from './plugin'; + +export function createProcessor(plugin: ZodPlugin['Instance']): ProcessorResult { + const processor = createSchemaProcessor(); + + const hooks = [plugin.config['~hooks']?.schemas, plugin.context.config.parser.hooks.schemas]; + + function extractor(ctx: ProcessorContext): IR.SchemaObject { + if (processor.hasEmitted(ctx.path)) { + return ctx.schema; + } + + for (const hook of hooks) { + const result = hook?.shouldExtract?.(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): void { + if (!processor.markEmitted(ctx.path)) return; + + processor.withContext({ anchor: ctx.namingAnchor, tags: ctx.tags }, () => { + const state = refs({ + hasLazyExpression: false, + path: ctx.path, + tags: ctx.tags, + }); + + const ast = irSchemaToAst({ + plugin, + schema: ctx.schema, + schemaExtractor: extractor, + state, + }); + + exportAst({ ...ctx, ast, plugin, state }); + }); + } + + return { process }; +} diff --git a/packages/openapi-ts/src/plugins/zod/shared/export.ts b/packages/openapi-ts/src/plugins/zod/shared/export.ts index 3fabac42b..6ea280d11 100644 --- a/packages/openapi-ts/src/plugins/zod/shared/export.ts +++ b/packages/openapi-ts/src/plugins/zod/shared/export.ts @@ -1,27 +1,50 @@ -import type { Symbol } from '@hey-api/codegen-core'; -import type { IR } from '@hey-api/shared'; +import { applyNaming, pathToName } from '@hey-api/shared'; import { createSchemaComment } from '../../../plugins/shared/utils/schema'; import { $ } from '../../../ts-dsl'; import { identifiers } from '../constants'; -import type { ZodPlugin } from '../types'; -import type { Ast } from './types'; +import type { ProcessorContext } from './processor'; +import type { Ast, IrSchemaToAstOptions } from './types'; export function exportAst({ ast, + meta, + naming, + namingAnchor, + path, plugin, schema, - symbol, - typeInferSymbol, -}: { - ast: Ast; - plugin: ZodPlugin['Instance']; - schema: IR.SchemaObject; - symbol: Symbol; - typeInferSymbol: Symbol | undefined; -}): void { + tags, +}: Pick & + ProcessorContext & { + ast: Ast; + }): void { const z = plugin.external('zod.z'); + const name = pathToName(path, { anchor: namingAnchor }); + const symbol = plugin.symbol(applyNaming(name, naming), { + meta: { + category: 'schema', + path, + tags, + tool: 'zod', + ...meta, + }, + }); + + const typeInferSymbol = naming.types.infer.enabled + ? plugin.symbol(applyNaming(name, naming.types.infer), { + meta: { + category: 'type', + path, + tags, + tool: 'zod', + variant: 'infer', + ...meta, + }, + }) + : undefined; + const statement = $.const(symbol) .export() .$if(plugin.config.comments && createSchemaComment(schema), (c, v) => c.doc(v)) diff --git a/packages/openapi-ts/src/plugins/zod/shared/operation-schema.ts b/packages/openapi-ts/src/plugins/zod/shared/operation-schema.ts new file mode 100644 index 000000000..f66658b29 --- /dev/null +++ b/packages/openapi-ts/src/plugins/zod/shared/operation-schema.ts @@ -0,0 +1,58 @@ +import type { IR } from '@hey-api/shared'; + +export interface OperationSchemaResult { + required: ReadonlyArray; + schema: IR.SchemaObject; +} + +export function buildOperationSchema(operation: IR.OperationObject): OperationSchemaResult { + const requiredProperties = new Set(); + + const schema: IR.SchemaObject = { + properties: { + body: { type: 'never' }, + path: { type: 'never' }, + query: { type: 'never' }, + }, + type: 'object', + }; + + if (operation.parameters) { + // TODO: add support for cookies + + for (const location of ['header', 'path', 'query'] satisfies ReadonlyArray< + keyof typeof operation.parameters + >) { + const params = operation.parameters[location]; + if (!params) continue; + + const properties: Record = {}; + const required: Array = []; + const propKey = location === 'header' ? 'headers' : location; + + for (const key in params) { + const parameter = params[key]!; + properties[parameter.name] = parameter.schema; + if (parameter.required) { + required.push(parameter.name); + requiredProperties.add(propKey); + } + } + + if (Object.keys(properties).length) { + schema.properties![propKey] = { properties, required, type: 'object' }; + } + } + } + + if (operation.body) { + schema.properties!.body = operation.body.schema; + if (operation.body.required) { + requiredProperties.add('body'); + } + } + + schema.required = [...requiredProperties]; + + return { required: schema.required, schema }; +} diff --git a/packages/openapi-ts/src/plugins/zod/shared/operation.ts b/packages/openapi-ts/src/plugins/zod/shared/operation.ts index b76bd2810..4672ece6f 100644 --- a/packages/openapi-ts/src/plugins/zod/shared/operation.ts +++ b/packages/openapi-ts/src/plugins/zod/shared/operation.ts @@ -1,149 +1,36 @@ -import { fromRef } from '@hey-api/codegen-core'; import type { IR } from '@hey-api/shared'; -import { applyNaming, operationResponsesMap } from '@hey-api/shared'; +import { operationResponsesMap } from '@hey-api/shared'; -import { exportAst } from './export'; -import type { Ast, IrSchemaToAstOptions } from './types'; +import { buildOperationSchema } from './operation-schema'; +import type { ProcessorContext, ProcessorResult } from './processor'; +import type { IrSchemaToAstOptions } from './types'; -export const irOperationToAst = ({ - getAst, +export function irOperationToAst({ operation, + path, plugin, - state, -}: IrSchemaToAstOptions & { - getAst: (schema: IR.SchemaObject, path: ReadonlyArray) => Ast; - operation: IR.OperationObject; -}): void => { + processor, + tags, +}: Pick & + Pick & { + operation: IR.OperationObject; + processor: ProcessorResult; + }): void { if (plugin.config.requests.enabled) { - const requiredProperties = new Set(); + const { schema } = buildOperationSchema(operation); - const schemaData: IR.SchemaObject = { - properties: { - body: { - type: 'never', - }, - path: { - type: 'never', - }, - query: { - type: 'never', - }, - }, - type: 'object', - }; - - if (operation.parameters) { - // TODO: add support for cookies - - if (operation.parameters.header) { - const properties: Record = {}; - const required: Array = []; - - for (const key in operation.parameters.header) { - const parameter = operation.parameters.header[key]!; - properties[parameter.name] = parameter.schema; - if (parameter.required) { - required.push(parameter.name); - requiredProperties.add('headers'); - } - } - - if (Object.keys(properties).length) { - schemaData.properties!.headers = { - properties, - required, - type: 'object', - }; - } - } - - if (operation.parameters.path) { - const properties: Record = {}; - const required: Array = []; - - for (const key in operation.parameters.path) { - const parameter = operation.parameters.path[key]!; - properties[parameter.name] = parameter.schema; - if (parameter.required) { - required.push(parameter.name); - requiredProperties.add('path'); - } - } - - if (Object.keys(properties).length) { - schemaData.properties!.path = { - properties, - required, - type: 'object', - }; - } - } - - if (operation.parameters.query) { - const properties: Record = {}; - const required: Array = []; - - for (const key in operation.parameters.query) { - const parameter = operation.parameters.query[key]!; - properties[parameter.name] = parameter.schema; - if (parameter.required) { - required.push(parameter.name); - requiredProperties.add('query'); - } - } - - if (Object.keys(properties).length) { - schemaData.properties!.query = { - properties, - required, - type: 'object', - }; - } - } - } - - if (operation.body) { - schemaData.properties!.body = operation.body.schema; - - if (operation.body.required) { - requiredProperties.add('body'); - } - } - - schemaData.required = [...requiredProperties]; - - const ast = getAst(schemaData, fromRef(state.path)); - const symbol = plugin.symbol(applyNaming(operation.id, plugin.config.requests), { + processor.process({ meta: { - category: 'schema', - path: fromRef(state.path), resource: 'operation', resourceId: operation.id, role: 'data', - tags: fromRef(state.tags), - tool: 'zod', }, - }); - const typeInferSymbol = plugin.config.requests.types.infer.enabled - ? plugin.symbol(applyNaming(operation.id, plugin.config.requests.types.infer), { - meta: { - category: 'type', - path: fromRef(state.path), - resource: 'operation', - resourceId: operation.id, - role: 'data', - tags: fromRef(state.tags), - tool: 'zod', - variant: 'infer', - }, - }) - : undefined; - exportAst({ - ast, + naming: plugin.config.requests, + namingAnchor: operation.id, + path, plugin, - schema: schemaData, - symbol, - typeInferSymbol, + schema, + tags, }); } @@ -152,41 +39,20 @@ export const irOperationToAst = ({ const { response } = operationResponsesMap(operation); if (response) { - const path = [...fromRef(state.path), 'responses']; - const ast = getAst(response, path); - const symbol = plugin.symbol(applyNaming(operation.id, plugin.config.responses), { + processor.process({ meta: { - category: 'schema', - path, resource: 'operation', resourceId: operation.id, role: 'responses', - tags: fromRef(state.tags), - tool: 'zod', }, - }); - const typeInferSymbol = plugin.config.responses.types.infer.enabled - ? plugin.symbol(applyNaming(operation.id, plugin.config.responses.types.infer), { - meta: { - category: 'type', - path, - resource: 'operation', - resourceId: operation.id, - role: 'responses', - tags: fromRef(state.tags), - tool: 'zod', - variant: 'infer', - }, - }) - : undefined; - exportAst({ - ast, + naming: plugin.config.responses, + namingAnchor: operation.id, + path: [...path, 'responses'], plugin, schema: response, - symbol, - typeInferSymbol, + tags, }); } } } -}; +} diff --git a/packages/openapi-ts/src/plugins/zod/shared/processor.ts b/packages/openapi-ts/src/plugins/zod/shared/processor.ts new file mode 100644 index 000000000..a7de612ee --- /dev/null +++ b/packages/openapi-ts/src/plugins/zod/shared/processor.ts @@ -0,0 +1,16 @@ +import type { + IR, + NamingConfig, + SchemaProcessorContext, + SchemaProcessorResult, +} from '@hey-api/shared'; + +import type { IrSchemaToAstOptions, TypeOptions } from './types'; + +export type ProcessorContext = Pick & + SchemaProcessorContext & { + naming: NamingConfig & TypeOptions; + schema: IR.SchemaObject; + }; + +export type ProcessorResult = SchemaProcessorResult; diff --git a/packages/openapi-ts/src/plugins/zod/shared/types.ts b/packages/openapi-ts/src/plugins/zod/shared/types.ts index 62b0f8d57..10081009f 100644 --- a/packages/openapi-ts/src/plugins/zod/shared/types.ts +++ b/packages/openapi-ts/src/plugins/zod/shared/types.ts @@ -1,9 +1,10 @@ import type { Refs, SymbolMeta } from '@hey-api/codegen-core'; -import type { IR } from '@hey-api/shared'; +import type { FeatureToggle, IR, NamingOptions, SchemaExtractor } from '@hey-api/shared'; import type ts from 'typescript'; import type { $ } from '../../../ts-dsl'; import type { ZodPlugin } from '../types'; +import type { ProcessorContext } from './processor'; export type Ast = { expression: ReturnType; @@ -12,7 +13,11 @@ export type Ast = { }; export type IrSchemaToAstOptions = { + /** The plugin instance. */ plugin: ZodPlugin['Instance']; + /** Optional schema extractor function. */ + schemaExtractor?: SchemaExtractor; + /** The plugin state references. */ state: Refs; }; @@ -21,6 +26,14 @@ export type PluginState = Pick, 'path'> & hasLazyExpression: boolean; }; +export type TypeOptions = { + /** Configuration for TypeScript type generation from Zod schemas. */ + types: { + /** Configuration for `infer` types. */ + infer: NamingOptions & FeatureToggle; + }; +}; + export type ValidatorArgs = { operation: IR.OperationObject; plugin: ZodPlugin['Instance']; diff --git a/packages/openapi-ts/src/plugins/zod/shared/webhook.ts b/packages/openapi-ts/src/plugins/zod/shared/webhook.ts index e6dbdef9d..56e1537cf 100644 --- a/packages/openapi-ts/src/plugins/zod/shared/webhook.ts +++ b/packages/openapi-ts/src/plugins/zod/shared/webhook.ts @@ -1,149 +1,35 @@ -import { fromRef } from '@hey-api/codegen-core'; import type { IR } from '@hey-api/shared'; -import { applyNaming } from '@hey-api/shared'; -import { exportAst } from './export'; -import type { Ast, IrSchemaToAstOptions } from './types'; +import { buildOperationSchema } from './operation-schema'; +import type { ProcessorContext, ProcessorResult } from './processor'; +import type { IrSchemaToAstOptions } from './types'; -export const irWebhookToAst = ({ - getAst, +export function irWebhookToAst({ operation, + path, plugin, - state, -}: IrSchemaToAstOptions & { - getAst: (schema: IR.SchemaObject, path: ReadonlyArray) => Ast; - operation: IR.OperationObject; -}) => { + processor, + tags, +}: Pick & + Pick & { + operation: IR.OperationObject; + processor: ProcessorResult; + }): void { if (plugin.config.webhooks.enabled) { - const requiredProperties = new Set(); + const { schema } = buildOperationSchema(operation); - const schemaData: IR.SchemaObject = { - properties: { - body: { - type: 'never', - }, - path: { - type: 'never', - }, - query: { - type: 'never', - }, - }, - type: 'object', - }; - - if (operation.parameters) { - // TODO: add support for cookies - - if (operation.parameters.header) { - const properties: Record = {}; - const required: Array = []; - - for (const key in operation.parameters.header) { - const parameter = operation.parameters.header[key]!; - properties[parameter.name] = parameter.schema; - if (parameter.required) { - required.push(parameter.name); - requiredProperties.add('headers'); - } - } - - if (Object.keys(properties).length) { - schemaData.properties!.headers = { - properties, - required, - type: 'object', - }; - } - } - - if (operation.parameters.path) { - const properties: Record = {}; - const required: Array = []; - - for (const key in operation.parameters.path) { - const parameter = operation.parameters.path[key]!; - properties[parameter.name] = parameter.schema; - if (parameter.required) { - required.push(parameter.name); - requiredProperties.add('path'); - } - } - - if (Object.keys(properties).length) { - schemaData.properties!.path = { - properties, - required, - type: 'object', - }; - } - } - - if (operation.parameters.query) { - const properties: Record = {}; - const required: Array = []; - - for (const key in operation.parameters.query) { - const parameter = operation.parameters.query[key]!; - properties[parameter.name] = parameter.schema; - if (parameter.required) { - required.push(parameter.name); - requiredProperties.add('query'); - } - } - - if (Object.keys(properties).length) { - schemaData.properties!.query = { - properties, - required, - type: 'object', - }; - } - } - } - - if (operation.body) { - schemaData.properties!.body = operation.body.schema; - - if (operation.body.required) { - requiredProperties.add('body'); - } - } - - schemaData.required = [...requiredProperties]; - - const ast = getAst(schemaData, fromRef(state.path)); - const symbol = plugin.symbol(applyNaming(operation.id, plugin.config.webhooks), { + processor.process({ meta: { - category: 'schema', - path: fromRef(state.path), resource: 'webhook', resourceId: operation.id, role: 'data', - tags: fromRef(state.tags), - tool: 'zod', }, - }); - const typeInferSymbol = plugin.config.webhooks.types.infer.enabled - ? plugin.symbol(applyNaming(operation.id, plugin.config.webhooks.types.infer), { - meta: { - category: 'type', - path: fromRef(state.path), - resource: 'webhook', - resourceId: operation.id, - role: 'data', - tags: fromRef(state.tags), - tool: 'zod', - variant: 'infer', - }, - }) - : undefined; - exportAst({ - ast, + naming: plugin.config.webhooks, + namingAnchor: operation.id, + path, plugin, - schema: schemaData, - symbol, - typeInferSymbol, + schema, + tags, }); } -}; +} diff --git a/packages/openapi-ts/src/plugins/zod/types.ts b/packages/openapi-ts/src/plugins/zod/types.ts index a679942f0..58f37d82c 100644 --- a/packages/openapi-ts/src/plugins/zod/types.ts +++ b/packages/openapi-ts/src/plugins/zod/types.ts @@ -9,6 +9,7 @@ import type { import type { IApi } from './api'; import type { Resolvers } from './resolvers'; +import type { TypeOptions } from './shared/types'; export type UserConfig = Plugin.Name<'zod'> & Plugin.Hooks & @@ -422,34 +423,13 @@ export type Config = Plugin.Name<'zod'> & offset: boolean; }; /** Configuration for reusable schema definitions. */ - definitions: NamingOptions & - FeatureToggle & { - /** Configuration for TypeScript type generation from Zod schemas. */ - types: { - /** Configuration for `infer` types. */ - infer: NamingOptions & FeatureToggle; - }; - }; + definitions: NamingOptions & FeatureToggle & TypeOptions; /** Enable Zod metadata support? */ metadata: boolean; /** Configuration for request-specific Zod schemas. */ - requests: NamingOptions & - FeatureToggle & { - /** Configuration for TypeScript type generation from Zod schemas. */ - types: { - /** Configuration for `infer` types. */ - infer: NamingOptions & FeatureToggle; - }; - }; + requests: NamingOptions & FeatureToggle & TypeOptions; /** Configuration for response-specific Zod schemas. */ - responses: NamingOptions & - FeatureToggle & { - /** Configuration for TypeScript type generation from Zod schemas. */ - types: { - /** Configuration for `infer` types. */ - infer: NamingOptions & FeatureToggle; - }; - }; + responses: NamingOptions & FeatureToggle & TypeOptions; /** Configuration for TypeScript type generation from Zod schemas. */ types: { /** Configuration for `infer` types. */ @@ -459,14 +439,7 @@ export type Config = Plugin.Name<'zod'> & }; }; /** Configuration for webhook-specific Zod schemas. */ - webhooks: NamingOptions & - FeatureToggle & { - /** Configuration for TypeScript type generation from Zod schemas. */ - types: { - /** Configuration for `infer` types. */ - infer: NamingOptions & FeatureToggle; - }; - }; + webhooks: NamingOptions & FeatureToggle & TypeOptions; }; export type ZodPlugin = DefinePlugin; diff --git a/packages/openapi-ts/src/plugins/zod/v3/plugin.ts b/packages/openapi-ts/src/plugins/zod/v3/plugin.ts index 5d29f4e91..500807bd1 100644 --- a/packages/openapi-ts/src/plugins/zod/v3/plugin.ts +++ b/packages/openapi-ts/src/plugins/zod/v3/plugin.ts @@ -1,23 +1,24 @@ import type { SymbolMeta } from '@hey-api/codegen-core'; -import { fromRef, ref, refs } from '@hey-api/codegen-core'; +import { fromRef, ref } from '@hey-api/codegen-core'; import type { IR, SchemaWithType } from '@hey-api/shared'; -import { applyNaming, deduplicateSchema, pathToJsonPointer, refToName } from '@hey-api/shared'; +import { deduplicateSchema, pathToJsonPointer } from '@hey-api/shared'; import { maybeBigInt } from '../../../plugins/shared/utils/coerce'; import { $ } from '../../../ts-dsl'; import { identifiers } from '../constants'; -import { exportAst } from '../shared/export'; import { getZodModule } from '../shared/module'; import { irOperationToAst } from '../shared/operation'; -import type { Ast, IrSchemaToAstOptions, PluginState } from '../shared/types'; +import type { Ast, IrSchemaToAstOptions } from '../shared/types'; import { irWebhookToAst } from '../shared/webhook'; import type { ZodPlugin } from '../types'; +import { createProcessor } from './processor'; import { irSchemaWithTypeToAst } from './toAst'; export function irSchemaToAst({ optional, plugin, schema, + schemaExtractor, state, }: IrSchemaToAstOptions & { /** @@ -28,6 +29,20 @@ export function irSchemaToAst({ optional?: boolean; schema: IR.SchemaObject; }): Ast { + if (schemaExtractor && !schema.$ref) { + const extracted = schemaExtractor({ + meta: { + resource: 'definition', + resourceId: pathToJsonPointer(fromRef(state.path)), + }, + naming: plugin.config.definitions, + path: fromRef(state.path), + plugin, + schema, + }); + if (extracted !== schema) schema = extracted; + } + let ast: Partial = {}; const z = plugin.external('zod.z'); @@ -153,48 +168,6 @@ export function irSchemaToAst({ return ast as Ast; } -function handleComponent({ - plugin, - schema, - state, -}: IrSchemaToAstOptions & { - schema: IR.SchemaObject; -}): void { - const $ref = pathToJsonPointer(fromRef(state.path)); - const ast = irSchemaToAst({ plugin, schema, state }); - const baseName = refToName($ref); - const symbol = plugin.symbol(applyNaming(baseName, plugin.config.definitions), { - meta: { - category: 'schema', - path: fromRef(state.path), - resource: 'definition', - resourceId: $ref, - tags: fromRef(state.tags), - tool: 'zod', - }, - }); - const typeInferSymbol = plugin.config.definitions.types.infer.enabled - ? plugin.symbol(applyNaming(baseName, plugin.config.definitions.types.infer), { - meta: { - category: 'type', - path: fromRef(state.path), - resource: 'definition', - resourceId: $ref, - tags: fromRef(state.tags), - tool: 'zod', - variant: 'infer', - }, - }) - : undefined; - exportAst({ - ast, - plugin, - schema, - symbol, - typeInferSymbol, - }); -} - export const handlerV3: ZodPlugin['Handler'] = ({ plugin }) => { plugin.symbol('z', { external: getZodModule({ plugin }), @@ -204,62 +177,65 @@ export const handlerV3: ZodPlugin['Handler'] = ({ plugin }) => { }, }); + const processor = createProcessor(plugin); + plugin.forEach('operation', 'parameter', 'requestBody', 'schema', 'webhook', (event) => { - const state = refs({ - hasLazyExpression: false, - path: event._path, - tags: event.tags, - }); switch (event.type) { case 'operation': irOperationToAst({ - getAst: (schema, path) => { - const state = refs({ - hasLazyExpression: false, - path, - tags: event.tags, - }); - return irSchemaToAst({ plugin, schema, state }); - }, operation: event.operation, + path: event._path, plugin, - state, + processor, + 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 'webhook': irWebhookToAst({ - getAst: (schema, path) => { - const state = refs({ - hasLazyExpression: false, - path, - tags: event.tags, - }); - return irSchemaToAst({ plugin, schema, state }); - }, operation: event.operation, + path: event._path, plugin, - state, + processor, + tags: event.tags, }); break; } diff --git a/packages/openapi-ts/src/plugins/zod/v3/processor.ts b/packages/openapi-ts/src/plugins/zod/v3/processor.ts new file mode 100644 index 000000000..5b4364774 --- /dev/null +++ b/packages/openapi-ts/src/plugins/zod/v3/processor.ts @@ -0,0 +1,58 @@ +import { refs } from '@hey-api/codegen-core'; +import type { IR } from '@hey-api/shared'; +import { createSchemaProcessor, pathToJsonPointer } from '@hey-api/shared'; + +import { exportAst } from '../shared/export'; +import type { ProcessorContext, ProcessorResult } from '../shared/processor'; +import type { PluginState } from '../shared/types'; +import type { ZodPlugin } from '../types'; +import { irSchemaToAst } from './plugin'; + +export function createProcessor(plugin: ZodPlugin['Instance']): ProcessorResult { + const processor = createSchemaProcessor(); + + const hooks = [plugin.config['~hooks']?.schemas, plugin.context.config.parser.hooks.schemas]; + + function extractor(ctx: ProcessorContext): IR.SchemaObject { + if (processor.hasEmitted(ctx.path)) { + return ctx.schema; + } + + for (const hook of hooks) { + const result = hook?.shouldExtract?.(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): void { + if (!processor.markEmitted(ctx.path)) return; + + processor.withContext({ anchor: ctx.namingAnchor, tags: ctx.tags }, () => { + const state = refs({ + hasLazyExpression: false, + path: ctx.path, + tags: ctx.tags, + }); + + const ast = irSchemaToAst({ + plugin, + schema: ctx.schema, + schemaExtractor: extractor, + state, + }); + + exportAst({ ...ctx, ast, plugin, state }); + }); + } + + return { process }; +} diff --git a/packages/openapi-ts/src/plugins/zod/v4/plugin.ts b/packages/openapi-ts/src/plugins/zod/v4/plugin.ts index db3ff16d1..adc6bda38 100644 --- a/packages/openapi-ts/src/plugins/zod/v4/plugin.ts +++ b/packages/openapi-ts/src/plugins/zod/v4/plugin.ts @@ -1,23 +1,24 @@ import type { SymbolMeta } from '@hey-api/codegen-core'; -import { fromRef, ref, refs } from '@hey-api/codegen-core'; +import { fromRef, ref } from '@hey-api/codegen-core'; import type { IR, SchemaWithType } from '@hey-api/shared'; -import { applyNaming, deduplicateSchema, pathToJsonPointer, refToName } from '@hey-api/shared'; +import { deduplicateSchema, pathToJsonPointer } from '@hey-api/shared'; import { maybeBigInt } from '../../../plugins/shared/utils/coerce'; import { $ } from '../../../ts-dsl'; import { identifiers } from '../constants'; -import { exportAst } from '../shared/export'; import { getZodModule } from '../shared/module'; import { irOperationToAst } from '../shared/operation'; -import type { Ast, IrSchemaToAstOptions, PluginState } from '../shared/types'; +import type { Ast, IrSchemaToAstOptions } from '../shared/types'; import { irWebhookToAst } from '../shared/webhook'; import type { ZodPlugin } from '../types'; +import { createProcessor } from './processor'; import { irSchemaWithTypeToAst } from './toAst'; export function irSchemaToAst({ optional, plugin, schema, + schemaExtractor, state, }: IrSchemaToAstOptions & { /** @@ -28,6 +29,20 @@ export function irSchemaToAst({ optional?: boolean; schema: IR.SchemaObject; }): Ast { + if (schemaExtractor && !schema.$ref) { + const extracted = schemaExtractor({ + meta: { + resource: 'definition', + resourceId: pathToJsonPointer(fromRef(state.path)), + }, + naming: plugin.config.definitions, + path: fromRef(state.path), + plugin, + schema, + }); + if (extracted !== schema) schema = extracted; + } + let ast: Partial = {}; const z = plugin.external('zod.z'); @@ -157,48 +172,6 @@ export function irSchemaToAst({ return ast as Ast; } -function handleComponent({ - plugin, - schema, - state, -}: IrSchemaToAstOptions & { - schema: IR.SchemaObject; -}): void { - const $ref = pathToJsonPointer(fromRef(state.path)); - const ast = irSchemaToAst({ plugin, schema, state }); - const baseName = refToName($ref); - const symbol = plugin.symbol(applyNaming(baseName, plugin.config.definitions), { - meta: { - category: 'schema', - path: fromRef(state.path), - resource: 'definition', - resourceId: $ref, - tags: fromRef(state.tags), - tool: 'zod', - }, - }); - const typeInferSymbol = plugin.config.definitions.types.infer.enabled - ? plugin.symbol(applyNaming(baseName, plugin.config.definitions.types.infer), { - meta: { - category: 'type', - path: fromRef(state.path), - resource: 'definition', - resourceId: $ref, - tags: fromRef(state.tags), - tool: 'zod', - variant: 'infer', - }, - }) - : undefined; - exportAst({ - ast, - plugin, - schema, - symbol, - typeInferSymbol, - }); -} - export const handlerV4: ZodPlugin['Handler'] = ({ plugin }) => { plugin.symbol('z', { external: getZodModule({ plugin }), @@ -209,62 +182,65 @@ export const handlerV4: ZodPlugin['Handler'] = ({ plugin }) => { }, }); + const processor = createProcessor(plugin); + plugin.forEach('operation', 'parameter', 'requestBody', 'schema', 'webhook', (event) => { - const state = refs({ - hasLazyExpression: false, - path: event._path, - tags: event.tags, - }); switch (event.type) { case 'operation': irOperationToAst({ - getAst: (schema, path) => { - const state = refs({ - hasLazyExpression: false, - path, - tags: event.tags, - }); - return irSchemaToAst({ plugin, schema, state }); - }, operation: event.operation, + path: event._path, plugin, - state, + processor, + 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 'webhook': irWebhookToAst({ - getAst: (schema, path) => { - const state = refs({ - hasLazyExpression: false, - path, - tags: event.tags, - }); - return irSchemaToAst({ plugin, schema, state }); - }, operation: event.operation, + path: event._path, plugin, - state, + processor, + tags: event.tags, }); break; } diff --git a/packages/openapi-ts/src/plugins/zod/v4/processor.ts b/packages/openapi-ts/src/plugins/zod/v4/processor.ts new file mode 100644 index 000000000..5b4364774 --- /dev/null +++ b/packages/openapi-ts/src/plugins/zod/v4/processor.ts @@ -0,0 +1,58 @@ +import { refs } from '@hey-api/codegen-core'; +import type { IR } from '@hey-api/shared'; +import { createSchemaProcessor, pathToJsonPointer } from '@hey-api/shared'; + +import { exportAst } from '../shared/export'; +import type { ProcessorContext, ProcessorResult } from '../shared/processor'; +import type { PluginState } from '../shared/types'; +import type { ZodPlugin } from '../types'; +import { irSchemaToAst } from './plugin'; + +export function createProcessor(plugin: ZodPlugin['Instance']): ProcessorResult { + const processor = createSchemaProcessor(); + + const hooks = [plugin.config['~hooks']?.schemas, plugin.context.config.parser.hooks.schemas]; + + function extractor(ctx: ProcessorContext): IR.SchemaObject { + if (processor.hasEmitted(ctx.path)) { + return ctx.schema; + } + + for (const hook of hooks) { + const result = hook?.shouldExtract?.(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): void { + if (!processor.markEmitted(ctx.path)) return; + + processor.withContext({ anchor: ctx.namingAnchor, tags: ctx.tags }, () => { + const state = refs({ + hasLazyExpression: false, + path: ctx.path, + tags: ctx.tags, + }); + + const ast = irSchemaToAst({ + plugin, + schema: ctx.schema, + schemaExtractor: extractor, + state, + }); + + exportAst({ ...ctx, ast, plugin, state }); + }); + } + + return { process }; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ba4995cb5..1d8ece6cb 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -59,8 +59,7 @@ export { } from './ir/parameter'; export { deduplicateSchema } from './ir/schema'; export type { IR } from './ir/types'; -export type { SchemaExtractor, SchemaExtractorContext } from './ir/utils'; -export { addItemsToSchema, createSchemaExtractor, inlineSchema } from './ir/utils'; +export { addItemsToSchema } from './ir/utils'; export { parseOpenApiSpec } from './openApi'; export type { OpenApiV2_0_X, OpenApiV2_0_XTypes } from './openApi/2.0.x'; export { parseV2_0_X } from './openApi/2.0.x'; @@ -88,6 +87,13 @@ export type { OpenApiSchemaObject, } from './openApi/types'; export type { Hooks } from './parser/hooks'; +export type { + SchemaExtractor, + SchemaProcessor, + SchemaProcessorContext, + SchemaProcessorResult, +} from './plugins/schema-processor'; +export { createSchemaProcessor } from './plugins/schema-processor'; export type { SchemaWithType } from './plugins/shared/types/schema'; export { definePluginConfig, mappers } from './plugins/shared/utils/config'; export type { PluginInstanceTypes } from './plugins/shared/utils/instance'; @@ -110,9 +116,10 @@ export { heyApiRegistryBaseUrl } from './utils/input/heyApi'; export { MinHeap } from './utils/minHeap'; export { applyNaming, resolveNaming, toCase } from './utils/naming/naming'; export type { Casing, NameTransformer, NamingConfig, NamingRule } from './utils/naming/types'; +export { pathToName } from './utils/path'; export { encodeJsonPointerSegment, - isTopLevelComponentRef, + isTopLevelComponent, jsonPointerToPath, normalizeJsonPointer, pathToJsonPointer, diff --git a/packages/shared/src/ir/utils.ts b/packages/shared/src/ir/utils.ts index 477251efb..bb0e641a7 100644 --- a/packages/shared/src/ir/utils.ts +++ b/packages/shared/src/ir/utils.ts @@ -1,4 +1,3 @@ -import { pathToJsonPointer } from '../utils/ref'; import type { IR } from './types'; /** @@ -43,37 +42,3 @@ export function addItemsToSchema({ schema.items = items; return schema; } - -export type SchemaExtractorContext = { - path: ReadonlyArray; - schema: IR.SchemaObject; -}; - -export type SchemaExtractor = (ctx: SchemaExtractorContext) => IR.SchemaObject; - -export const inlineSchema: SchemaExtractor = (ctx) => ctx.schema; - -export function createSchemaExtractor({ - callback, - shouldExtract, -}: { - /** Called when a schema should be extracted. Should call irSchemaToAst with the provided path to extract the schema and register the symbol. */ - callback: (ctx: SchemaExtractorContext) => void; - /** Determines whether a schema at a given path should be extracted. */ - shouldExtract: (ctx: SchemaExtractorContext) => boolean; -}): SchemaExtractor { - // track pointers to prevent infinite recursion - const extractedPointers = new Set(); - - const extractor: SchemaExtractor = (ctx) => { - const pointer = pathToJsonPointer(ctx.path); - if (extractedPointers.has(pointer) || !shouldExtract(ctx)) { - return ctx.schema; - } - extractedPointers.add(pointer); - callback(ctx); - return { $ref: pointer }; - }; - - return extractor; -} diff --git a/packages/shared/src/openApi/2.0.x/parser/schema.ts b/packages/shared/src/openApi/2.0.x/parser/schema.ts index 86578c74c..d3f2cf55c 100644 --- a/packages/shared/src/openApi/2.0.x/parser/schema.ts +++ b/packages/shared/src/openApi/2.0.x/parser/schema.ts @@ -7,7 +7,7 @@ import type { SchemaWithRequired, } from '../../../openApi/shared/types/schema'; import { discriminatorValues } from '../../../openApi/shared/utils/discriminator'; -import { isTopLevelComponentRef, refToName } from '../../../utils/ref'; +import { isTopLevelComponent, refToName } from '../../../utils/ref'; import type { SchemaObject } from '../types/spec'; export const getSchemaType = ({ @@ -531,7 +531,7 @@ const parseRef = ({ const irSchema: IR.SchemaObject = {}; // Inline non-component refs (e.g. #/paths/...) and deep path refs (e.g. #/definitions/Foo/properties/bar) // to avoid generating orphaned named types or referencing unregistered symbols - const isComponentsRef = isTopLevelComponentRef(schema.$ref); + const isComponentsRef = isTopLevelComponent(schema.$ref); if (!isComponentsRef) { if (!state.circularReferenceTracker.has(schema.$ref)) { const refSchema = context.resolveRef(schema.$ref); diff --git a/packages/shared/src/openApi/3.0.x/parser/schema.ts b/packages/shared/src/openApi/3.0.x/parser/schema.ts index 6a235435a..f805b8b1f 100644 --- a/packages/shared/src/openApi/3.0.x/parser/schema.ts +++ b/packages/shared/src/openApi/3.0.x/parser/schema.ts @@ -7,7 +7,7 @@ import type { SchemaWithRequired, } from '../../../openApi/shared/types/schema'; import { discriminatorValues } from '../../../openApi/shared/utils/discriminator'; -import { isTopLevelComponentRef, refToName } from '../../../utils/ref'; +import { isTopLevelComponent, refToName } from '../../../utils/ref'; import type { ReferenceObject, SchemaObject } from '../types/spec'; export const getSchemaType = ({ @@ -924,7 +924,7 @@ const parseRef = ({ }): IR.SchemaObject => { // Inline non-component refs (e.g. #/paths/...) and deep path refs (e.g. #/components/schemas/Foo/properties/bar) // to avoid generating orphaned named types or referencing unregistered symbols - const isComponentsRef = isTopLevelComponentRef(schema.$ref); + const isComponentsRef = isTopLevelComponent(schema.$ref); if (!isComponentsRef) { if (!state.circularReferenceTracker.has(schema.$ref)) { const refSchema = context.resolveRef(schema.$ref); diff --git a/packages/shared/src/openApi/3.1.x/parser/schema.ts b/packages/shared/src/openApi/3.1.x/parser/schema.ts index d2aeb00fb..0cad93884 100644 --- a/packages/shared/src/openApi/3.1.x/parser/schema.ts +++ b/packages/shared/src/openApi/3.1.x/parser/schema.ts @@ -7,7 +7,7 @@ import type { SchemaWithRequired, } from '../../../openApi/shared/types/schema'; import { discriminatorValues } from '../../../openApi/shared/utils/discriminator'; -import { isTopLevelComponentRef, refToName } from '../../../utils/ref'; +import { isTopLevelComponent, refToName } from '../../../utils/ref'; import type { SchemaObject } from '../types/spec'; export const getSchemaTypes = ({ @@ -984,7 +984,7 @@ const parseRef = ({ }): IR.SchemaObject => { // Inline non-component refs (e.g. #/paths/...) and deep path refs (e.g. #/components/schemas/Foo/properties/bar) // to avoid generating orphaned named types or referencing unregistered symbols - const isComponentsRef = isTopLevelComponentRef(schema.$ref); + const isComponentsRef = isTopLevelComponent(schema.$ref); if (!isComponentsRef) { if (!state.circularReferenceTracker.has(schema.$ref)) { const refSchema = context.resolveRef(schema.$ref); diff --git a/packages/shared/src/parser/hooks.ts b/packages/shared/src/parser/hooks.ts index d7fbcce29..b159bb52d 100644 --- a/packages/shared/src/parser/hooks.ts +++ b/packages/shared/src/parser/hooks.ts @@ -1,6 +1,7 @@ import type { Node, Symbol, SymbolIn } from '@hey-api/codegen-core'; import type { IROperationObject } from '../ir/types'; +import type { SchemaProcessorContext } from '../plugins/schema-processor'; import type { PluginInstance } from '../plugins/shared/utils/instance'; export type Hooks = { @@ -165,6 +166,28 @@ export type Hooks = { */ isQuery?: (operation: IROperationObject) => boolean | undefined; }; + schemas?: { + /** + * Whether to extract the given schema into a separate symbol. + * + * This affects how schemas are processed and output. + * + * **Default behavior:** No schemas are extracted. + * + * @param ctx - The processing context for the schema. + * @returns true to extract the schema, false to keep it inline, or undefined to fallback to default behavior. + * @example + * ```ts + * shouldExtract: (ctx) => { + * if (ctx.meta.resource === 'requestBody') { + * return true; + * } + * return; // fallback to default behavior + * } + * ``` + */ + shouldExtract?: (ctx: SchemaProcessorContext) => boolean; + }; /** * Hooks specifically for overriding symbols behavior. * diff --git a/packages/shared/src/plugins/index.ts b/packages/shared/src/plugins/index.ts deleted file mode 100644 index 2a0d4dd8a..000000000 --- a/packages/shared/src/plugins/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type { SchemaWithType } from './shared/types/schema'; -export type { DefinePlugin, Plugin } from './types'; diff --git a/packages/shared/src/plugins/schema-processor.ts b/packages/shared/src/plugins/schema-processor.ts new file mode 100644 index 000000000..74dd1993e --- /dev/null +++ b/packages/shared/src/plugins/schema-processor.ts @@ -0,0 +1,70 @@ +import type { IR } from '../ir/types'; +import { pathToJsonPointer } from '../utils/ref'; + +export interface SchemaProcessor { + /** Current inherited context (set by withContext) */ + readonly context: { + readonly anchor: string | undefined; + readonly tags: ReadonlyArray | undefined; + }; + /** Check if pointer was already emitted */ + hasEmitted: (path: ReadonlyArray) => boolean; + /** Mark pointer as emitted. Returns false if already emitted. */ + markEmitted: (path: ReadonlyArray) => boolean; + /** Execute with inherited context for nested extractions */ + withContext: (ctx: { anchor?: string; tags?: ReadonlyArray }, fn: () => T) => T; +} + +export interface SchemaProcessorContext { + meta: { resource: string; resourceId: string; role?: string }; + namingAnchor?: string; + path: ReadonlyArray; + schema: IR.SchemaObject; + tags?: ReadonlyArray; +} + +export interface SchemaProcessorResult< + Context extends SchemaProcessorContext = SchemaProcessorContext, +> { + process: (ctx: Context) => void; +} + +export type SchemaExtractor = ( + ctx: Context, +) => IR.SchemaObject; + +export function createSchemaProcessor(): SchemaProcessor { + const emitted = new Set(); + let contextTags: ReadonlyArray | undefined; + let contextAnchor: string | undefined; + + return { + get context() { + return { + anchor: contextAnchor, + tags: contextTags, + }; + }, + hasEmitted(path) { + return emitted.has(pathToJsonPointer(path)); + }, + markEmitted(path) { + const pointer = pathToJsonPointer(path); + if (emitted.has(pointer)) return false; + emitted.add(pointer); + return true; + }, + withContext(ctx, fn) { + const prevTags = contextTags; + const prevAnchor = contextAnchor; + contextTags = ctx.tags; + contextAnchor = ctx.anchor; + try { + return fn(); + } finally { + contextTags = prevTags; + contextAnchor = prevAnchor; + } + }, + }; +} diff --git a/packages/shared/src/utils/__tests__/path.test.ts b/packages/shared/src/utils/__tests__/path.test.ts new file mode 100644 index 000000000..b7963a3cd --- /dev/null +++ b/packages/shared/src/utils/__tests__/path.test.ts @@ -0,0 +1,188 @@ +import { pathToName } from '../path'; + +describe('pathToName', () => { + // ── OpenAPI 3.x component schemas ── + + it('handles top-level schema', () => { + expect(pathToName(['components', 'schemas', 'User'])).toBe('User'); + }); + + it('handles nested property', () => { + expect(pathToName(['components', 'schemas', 'User', 'properties', 'address'])).toBe( + 'User-address', + ); + }); + + it('handles deeply nested properties', () => { + expect( + pathToName(['components', 'schemas', 'User', 'properties', 'address', 'properties', 'city']), + ).toBe('User-address-city'); + }); + + it('handles property literally named "properties"', () => { + expect(pathToName(['components', 'schemas', 'Foo', 'properties', 'properties'])).toBe( + 'Foo-properties', + ); + }); + + it('handles property named "properties" with children', () => { + expect( + pathToName([ + 'components', + 'schemas', + 'Foo', + 'properties', + 'properties', + 'properties', + 'items', + ]), + ).toBe('Foo-properties-items'); + }); + + it('handles property named "items"', () => { + expect(pathToName(['components', 'schemas', 'Foo', 'properties', 'items'])).toBe('Foo-items'); + }); + + // ── additionalProperties ── + + it('handles additionalProperties', () => { + expect(pathToName(['components', 'schemas', 'Pet', 'additionalProperties'])).toBe('Pet-Value'); + }); + + it('handles nested additionalProperties', () => { + expect( + pathToName([ + 'components', + 'schemas', + 'Pet', + 'properties', + 'metadata', + 'additionalProperties', + ]), + ).toBe('Pet-metadata-Value'); + }); + + // ── Array items ── + + it('handles array items (skips index)', () => { + expect( + pathToName(['components', 'schemas', 'Order', 'properties', 'line_items', 'items', 0]), + ).toBe('Order-line_items'); + }); + + it('handles items without numeric index', () => { + expect(pathToName(['components', 'schemas', 'Result', 'items', 0])).toBe('Result'); + }); + + // ── Tuple items ── + + it('handles tuple items at different indices', () => { + expect(pathToName(['components', 'schemas', 'Pair', 'items', 0])).toBe('Pair'); + + expect(pathToName(['components', 'schemas', 'Pair', 'items', 1])).toBe('Pair'); + }); + + // ── patternProperties ── + + it('handles patternProperties', () => { + expect(pathToName(['components', 'schemas', 'Config', 'patternProperties', '^x-'])).toBe( + 'Config-^x-', + ); + }); + + // ── OpenAPI 2.0 ── + + it('handles definitions (OpenAPI 2.0)', () => { + expect(pathToName(['definitions', 'User'])).toBe('User'); + }); + + it('handles definitions with nested properties', () => { + expect(pathToName(['definitions', 'User', 'properties', 'address'])).toBe('User-address'); + }); + + // ── Paths (operations) ── + + it('handles simple path', () => { + expect(pathToName(['paths', '/event', 'get', 'properties', 'query'])).toBe('Event-get-query'); + }); + + it('handles path with multiple segments', () => { + expect(pathToName(['paths', '/api/v1/users', 'post', 'properties', 'body'])).toBe( + 'ApiV1Users-post-body', + ); + }); + + it('handles path with parameter', () => { + expect(pathToName(['paths', '/users/{id}/posts', 'get', 'properties', 'query'])).toBe( + 'UsersIdPosts-get-query', + ); + }); + + it('handles path without properties', () => { + expect(pathToName(['paths', '/event', 'get'])).toBe('Event-get'); + }); + + // ── Webhooks ── + + it('handles webhooks', () => { + expect(pathToName(['webhooks', 'onEvent', 'post', 'properties', 'body'])).toBe( + 'onEvent-post-body', + ); + }); + + // ── Component types beyond schemas ── + + it('handles component parameters', () => { + expect(pathToName(['components', 'parameters', 'UserId'])).toBe('UserId'); + }); + + it('handles component requestBodies', () => { + expect(pathToName(['components', 'requestBodies', 'CreateUser', 'properties', 'name'])).toBe( + 'CreateUser-name', + ); + }); + + // ── Encoded characters ── + + it('handles URI-encoded names', () => { + expect(pathToName(['components', 'schemas', 'My%20Schema'])).toBe('My Schema'); + }); + + // ── Anchor option ── + + it('uses anchor for component schema', () => { + expect( + pathToName(['components', 'schemas', 'User', 'properties', 'address'], { + anchor: 'UserDTO', + }), + ).toBe('UserDTO-address'); + }); + + it('uses anchor for paths', () => { + expect( + pathToName(['paths', '/event', 'get', 'properties', 'query'], { + anchor: 'event.subscribe', + }), + ).toBe('event.subscribe-query'); + }); + + it('uses anchor and preserves structural suffix', () => { + expect( + pathToName(['components', 'schemas', 'Pet', 'additionalProperties'], { + anchor: 'PetMap', + }), + ).toBe('PetMap-Value'); + }); + + it('uses anchor with deeply nested properties', () => { + expect( + pathToName(['components', 'schemas', 'User', 'properties', 'address', 'properties', 'city'], { + anchor: 'UserInput', + }), + ).toBe('UserInput-address-city'); + }); + + it('uses anchor for unknown root', () => { + expect(pathToName(['foo', 'bar', 'baz'], { anchor: 'Root' })).toBe('Root'); + }); +}); diff --git a/packages/shared/src/utils/__tests__/ref.test.ts b/packages/shared/src/utils/__tests__/ref.test.ts index a4c8b3f53..a311ddfb9 100644 --- a/packages/shared/src/utils/__tests__/ref.test.ts +++ b/packages/shared/src/utils/__tests__/ref.test.ts @@ -1,4 +1,4 @@ -import { isTopLevelComponentRef, jsonPointerToPath, pathToJsonPointer } from '../ref'; +import { isTopLevelComponent, jsonPointerToPath, pathToJsonPointer } from '../ref'; describe('jsonPointerToPath', () => { it('parses root pointer', () => { @@ -32,41 +32,41 @@ describe('pathToJsonPointer', () => { }); }); -describe('isTopLevelComponentRef', () => { +describe('isTopLevelComponent', () => { describe('OpenAPI 3.x refs', () => { it('returns true for top-level component refs', () => { - expect(isTopLevelComponentRef('#/components/schemas/Foo')).toBe(true); - expect(isTopLevelComponentRef('#/components/parameters/Bar')).toBe(true); - expect(isTopLevelComponentRef('#/components/responses/Error')).toBe(true); - expect(isTopLevelComponentRef('#/components/requestBodies/Body')).toBe(true); + expect(isTopLevelComponent('#/components/schemas/Foo')).toBe(true); + expect(isTopLevelComponent('#/components/parameters/Bar')).toBe(true); + expect(isTopLevelComponent('#/components/responses/Error')).toBe(true); + expect(isTopLevelComponent('#/components/requestBodies/Body')).toBe(true); }); it('returns false for deep path refs', () => { - expect(isTopLevelComponentRef('#/components/schemas/Foo/properties/bar')).toBe(false); - expect(isTopLevelComponentRef('#/components/schemas/Foo/properties/bar/items')).toBe(false); - expect(isTopLevelComponentRef('#/components/schemas/Foo/allOf/0')).toBe(false); + expect(isTopLevelComponent('#/components/schemas/Foo/properties/bar')).toBe(false); + expect(isTopLevelComponent('#/components/schemas/Foo/properties/bar/items')).toBe(false); + expect(isTopLevelComponent('#/components/schemas/Foo/allOf/0')).toBe(false); }); }); describe('OpenAPI 2.0 refs', () => { it('returns true for top-level definitions refs', () => { - expect(isTopLevelComponentRef('#/definitions/Foo')).toBe(true); - expect(isTopLevelComponentRef('#/definitions/Bar')).toBe(true); + expect(isTopLevelComponent('#/definitions/Foo')).toBe(true); + expect(isTopLevelComponent('#/definitions/Bar')).toBe(true); }); it('returns false for deep path refs', () => { - expect(isTopLevelComponentRef('#/definitions/Foo/properties/bar')).toBe(false); - expect(isTopLevelComponentRef('#/definitions/Foo/properties/bar/items')).toBe(false); + expect(isTopLevelComponent('#/definitions/Foo/properties/bar')).toBe(false); + expect(isTopLevelComponent('#/definitions/Foo/properties/bar/items')).toBe(false); }); }); describe('non-component refs', () => { it('returns false for path refs', () => { - expect(isTopLevelComponentRef('#/paths/~1users/get')).toBe(false); + expect(isTopLevelComponent('#/paths/~1users/get')).toBe(false); }); it('returns false for other refs', () => { - expect(isTopLevelComponentRef('#/info/title')).toBe(false); + expect(isTopLevelComponent('#/info/title')).toBe(false); }); }); }); diff --git a/packages/shared/src/utils/path.ts b/packages/shared/src/utils/path.ts new file mode 100644 index 000000000..c176990f9 --- /dev/null +++ b/packages/shared/src/utils/path.ts @@ -0,0 +1,142 @@ +/** + * After these structural segments, the next segment has a known role. + * This is what makes a property literally named "properties" safe — + * it occupies the name position, never the structural position. + */ +const STRUCTURAL_ROLE: Record = { + items: 'index', + patternProperties: 'name', + properties: 'name', +}; + +/** + * These structural segments have no following name/index — + * they are the terminal structural node. Append a suffix + * to disambiguate from the parent. + */ +const STRUCTURAL_SUFFIX: Record = { + additionalProperties: 'Value', +}; + +type RootContextConfig = { + /** How many consecutive semantic segments follow before structural walking begins */ + names: number; + /** How many leading segments to skip (the root keyword + any category segment) */ + skip: number; +}; + +/** + * Root context configuration. + */ +const ROOT_CONTEXT: Record = { + components: { names: 1, skip: 2 }, // components/schemas/{name} + definitions: { names: 1, skip: 1 }, // definitions/{name} + paths: { names: 2, skip: 1 }, // paths/{path}/{method} + webhooks: { names: 2, skip: 1 }, // webhooks/{name}/{method} +}; + +/** + * Sanitizes a path segment for use in a derived name. + * + * Handles API path segments like `/api/v1/users/{id}` → `ApiV1UsersId`. + */ +function sanitizeSegment(segment: string | number): string { + const str = String(segment); + if (str.startsWith('/')) { + return str + .split('/') + .filter(Boolean) + .map((part) => { + const clean = part.replace(/[{}]/g, ''); + return clean.charAt(0).toUpperCase() + clean.slice(1); + }) + .join(''); + } + return str; +} + +export interface PathToNameOptions { + /** + * When provided, replaces the root semantic segments with this anchor. + * Structural suffixes are still derived from path. + */ + anchor?: string; +} + +/** + * Derives a composite name from a path. + * + * Examples: + * .../User → 'User' + * .../User/properties/address → 'UserAddress' + * .../User/properties/properties → 'UserProperties' + * .../User/properties/address/properties/city → 'UserAddressCity' + * .../Pet/additionalProperties → 'PetValue' + * .../Order/properties/items/items/0 → 'OrderItems' + * paths//event/get/properties/query → 'EventGetQuery' + * + * With anchor: + * paths//event/get/properties/query, { anchor: 'event.subscribe' } + * → 'event.subscribe-Query' + */ +export function pathToName( + path: ReadonlyArray, + options?: PathToNameOptions, +): string { + const names: Array = []; + let index = 0; + + const rootContext = ROOT_CONTEXT[path[0]!]; + if (rootContext) { + index = rootContext.skip; + + if (options?.anchor) { + // Use anchor as base name, skip past root semantic segments + names.push(options.anchor); + index += rootContext.names; + } else { + // Collect consecutive semantic name segments + for (let n = 0; n < rootContext.names && index < path.length; n++) { + names.push(sanitizeSegment(path[index]!)); + index++; + } + } + } else { + // Unknown root + if (options?.anchor) { + names.push(options.anchor); + index++; + } else if (index < path.length) { + names.push(sanitizeSegment(path[index]!)); + index++; + } + } + + while (index < path.length) { + const segment = String(path[index]); + + const role = STRUCTURAL_ROLE[segment]; + if (role === 'name') { + // Next segment is a semantic name — collect it + index++; + if (index < path.length) { + names.push(sanitizeSegment(path[index]!)); + } + } else if (role === 'index') { + // Next segment is a numeric index — skip it + index++; + if (index < path.length && typeof path[index] === 'number') { + index++; + } + continue; + } else if (STRUCTURAL_SUFFIX[segment]) { + names.push(STRUCTURAL_SUFFIX[segment]); + } + + index++; + } + + // refs using unicode characters become encoded, didn't investigate why + // but the suspicion is this comes from `@hey-api/json-schema-ref-parser` + return decodeURI(names.join('-')); +} diff --git a/packages/shared/src/utils/ref.ts b/packages/shared/src/utils/ref.ts index 5b76b8bea..d20ab19ee 100644 --- a/packages/shared/src/utils/ref.ts +++ b/packages/shared/src/utils/ref.ts @@ -92,7 +92,7 @@ export function pathToJsonPointer(path: ReadonlyArray): string } /** - * Checks if a $ref points to a top-level component (not a deep path reference). + * Checks if a $ref or path points to a top-level component (not a deep path reference). * * Top-level component references: * - OpenAPI 3.x: #/components/{type}/{name} (3 segments) @@ -101,11 +101,11 @@ export function pathToJsonPointer(path: ReadonlyArray): string * Deep path references (4+ segments for 3.x, 3+ for 2.0) should be inlined * because they don't have corresponding registered symbols. * - * @param $ref - The $ref string to check + * @param refOrPath - The $ref string or path array to check * @returns true if the ref points to a top-level component, false otherwise */ -export function isTopLevelComponentRef($ref: string): boolean { - const path = jsonPointerToPath($ref); +export function isTopLevelComponent(refOrPath: string | ReadonlyArray): boolean { + const path = refOrPath instanceof Array ? refOrPath : jsonPointerToPath(refOrPath); // OpenAPI 3.x: #/components/{type}/{name} = 3 segments if (path[0] === 'components') { diff --git a/turbo.json b/turbo.json index d2423397a..b32a4c702 100644 --- a/turbo.json +++ b/turbo.json @@ -3,7 +3,13 @@ "tasks": { "build": { "dependsOn": ["^build"], - "inputs": ["src/**", "package.json", "tsdown.config.ts", "tsconfig.json"], + "inputs": [ + "src/**", + "!src/**/*.test.ts", + "package.json", + "tsconfig.json", + "tsdown.config.ts" + ], "outputs": [ ".next/**", "!.next/cache/**", -- 2.51.2