From adbf2e25bbc0b2ba29619556448dcdb90db11d27 Mon Sep 17 00:00:00 2001 From: Samuel Will Date: Mon, 24 Nov 2025 11:48:33 +0200 Subject: [PATCH 1/6] feat: add zod v4 .meta support instead of using global registry This enables local meta support. No breaking changes on the api. Also parses examples for previous global registry. --- .../validators-metadata-global/zod.gen.ts | 59 ++++++++++ .../mini/validators-metadata-local/zod.gen.ts | 59 ++++++++++ .../v3/validators-metadata-global/zod.gen.ts | 39 +++++++ .../v3/validators-metadata-local/zod.gen.ts | 39 +++++++ .../v4/validators-metadata-global/zod.gen.ts | 106 ++++++++++++++++++ .../v4/validators-metadata-local/zod.gen.ts | 106 ++++++++++++++++++ .../zod/v4/test/3.1.x.test.ts | 30 +++++ packages/openapi-ts/src/ir/types.d.ts | 1 + .../src/openApi/3.1.x/parser/schema.ts | 4 + .../openapi-ts/src/plugins/zod/constants.ts | 1 + .../openapi-ts/src/plugins/zod/types.d.ts | 18 ++- .../openapi-ts/src/plugins/zod/v4/plugin.ts | 61 ++++++++-- specs/3.1.x/validators-metadata-enhanced.yaml | 72 ++++++++++++ 13 files changed, 584 insertions(+), 11 deletions(-) create mode 100644 packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/mini/validators-metadata-global/zod.gen.ts create mode 100644 packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/mini/validators-metadata-local/zod.gen.ts create mode 100644 packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v3/validators-metadata-global/zod.gen.ts create mode 100644 packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v3/validators-metadata-local/zod.gen.ts create mode 100644 packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v4/validators-metadata-global/zod.gen.ts create mode 100644 packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v4/validators-metadata-local/zod.gen.ts create mode 100644 specs/3.1.x/validators-metadata-enhanced.yaml diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/mini/validators-metadata-global/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/mini/validators-metadata-global/zod.gen.ts new file mode 100644 index 000000000..9b31796da --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/mini/validators-metadata-global/zod.gen.ts @@ -0,0 +1,59 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod/mini'; + +/** + * User Schema + * + * A user in the system. + */ +export const zUser = z.object({ + id: z.optional(z.int().register(z.globalRegistry, { + description: 'Unique identifier for the user' + })), + username: z.optional(z.string().register(z.globalRegistry, { + description: 'The user login name' + })), + email: z.optional(z.email().register(z.globalRegistry, { + description: 'User email address' + })), + age: z.optional(z.int().register(z.globalRegistry, { + description: 'User age in years' + })), + role: z.optional(z.string().register(z.globalRegistry, { + description: 'The role of the user' + })), + status: z.optional(z.enum([ + 'active', + 'inactive', + 'suspended' + ]).register(z.globalRegistry, { + description: 'Current status of the account' + })) +}).register(z.globalRegistry, { + description: 'A user in the system.' +}); + +/** + * Product + * + * A product in the catalog + * + * @deprecated + */ +export const zProduct = z.object({ + sku: z.optional(z.string().check(z.regex(/^[A-Z]{3}-\d{4}$/)).register(z.globalRegistry, { + description: 'Product SKU code' + })), + price: z.optional(z.number().register(z.globalRegistry, { + description: 'Price in USD' + })) +}).register(z.globalRegistry, { + description: 'A product in the catalog' +}); + +export const zPostFooData = z.object({ + body: z.optional(zUser), + path: z.optional(z.never()), + query: z.optional(z.never()) +}); diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/mini/validators-metadata-local/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/mini/validators-metadata-local/zod.gen.ts new file mode 100644 index 000000000..9b31796da --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/mini/validators-metadata-local/zod.gen.ts @@ -0,0 +1,59 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod/mini'; + +/** + * User Schema + * + * A user in the system. + */ +export const zUser = z.object({ + id: z.optional(z.int().register(z.globalRegistry, { + description: 'Unique identifier for the user' + })), + username: z.optional(z.string().register(z.globalRegistry, { + description: 'The user login name' + })), + email: z.optional(z.email().register(z.globalRegistry, { + description: 'User email address' + })), + age: z.optional(z.int().register(z.globalRegistry, { + description: 'User age in years' + })), + role: z.optional(z.string().register(z.globalRegistry, { + description: 'The role of the user' + })), + status: z.optional(z.enum([ + 'active', + 'inactive', + 'suspended' + ]).register(z.globalRegistry, { + description: 'Current status of the account' + })) +}).register(z.globalRegistry, { + description: 'A user in the system.' +}); + +/** + * Product + * + * A product in the catalog + * + * @deprecated + */ +export const zProduct = z.object({ + sku: z.optional(z.string().check(z.regex(/^[A-Z]{3}-\d{4}$/)).register(z.globalRegistry, { + description: 'Product SKU code' + })), + price: z.optional(z.number().register(z.globalRegistry, { + description: 'Price in USD' + })) +}).register(z.globalRegistry, { + description: 'A product in the catalog' +}); + +export const zPostFooData = z.object({ + body: z.optional(zUser), + path: z.optional(z.never()), + query: z.optional(z.never()) +}); diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v3/validators-metadata-global/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v3/validators-metadata-global/zod.gen.ts new file mode 100644 index 000000000..4bd9691a7 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v3/validators-metadata-global/zod.gen.ts @@ -0,0 +1,39 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { z } from 'zod/v3'; + +/** + * User Schema + * + * A user in the system. + */ +export const zUser = z.object({ + id: z.number().int().describe('Unique identifier for the user').optional(), + username: z.string().describe('The user login name').optional(), + email: z.string().email().describe('User email address').optional(), + age: z.number().int().describe('User age in years').optional(), + role: z.string().describe('The role of the user').optional(), + status: z.enum([ + 'active', + 'inactive', + 'suspended' + ]).describe('Current status of the account').optional() +}).describe('A user in the system.'); + +/** + * Product + * + * A product in the catalog + * + * @deprecated + */ +export const zProduct = z.object({ + sku: z.string().regex(/^[A-Z]{3}-\d{4}$/).describe('Product SKU code').optional(), + price: z.number().describe('Price in USD').optional() +}).describe('A product in the catalog'); + +export const zPostFooData = z.object({ + body: zUser.optional(), + path: z.never().optional(), + query: z.never().optional() +}); diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v3/validators-metadata-local/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v3/validators-metadata-local/zod.gen.ts new file mode 100644 index 000000000..4bd9691a7 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v3/validators-metadata-local/zod.gen.ts @@ -0,0 +1,39 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { z } from 'zod/v3'; + +/** + * User Schema + * + * A user in the system. + */ +export const zUser = z.object({ + id: z.number().int().describe('Unique identifier for the user').optional(), + username: z.string().describe('The user login name').optional(), + email: z.string().email().describe('User email address').optional(), + age: z.number().int().describe('User age in years').optional(), + role: z.string().describe('The role of the user').optional(), + status: z.enum([ + 'active', + 'inactive', + 'suspended' + ]).describe('Current status of the account').optional() +}).describe('A user in the system.'); + +/** + * Product + * + * A product in the catalog + * + * @deprecated + */ +export const zProduct = z.object({ + sku: z.string().regex(/^[A-Z]{3}-\d{4}$/).describe('Product SKU code').optional(), + price: z.number().describe('Price in USD').optional() +}).describe('A product in the catalog'); + +export const zPostFooData = z.object({ + body: zUser.optional(), + path: z.never().optional(), + query: z.never().optional() +}); diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v4/validators-metadata-global/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v4/validators-metadata-global/zod.gen.ts new file mode 100644 index 000000000..6d276f02a --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v4/validators-metadata-global/zod.gen.ts @@ -0,0 +1,106 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { z } from 'zod'; + +/** + * User Schema + * + * A user in the system. + */ +export const zUser = z.object({ + id: z.optional(z.int().register(z.globalRegistry, { + description: 'Unique identifier for the user', + title: 'User ID', + examples: [ + 1, + 42, + 999 + ] + })), + username: z.optional(z.string().register(z.globalRegistry, { + description: 'The user login name', + title: 'Username', + examples: [ + 'john_doe', + 'jane_smith' + ] + })), + email: z.optional(z.email().register(z.globalRegistry, { + description: 'User email address', + title: 'Email Address', + examples: [ + 'user@example.com', + 'test@test.org' + ] + })), + age: z.optional(z.int().register(z.globalRegistry, { + description: 'User age in years', + title: 'Age', + examples: [ + 25, + 30, + 45 + ] + })), + role: z.optional(z.string().register(z.globalRegistry, { + description: 'The role of the user', + title: 'User Role', + deprecated: true, + examples: [ + 'admin', + 'user', + 'guest' + ] + })), + status: z.optional(z.enum([ + 'active', + 'inactive', + 'suspended' + ]).register(z.globalRegistry, { + description: 'Current status of the account', + title: 'Account Status', + examples: [ + 'active' + ] + })) +}).register(z.globalRegistry, { + description: 'A user in the system.', + title: 'User Schema' +}); + +/** + * Product + * + * A product in the catalog + * + * @deprecated + */ +export const zProduct = z.object({ + sku: z.optional(z.string().regex(/^[A-Z]{3}-\d{4}$/).register(z.globalRegistry, { + description: 'Product SKU code', + title: 'Stock Keeping Unit', + examples: [ + 'ABC-1234', + 'XYZ-9999' + ] + })), + price: z.optional(z.number().register(z.globalRegistry, { + description: 'Price in USD', + title: 'Product Price', + examples: [ + 19.99, + 49.95, + 99.99 + ] + })) +}).register(z.globalRegistry, { + description: 'A product in the catalog', + title: 'Product', + deprecated: true +}); + +export const zPostFooData = z.object({ + body: z.optional(zUser), + path: z.optional(z.never()), + query: z.optional(z.never()) +}); diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v4/validators-metadata-local/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v4/validators-metadata-local/zod.gen.ts new file mode 100644 index 000000000..8eb8ef578 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v4/validators-metadata-local/zod.gen.ts @@ -0,0 +1,106 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { z } from 'zod'; + +/** + * User Schema + * + * A user in the system. + */ +export const zUser = z.object({ + id: z.optional(z.int().meta({ + description: 'Unique identifier for the user', + title: 'User ID', + examples: [ + 1, + 42, + 999 + ] + })), + username: z.optional(z.string().meta({ + description: 'The user login name', + title: 'Username', + examples: [ + 'john_doe', + 'jane_smith' + ] + })), + email: z.optional(z.email().meta({ + description: 'User email address', + title: 'Email Address', + examples: [ + 'user@example.com', + 'test@test.org' + ] + })), + age: z.optional(z.int().meta({ + description: 'User age in years', + title: 'Age', + examples: [ + 25, + 30, + 45 + ] + })), + role: z.optional(z.string().meta({ + description: 'The role of the user', + title: 'User Role', + deprecated: true, + examples: [ + 'admin', + 'user', + 'guest' + ] + })), + status: z.optional(z.enum([ + 'active', + 'inactive', + 'suspended' + ]).meta({ + description: 'Current status of the account', + title: 'Account Status', + examples: [ + 'active' + ] + })) +}).meta({ + description: 'A user in the system.', + title: 'User Schema' +}); + +/** + * Product + * + * A product in the catalog + * + * @deprecated + */ +export const zProduct = z.object({ + sku: z.optional(z.string().regex(/^[A-Z]{3}-\d{4}$/).meta({ + description: 'Product SKU code', + title: 'Stock Keeping Unit', + examples: [ + 'ABC-1234', + 'XYZ-9999' + ] + })), + price: z.optional(z.number().meta({ + description: 'Price in USD', + title: 'Product Price', + examples: [ + 19.99, + 49.95, + 99.99 + ] + })) +}).meta({ + description: 'A product in the catalog', + title: 'Product', + deprecated: true +}); + +export const zPostFooData = z.object({ + body: z.optional(zUser), + path: z.optional(z.never()), + query: z.optional(z.never()) +}); diff --git a/packages/openapi-ts-tests/zod/v4/test/3.1.x.test.ts b/packages/openapi-ts-tests/zod/v4/test/3.1.x.test.ts index 03877b95f..2f35fecd9 100644 --- a/packages/openapi-ts-tests/zod/v4/test/3.1.x.test.ts +++ b/packages/openapi-ts-tests/zod/v4/test/3.1.x.test.ts @@ -100,6 +100,36 @@ for (const zodVersion of zodVersions) { }), description: 'generates validator schemas with metadata', }, + { + config: createConfig({ + input: 'validators-metadata-enhanced.yaml', + output: 'validators-metadata-local', + plugins: [ + { + compatibilityVersion: zodVersion.compatibilityVersion, + metadata: 'local', + name: 'zod', + }, + ], + }), + description: + 'generates validator schemas with local metadata using .meta()', + }, + { + config: createConfig({ + input: 'validators-metadata-enhanced.yaml', + output: 'validators-metadata-global', + plugins: [ + { + compatibilityVersion: zodVersion.compatibilityVersion, + metadata: 'global', + name: 'zod', + }, + ], + }), + description: + 'generates validator schemas with global metadata using .register()', + }, { config: createConfig({ input: 'validators.yaml', diff --git a/packages/openapi-ts/src/ir/types.d.ts b/packages/openapi-ts/src/ir/types.d.ts index 6c28cce86..e5498728e 100644 --- a/packages/openapi-ts/src/ir/types.d.ts +++ b/packages/openapi-ts/src/ir/types.d.ts @@ -128,6 +128,7 @@ interface IRSchemaObject | 'default' | 'deprecated' | 'description' + | 'examples' | 'exclusiveMaximum' | 'exclusiveMinimum' | 'maximum' diff --git a/packages/openapi-ts/src/openApi/3.1.x/parser/schema.ts b/packages/openapi-ts/src/openApi/3.1.x/parser/schema.ts index fb4fbebcb..275687973 100644 --- a/packages/openapi-ts/src/openApi/3.1.x/parser/schema.ts +++ b/packages/openapi-ts/src/openApi/3.1.x/parser/schema.ts @@ -47,6 +47,10 @@ const parseSchemaJsDoc = ({ irSchema.example = schema.example; } + if (schema.examples) { + irSchema.examples = schema.examples; + } + if (schema.description) { irSchema.description = schema.description; } diff --git a/packages/openapi-ts/src/plugins/zod/constants.ts b/packages/openapi-ts/src/plugins/zod/constants.ts index 5d44c6710..cc95a9358 100644 --- a/packages/openapi-ts/src/plugins/zod/constants.ts +++ b/packages/openapi-ts/src/plugins/zod/constants.ts @@ -32,6 +32,7 @@ export const identifiers = { lte: 'lte', max: 'max', maxLength: 'maxLength', + meta: 'meta', min: 'min', minLength: 'minLength', never: 'never', diff --git a/packages/openapi-ts/src/plugins/zod/types.d.ts b/packages/openapi-ts/src/plugins/zod/types.d.ts index 9038967a2..11d6b7cc1 100644 --- a/packages/openapi-ts/src/plugins/zod/types.d.ts +++ b/packages/openapi-ts/src/plugins/zod/types.d.ts @@ -151,9 +151,16 @@ export type UserConfig = Plugin.Name<'zod'> & * some additional metadata for documentation, code generation, AI * structured outputs, form validation, and other purposes. * + * Can be: + * - `false` or `undefined`: No metadata generation (default) + * - `true` or `'global'`: Use `.register(z.globalRegistry, {...})` for backwards compatibility + * - `'local'`: Use `.meta({...})` method (Zod v4 only) + * + * Metadata includes: description, title, deprecated, and examples from OpenAPI spec. + * * @default false */ - metadata?: boolean; + metadata?: boolean | 'global' | 'local'; /** * Configuration for request-specific Zod schemas. * @@ -545,9 +552,16 @@ export type Config = Plugin.Name<'zod'> & * some additional metadata for documentation, code generation, AI * structured outputs, form validation, and other purposes. * + * Can be: + * - `false`: No metadata generation (default) + * - `true` or `'global'`: Use `.register(z.globalRegistry, {...})` for backwards compatibility + * - `'local'`: Use `.meta({...})` method (Zod v4 only) + * + * Metadata includes: description, title, deprecated, and examples from OpenAPI spec. + * * @default false */ - metadata: boolean; + metadata: boolean | 'global' | 'local'; /** * Configuration for request-specific Zod schemas. * diff --git a/packages/openapi-ts/src/plugins/zod/v4/plugin.ts b/packages/openapi-ts/src/plugins/zod/v4/plugin.ts index db46ae0da..930e10c0e 100644 --- a/packages/openapi-ts/src/plugins/zod/v4/plugin.ts +++ b/packages/openapi-ts/src/plugins/zod/v4/plugin.ts @@ -65,15 +65,58 @@ export const irSchemaToAst = ({ ast.expression = typeAst.expression; ast.hasLazyExpression = typeAst.hasLazyExpression; - if (plugin.config.metadata && schema.description) { - ast.expression = ast.expression - .attr(identifiers.register) - .call( - $(z.placeholder).attr(identifiers.globalRegistry), - $.object() - .pretty() - .prop('description', $.literal(schema.description)), - ); + if (plugin.config.metadata) { + // Build metadata object with all available fields + const metadataObj = $.object().pretty(); + let hasMetadata = false; + + if (schema.description) { + metadataObj.prop('description', $.literal(schema.description)); + hasMetadata = true; + } + + if (schema.title) { + metadataObj.prop('title', $.literal(schema.title)); + hasMetadata = true; + } + + if (schema.deprecated !== undefined) { + metadataObj.prop('deprecated', $.literal(schema.deprecated)); + hasMetadata = true; + } + + // Handle examples - convert single example to array or use examples array + if (schema.examples || schema.example !== undefined) { + const examplesArray = + schema.examples || + (schema.example !== undefined ? [schema.example] : undefined); + if (examplesArray) { + metadataObj.prop( + 'examples', + $.array() + .pretty() + .elements(...examplesArray.map((ex) => $.literal(ex))), + ); + hasMetadata = true; + } + } + + if (hasMetadata) { + if (plugin.config.metadata === 'local') { + // Use .meta() for local metadata (Zod v4) + ast.expression = ast.expression + .attr(identifiers.meta) + .call(metadataObj); + } else { + // Use .register(z.globalRegistry, {...}) for global metadata (backwards compatible) + ast.expression = ast.expression + .attr(identifiers.register) + .call( + $(z.placeholder).attr(identifiers.globalRegistry), + metadataObj, + ); + } + } } } else if (schema.items) { schema = deduplicateSchema({ schema }); diff --git a/specs/3.1.x/validators-metadata-enhanced.yaml b/specs/3.1.x/validators-metadata-enhanced.yaml new file mode 100644 index 000000000..f55d0e825 --- /dev/null +++ b/specs/3.1.x/validators-metadata-enhanced.yaml @@ -0,0 +1,72 @@ +openapi: 3.1.0 +info: + title: OpenAPI 3.1.0 validators with enhanced metadata + version: '1' +paths: + /foo: + post: + requestBody: + content: + 'application/json': + schema: + $ref: '#/components/schemas/User' + responses: + '200': + description: OK +components: + schemas: + User: + title: User Schema + description: 'A user in the system.' + type: object + properties: + id: + title: User ID + description: 'Unique identifier for the user' + type: integer + examples: [1, 42, 999] + username: + title: Username + description: 'The user login name' + type: string + examples: ['john_doe', 'jane_smith'] + email: + title: Email Address + description: 'User email address' + type: string + format: email + examples: ['user@example.com', 'test@test.org'] + age: + title: Age + description: 'User age in years' + type: integer + examples: [25, 30, 45] + role: + title: User Role + description: 'The role of the user' + type: string + deprecated: true + examples: ['admin', 'user', 'guest'] + status: + title: Account Status + description: 'Current status of the account' + type: string + enum: ['active', 'inactive', 'suspended'] + examples: ['active'] + Product: + title: Product + description: 'A product in the catalog' + deprecated: true + type: object + properties: + sku: + title: Stock Keeping Unit + description: 'Product SKU code' + type: string + pattern: '^[A-Z]{3}-\d{4}$' + examples: ['ABC-1234', 'XYZ-9999'] + price: + title: Product Price + description: 'Price in USD' + type: number + examples: [19.99, 49.95, 99.99] From ebeb97dd4a63724cab065cf0662e1ab1db443e73 Mon Sep 17 00:00:00 2001 From: Samuel Will Date: Mon, 24 Nov 2025 13:20:22 +0200 Subject: [PATCH 2/6] fix: extract examples from parameters Parameter examples have not been extracted. Also places examples in "example" meta attribute instead of "examples" as this is utilized by zod v4s built-in support for generating OpenAPI schemas. --- .../validators-parameter-example/zod.gen.ts | 34 ++++++++++++ .../validators-parameter-example/zod.gen.ts | 24 ++++++++ .../validators-parameter-example/zod.gen.ts | 46 ++++++++++++++++ .../v4/validators-metadata-global/zod.gen.ts | 16 +++--- .../v4/validators-metadata-local/zod.gen.ts | 16 +++--- .../zod/v4/test/3.0.x.test.ts | 15 +++++ .../src/openApi/3.0.x/parser/parameter.ts | 28 +++++++++- .../src/openApi/3.1.x/parser/parameter.ts | 23 ++++++++ .../openapi-ts/src/plugins/zod/v4/plugin.ts | 2 +- specs/3.0.x/validators-parameter-example.yaml | 55 +++++++++++++++++++ 10 files changed, 241 insertions(+), 18 deletions(-) create mode 100644 packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/mini/validators-parameter-example/zod.gen.ts create mode 100644 packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v3/validators-parameter-example/zod.gen.ts create mode 100644 packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v4/validators-parameter-example/zod.gen.ts create mode 100644 specs/3.0.x/validators-parameter-example.yaml diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/mini/validators-parameter-example/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/mini/validators-parameter-example/zod.gen.ts new file mode 100644 index 000000000..5a6481aff --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/mini/validators-parameter-example/zod.gen.ts @@ -0,0 +1,34 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod/mini'; + +export const zUser = z.object({ + id: z.optional(z.int()), + name: z.optional(z.string()) +}); + +export const zGetUsersData = z.object({ + body: z.optional(z.never()), + path: z.optional(z.never()), + query: z.optional(z.object({ + sort: z.optional(z.string().register(z.globalRegistry, { + description: 'Sort order for results' + })), + filter: z.optional(z.string().register(z.globalRegistry, { + description: 'This description should be overridden' + })), + limit: z.optional(z.int().check(z.gte(1), z.lte(100)).register(z.globalRegistry, { + description: 'Number of results per page' + })), + search: z.optional(z.string().register(z.globalRegistry, { + description: 'Search query' + })) + })) +}); + +/** + * OK + */ +export const zGetUsersResponse = z.array(zUser).register(z.globalRegistry, { + description: 'OK' +}); diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v3/validators-parameter-example/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v3/validators-parameter-example/zod.gen.ts new file mode 100644 index 000000000..8d0cccf38 --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v3/validators-parameter-example/zod.gen.ts @@ -0,0 +1,24 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { z } from 'zod/v3'; + +export const zUser = z.object({ + id: z.number().int().optional(), + name: z.string().optional() +}); + +export const zGetUsersData = z.object({ + body: z.never().optional(), + path: z.never().optional(), + query: z.object({ + sort: z.string().describe('Sort order for results').optional(), + filter: z.string().describe('This description should be overridden').optional(), + limit: z.number().int().gte(1).lte(100).describe('Number of results per page').optional(), + search: z.string().describe('Search query').optional() + }).optional() +}); + +/** + * OK + */ +export const zGetUsersResponse = z.array(zUser).describe('OK'); diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v4/validators-parameter-example/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v4/validators-parameter-example/zod.gen.ts new file mode 100644 index 000000000..3e59af3be --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v4/validators-parameter-example/zod.gen.ts @@ -0,0 +1,46 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { z } from 'zod'; + +export const zUser = z.object({ + id: z.optional(z.int()), + name: z.optional(z.string()) +}); + +export const zGetUsersData = z.object({ + body: z.optional(z.never()), + path: z.optional(z.never()), + query: z.optional(z.object({ + sort: z.optional(z.string().meta({ + description: 'Sort order for results', + example: [ + 'name,desc' + ] + })), + filter: z.optional(z.string().meta({ + description: 'This description should be overridden', + example: [ + 'status:active' + ] + })), + limit: z.optional(z.int().gte(1).lte(100).meta({ + description: 'Number of results per page', + example: [ + 25 + ] + })), + search: z.optional(z.string().meta({ + description: 'Search query', + example: [ + 'john doe' + ] + })) + })) +}); + +/** + * OK + */ +export const zGetUsersResponse = z.array(zUser).meta({ + description: 'OK' +}); diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v4/validators-metadata-global/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v4/validators-metadata-global/zod.gen.ts index 6d276f02a..c13f52a35 100644 --- a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v4/validators-metadata-global/zod.gen.ts +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v4/validators-metadata-global/zod.gen.ts @@ -11,7 +11,7 @@ export const zUser = z.object({ id: z.optional(z.int().register(z.globalRegistry, { description: 'Unique identifier for the user', title: 'User ID', - examples: [ + example: [ 1, 42, 999 @@ -20,7 +20,7 @@ export const zUser = z.object({ username: z.optional(z.string().register(z.globalRegistry, { description: 'The user login name', title: 'Username', - examples: [ + example: [ 'john_doe', 'jane_smith' ] @@ -28,7 +28,7 @@ export const zUser = z.object({ email: z.optional(z.email().register(z.globalRegistry, { description: 'User email address', title: 'Email Address', - examples: [ + example: [ 'user@example.com', 'test@test.org' ] @@ -36,7 +36,7 @@ export const zUser = z.object({ age: z.optional(z.int().register(z.globalRegistry, { description: 'User age in years', title: 'Age', - examples: [ + example: [ 25, 30, 45 @@ -46,7 +46,7 @@ export const zUser = z.object({ description: 'The role of the user', title: 'User Role', deprecated: true, - examples: [ + example: [ 'admin', 'user', 'guest' @@ -59,7 +59,7 @@ export const zUser = z.object({ ]).register(z.globalRegistry, { description: 'Current status of the account', title: 'Account Status', - examples: [ + example: [ 'active' ] })) @@ -79,7 +79,7 @@ export const zProduct = z.object({ sku: z.optional(z.string().regex(/^[A-Z]{3}-\d{4}$/).register(z.globalRegistry, { description: 'Product SKU code', title: 'Stock Keeping Unit', - examples: [ + example: [ 'ABC-1234', 'XYZ-9999' ] @@ -87,7 +87,7 @@ export const zProduct = z.object({ price: z.optional(z.number().register(z.globalRegistry, { description: 'Price in USD', title: 'Product Price', - examples: [ + example: [ 19.99, 49.95, 99.99 diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v4/validators-metadata-local/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v4/validators-metadata-local/zod.gen.ts index 8eb8ef578..f7fb04bb0 100644 --- a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v4/validators-metadata-local/zod.gen.ts +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/v4/validators-metadata-local/zod.gen.ts @@ -11,7 +11,7 @@ export const zUser = z.object({ id: z.optional(z.int().meta({ description: 'Unique identifier for the user', title: 'User ID', - examples: [ + example: [ 1, 42, 999 @@ -20,7 +20,7 @@ export const zUser = z.object({ username: z.optional(z.string().meta({ description: 'The user login name', title: 'Username', - examples: [ + example: [ 'john_doe', 'jane_smith' ] @@ -28,7 +28,7 @@ export const zUser = z.object({ email: z.optional(z.email().meta({ description: 'User email address', title: 'Email Address', - examples: [ + example: [ 'user@example.com', 'test@test.org' ] @@ -36,7 +36,7 @@ export const zUser = z.object({ age: z.optional(z.int().meta({ description: 'User age in years', title: 'Age', - examples: [ + example: [ 25, 30, 45 @@ -46,7 +46,7 @@ export const zUser = z.object({ description: 'The role of the user', title: 'User Role', deprecated: true, - examples: [ + example: [ 'admin', 'user', 'guest' @@ -59,7 +59,7 @@ export const zUser = z.object({ ]).meta({ description: 'Current status of the account', title: 'Account Status', - examples: [ + example: [ 'active' ] })) @@ -79,7 +79,7 @@ export const zProduct = z.object({ sku: z.optional(z.string().regex(/^[A-Z]{3}-\d{4}$/).meta({ description: 'Product SKU code', title: 'Stock Keeping Unit', - examples: [ + example: [ 'ABC-1234', 'XYZ-9999' ] @@ -87,7 +87,7 @@ export const zProduct = z.object({ price: z.optional(z.number().meta({ description: 'Price in USD', title: 'Product Price', - examples: [ + example: [ 19.99, 49.95, 99.99 diff --git a/packages/openapi-ts-tests/zod/v4/test/3.0.x.test.ts b/packages/openapi-ts-tests/zod/v4/test/3.0.x.test.ts index 3e255b3bd..060004820 100644 --- a/packages/openapi-ts-tests/zod/v4/test/3.0.x.test.ts +++ b/packages/openapi-ts-tests/zod/v4/test/3.0.x.test.ts @@ -63,6 +63,21 @@ for (const zodVersion of zodVersions) { }), description: 'generates validator schemas', }, + { + config: createConfig({ + input: 'validators-parameter-example.yaml', + output: 'validators-parameter-example', + plugins: [ + { + compatibilityVersion: zodVersion.compatibilityVersion, + metadata: 'local', + name: 'zod', + }, + ], + }), + description: + 'generates validator schemas with parameter-level examples in metadata', + }, ]; it.each(scenarios)('$description', async ({ config }) => { diff --git a/packages/openapi-ts/src/openApi/3.0.x/parser/parameter.ts b/packages/openapi-ts/src/openApi/3.0.x/parser/parameter.ts index 82b3ee383..06997fcbe 100644 --- a/packages/openapi-ts/src/openApi/3.0.x/parser/parameter.ts +++ b/packages/openapi-ts/src/openApi/3.0.x/parser/parameter.ts @@ -3,6 +3,7 @@ import type { IR } from '~/ir/types'; import { refToName } from '~/utils/ref'; import type { + ExampleObject, ParameterObject, ReferenceObject, SchemaObject, @@ -114,17 +115,42 @@ const parameterToIrParameter = ({ } } - const finalSchema: SchemaObject = + // Handle parameter-level examples (Record) -> extract values for IR schema + // Note: OpenAPI 3.0.x SchemaObject doesn't have 'examples' property, but we can + // pass it to the IR schema parser which will handle it + let parameterExamples: ReadonlyArray | undefined; + if (parameter.examples) { + // Extract values from ExampleObject Record + parameterExamples = Object.values(parameter.examples) + .map((exampleObj) => { + if ('$ref' in exampleObj) { + const dereferenced = context.dereference(exampleObj); + return dereferenced.value; + } + return exampleObj.value; + }) + .filter((val) => val !== undefined); + } + + const finalSchema: SchemaObject & { examples?: ReadonlyArray } = schema && '$ref' in schema ? { allOf: [{ ...schema }], deprecated: parameter.deprecated, description: parameter.description, + example: parameter.example, + examples: parameterExamples, } : { deprecated: parameter.deprecated, description: parameter.description, ...schema, + // Parameter-level example/examples override schema-level ones + example: + parameter.example !== undefined + ? parameter.example + : schema?.example, + examples: parameterExamples, }; const pagination = paginationField({ diff --git a/packages/openapi-ts/src/openApi/3.1.x/parser/parameter.ts b/packages/openapi-ts/src/openApi/3.1.x/parser/parameter.ts index c10c7510f..c26ba4f00 100644 --- a/packages/openapi-ts/src/openApi/3.1.x/parser/parameter.ts +++ b/packages/openapi-ts/src/openApi/3.1.x/parser/parameter.ts @@ -3,6 +3,7 @@ import type { IR } from '~/ir/types'; import { refToName } from '~/utils/ref'; import type { + ExampleObject, ParameterObject, ReferenceObject, SchemaObject, @@ -114,10 +115,32 @@ const parameterToIrParameter = ({ } } + // Handle parameter-level examples (Record) -> convert to array + let examplesArray: ReadonlyArray | undefined; + if (parameter.examples) { + // Extract values from ExampleObject Record + examplesArray = Object.values(parameter.examples) + .map((exampleObj) => { + if ('$ref' in exampleObj) { + const dereferenced = context.dereference(exampleObj); + return dereferenced.value; + } + return exampleObj.value; + }) + .filter((val) => val !== undefined); + } + const finalSchema: SchemaObject = { deprecated: parameter.deprecated, description: parameter.description, ...schema, + // Parameter-level example/examples override schema-level ones + example: + parameter.example !== undefined ? parameter.example : schema?.example, + examples: + examplesArray !== undefined && examplesArray.length > 0 + ? examplesArray + : schema?.examples, }; const pagination = paginationField({ diff --git a/packages/openapi-ts/src/plugins/zod/v4/plugin.ts b/packages/openapi-ts/src/plugins/zod/v4/plugin.ts index 930e10c0e..b52c4ae97 100644 --- a/packages/openapi-ts/src/plugins/zod/v4/plugin.ts +++ b/packages/openapi-ts/src/plugins/zod/v4/plugin.ts @@ -92,7 +92,7 @@ export const irSchemaToAst = ({ (schema.example !== undefined ? [schema.example] : undefined); if (examplesArray) { metadataObj.prop( - 'examples', + 'example', $.array() .pretty() .elements(...examplesArray.map((ex) => $.literal(ex))), diff --git a/specs/3.0.x/validators-parameter-example.yaml b/specs/3.0.x/validators-parameter-example.yaml new file mode 100644 index 000000000..573700121 --- /dev/null +++ b/specs/3.0.x/validators-parameter-example.yaml @@ -0,0 +1,55 @@ +openapi: 3.0.3 +info: + title: OpenAPI 3.0 validators with parameter-level examples + version: '1' +paths: + /users: + get: + parameters: + - name: sort + in: query + description: 'Sort order for results' + schema: + type: string + example: 'name,desc' + - name: filter + in: query + description: 'Filter criteria' + required: false + schema: + type: string + description: 'This description should be overridden' + example: 'thisExampleShouldBeOverridden' + example: 'status:active' + - name: limit + in: query + description: 'Number of results per page' + schema: + type: integer + minimum: 1 + maximum: 100 + example: 25 + - name: search + in: query + description: 'Search query' + schema: + type: string + example: 'john doe' + responses: + '200': + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' +components: + schemas: + User: + type: object + properties: + id: + type: integer + name: + type: string From 0db3765c72d838f48e48d707eacaf7455e9ec23d Mon Sep 17 00:00:00 2001 From: Samuel Will Date: Mon, 24 Nov 2025 15:32:03 +0200 Subject: [PATCH 3/6] chore: update documentation --- docs/openapi-ts/plugins/zod.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/openapi-ts/plugins/zod.md b/docs/openapi-ts/plugins/zod.md index ff0b9dc45..a87c2a677 100644 --- a/docs/openapi-ts/plugins/zod.md +++ b/docs/openapi-ts/plugins/zod.md @@ -240,6 +240,8 @@ export default { It's often useful to associate a schema with some additional [metadata](https://zod.dev/metadata) for documentation, code generation, AI structured outputs, form validation, and other purposes. If this is your use case, you can set `metadata` to `true` to generate additional metadata about schemas. +> If you wish to generate metadata for individual parameters with the zod v4 api, you can set `metadata` to `'local'` to use the `.meta()` method. + ::: code-group ```ts [example] From 7c7137a28177ac2c262829adbf9229d8501fd883 Mon Sep 17 00:00:00 2001 From: Samuel Will Date: Mon, 24 Nov 2025 15:55:05 +0200 Subject: [PATCH 4/6] fix!: rearrange order of parameters This is a somewhat breaking change. Previously, the deprecated and description field have been overwritten by the schema property of parameters. This leads to parameters that were marked as deprecated on the parameter-level to not be marked as deprecated in the generated output IF the schema was not marked as deprecated. Local configuration should overwrite schema configuration. This is a breaking change, as it introduces different (valid) documentation compared to how it was handled previously. The general runtime of outputs should not be affected. --- .../openapi-ts/src/openApi/3.0.x/parser/parameter.ts | 11 ++++++++--- .../openapi-ts/src/openApi/3.1.x/parser/parameter.ts | 11 ++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/openapi-ts/src/openApi/3.0.x/parser/parameter.ts b/packages/openapi-ts/src/openApi/3.0.x/parser/parameter.ts index 06997fcbe..b3901ad28 100644 --- a/packages/openapi-ts/src/openApi/3.0.x/parser/parameter.ts +++ b/packages/openapi-ts/src/openApi/3.0.x/parser/parameter.ts @@ -142,10 +142,15 @@ const parameterToIrParameter = ({ examples: parameterExamples, } : { - deprecated: parameter.deprecated, - description: parameter.description, ...schema, - // Parameter-level example/examples override schema-level ones + deprecated: + parameter.deprecated !== undefined + ? parameter.deprecated + : schema?.deprecated, + description: + parameter.description !== undefined + ? parameter.description + : schema?.description, example: parameter.example !== undefined ? parameter.example diff --git a/packages/openapi-ts/src/openApi/3.1.x/parser/parameter.ts b/packages/openapi-ts/src/openApi/3.1.x/parser/parameter.ts index c26ba4f00..2d7f2f525 100644 --- a/packages/openapi-ts/src/openApi/3.1.x/parser/parameter.ts +++ b/packages/openapi-ts/src/openApi/3.1.x/parser/parameter.ts @@ -131,10 +131,15 @@ const parameterToIrParameter = ({ } const finalSchema: SchemaObject = { - deprecated: parameter.deprecated, - description: parameter.description, ...schema, - // Parameter-level example/examples override schema-level ones + deprecated: + parameter.deprecated !== undefined + ? parameter.deprecated + : schema?.deprecated, + description: + parameter.description !== undefined + ? parameter.description + : schema?.description, example: parameter.example !== undefined ? parameter.example : schema?.example, examples: From 2ec4ef40c45230a70e09f258ae4050e19d1938bf Mon Sep 17 00:00:00 2001 From: Samuel Will Date: Mon, 24 Nov 2025 19:56:31 +0200 Subject: [PATCH 5/6] fix: parameter description and example value parsing There has been an issue that incorrecly parses schema-level descriptions instead of parameter level ones and example values --- .../validators-parameter-example/zod.gen.ts | 20 ++++++-- .../validators-parameter-example/zod.gen.ts | 2 +- .../validators-parameter-example/zod.gen.ts | 2 +- .../validators-metadata-global/zod.gen.ts | 51 ++++++++++++++++--- .../mini/validators-metadata-local/zod.gen.ts | 51 ++++++++++++++++--- .../src/openApi/3.0.x/parser/parameter.ts | 12 ++--- .../src/openApi/3.0.x/parser/schema.ts | 5 ++ .../src/openApi/3.1.x/parser/parameter.ts | 8 +-- .../openapi-ts/src/plugins/zod/mini/plugin.ts | 44 ++++++++++++---- 9 files changed, 155 insertions(+), 40 deletions(-) diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/mini/validators-parameter-example/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/mini/validators-parameter-example/zod.gen.ts index 5a6481aff..0f2e86a7f 100644 --- a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/mini/validators-parameter-example/zod.gen.ts +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/mini/validators-parameter-example/zod.gen.ts @@ -12,16 +12,28 @@ export const zGetUsersData = z.object({ path: z.optional(z.never()), query: z.optional(z.object({ sort: z.optional(z.string().register(z.globalRegistry, { - description: 'Sort order for results' + description: 'Sort order for results', + example: [ + 'name,desc' + ] })), filter: z.optional(z.string().register(z.globalRegistry, { - description: 'This description should be overridden' + description: 'Filter criteria', + example: [ + 'status:active' + ] })), limit: z.optional(z.int().check(z.gte(1), z.lte(100)).register(z.globalRegistry, { - description: 'Number of results per page' + description: 'Number of results per page', + example: [ + 25 + ] })), search: z.optional(z.string().register(z.globalRegistry, { - description: 'Search query' + description: 'Search query', + example: [ + 'john doe' + ] })) })) }); diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v3/validators-parameter-example/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v3/validators-parameter-example/zod.gen.ts index 8d0cccf38..481e7d5d4 100644 --- a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v3/validators-parameter-example/zod.gen.ts +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v3/validators-parameter-example/zod.gen.ts @@ -12,7 +12,7 @@ export const zGetUsersData = z.object({ path: z.never().optional(), query: z.object({ sort: z.string().describe('Sort order for results').optional(), - filter: z.string().describe('This description should be overridden').optional(), + filter: z.string().describe('Filter criteria').optional(), limit: z.number().int().gte(1).lte(100).describe('Number of results per page').optional(), search: z.string().describe('Search query').optional() }).optional() diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v4/validators-parameter-example/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v4/validators-parameter-example/zod.gen.ts index 3e59af3be..2c39a29ac 100644 --- a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v4/validators-parameter-example/zod.gen.ts +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/v4/validators-parameter-example/zod.gen.ts @@ -18,7 +18,7 @@ export const zGetUsersData = z.object({ ] })), filter: z.optional(z.string().meta({ - description: 'This description should be overridden', + description: 'Filter criteria', example: [ 'status:active' ] diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/mini/validators-metadata-global/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/mini/validators-metadata-global/zod.gen.ts index 9b31796da..7111ae37f 100644 --- a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/mini/validators-metadata-global/zod.gen.ts +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/mini/validators-metadata-global/zod.gen.ts @@ -9,26 +9,52 @@ import * as z from 'zod/mini'; */ export const zUser = z.object({ id: z.optional(z.int().register(z.globalRegistry, { - description: 'Unique identifier for the user' + description: 'Unique identifier for the user', + example: [ + 1, + 42, + 999 + ] })), username: z.optional(z.string().register(z.globalRegistry, { - description: 'The user login name' + description: 'The user login name', + example: [ + 'john_doe', + 'jane_smith' + ] })), email: z.optional(z.email().register(z.globalRegistry, { - description: 'User email address' + description: 'User email address', + example: [ + 'user@example.com', + 'test@test.org' + ] })), age: z.optional(z.int().register(z.globalRegistry, { - description: 'User age in years' + description: 'User age in years', + example: [ + 25, + 30, + 45 + ] })), role: z.optional(z.string().register(z.globalRegistry, { - description: 'The role of the user' + description: 'The role of the user', + example: [ + 'admin', + 'user', + 'guest' + ] })), status: z.optional(z.enum([ 'active', 'inactive', 'suspended' ]).register(z.globalRegistry, { - description: 'Current status of the account' + description: 'Current status of the account', + example: [ + 'active' + ] })) }).register(z.globalRegistry, { description: 'A user in the system.' @@ -43,10 +69,19 @@ export const zUser = z.object({ */ export const zProduct = z.object({ sku: z.optional(z.string().check(z.regex(/^[A-Z]{3}-\d{4}$/)).register(z.globalRegistry, { - description: 'Product SKU code' + description: 'Product SKU code', + example: [ + 'ABC-1234', + 'XYZ-9999' + ] })), price: z.optional(z.number().register(z.globalRegistry, { - description: 'Price in USD' + description: 'Price in USD', + example: [ + 19.99, + 49.95, + 99.99 + ] })) }).register(z.globalRegistry, { description: 'A product in the catalog' diff --git a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/mini/validators-metadata-local/zod.gen.ts b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/mini/validators-metadata-local/zod.gen.ts index 9b31796da..7111ae37f 100644 --- a/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/mini/validators-metadata-local/zod.gen.ts +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/mini/validators-metadata-local/zod.gen.ts @@ -9,26 +9,52 @@ import * as z from 'zod/mini'; */ export const zUser = z.object({ id: z.optional(z.int().register(z.globalRegistry, { - description: 'Unique identifier for the user' + description: 'Unique identifier for the user', + example: [ + 1, + 42, + 999 + ] })), username: z.optional(z.string().register(z.globalRegistry, { - description: 'The user login name' + description: 'The user login name', + example: [ + 'john_doe', + 'jane_smith' + ] })), email: z.optional(z.email().register(z.globalRegistry, { - description: 'User email address' + description: 'User email address', + example: [ + 'user@example.com', + 'test@test.org' + ] })), age: z.optional(z.int().register(z.globalRegistry, { - description: 'User age in years' + description: 'User age in years', + example: [ + 25, + 30, + 45 + ] })), role: z.optional(z.string().register(z.globalRegistry, { - description: 'The role of the user' + description: 'The role of the user', + example: [ + 'admin', + 'user', + 'guest' + ] })), status: z.optional(z.enum([ 'active', 'inactive', 'suspended' ]).register(z.globalRegistry, { - description: 'Current status of the account' + description: 'Current status of the account', + example: [ + 'active' + ] })) }).register(z.globalRegistry, { description: 'A user in the system.' @@ -43,10 +69,19 @@ export const zUser = z.object({ */ export const zProduct = z.object({ sku: z.optional(z.string().check(z.regex(/^[A-Z]{3}-\d{4}$/)).register(z.globalRegistry, { - description: 'Product SKU code' + description: 'Product SKU code', + example: [ + 'ABC-1234', + 'XYZ-9999' + ] })), price: z.optional(z.number().register(z.globalRegistry, { - description: 'Price in USD' + description: 'Price in USD', + example: [ + 19.99, + 49.95, + 99.99 + ] })) }).register(z.globalRegistry, { description: 'A product in the catalog' diff --git a/packages/openapi-ts/src/openApi/3.0.x/parser/parameter.ts b/packages/openapi-ts/src/openApi/3.0.x/parser/parameter.ts index b3901ad28..dcd41c177 100644 --- a/packages/openapi-ts/src/openApi/3.0.x/parser/parameter.ts +++ b/packages/openapi-ts/src/openApi/3.0.x/parser/parameter.ts @@ -130,6 +130,9 @@ const parameterToIrParameter = ({ return exampleObj.value; }) .filter((val) => val !== undefined); + } else if (parameter.example !== undefined) { + // Convert single example to array + parameterExamples = [parameter.example]; } const finalSchema: SchemaObject & { examples?: ReadonlyArray } = @@ -138,7 +141,6 @@ const parameterToIrParameter = ({ allOf: [{ ...schema }], deprecated: parameter.deprecated, description: parameter.description, - example: parameter.example, examples: parameterExamples, } : { @@ -151,11 +153,9 @@ const parameterToIrParameter = ({ parameter.description !== undefined ? parameter.description : schema?.description, - example: - parameter.example !== undefined - ? parameter.example - : schema?.example, - examples: parameterExamples, + examples: + parameterExamples || + (schema?.example !== undefined ? [schema.example] : undefined), }; const pagination = paginationField({ diff --git a/packages/openapi-ts/src/openApi/3.0.x/parser/schema.ts b/packages/openapi-ts/src/openApi/3.0.x/parser/schema.ts index adac23cc2..5f3e3129b 100644 --- a/packages/openapi-ts/src/openApi/3.0.x/parser/schema.ts +++ b/packages/openapi-ts/src/openApi/3.0.x/parser/schema.ts @@ -43,6 +43,11 @@ const parseSchemaJsDoc = ({ irSchema.example = schema.example; } + // Handle examples array (extended property for parameter-level examples) + if ('examples' in schema && schema.examples) { + irSchema.examples = schema.examples as ReadonlyArray; + } + if (schema.description) { irSchema.description = schema.description; } diff --git a/packages/openapi-ts/src/openApi/3.1.x/parser/parameter.ts b/packages/openapi-ts/src/openApi/3.1.x/parser/parameter.ts index 2d7f2f525..052ba7689 100644 --- a/packages/openapi-ts/src/openApi/3.1.x/parser/parameter.ts +++ b/packages/openapi-ts/src/openApi/3.1.x/parser/parameter.ts @@ -128,6 +128,9 @@ const parameterToIrParameter = ({ return exampleObj.value; }) .filter((val) => val !== undefined); + } else if (parameter.example !== undefined) { + // Convert single example to array + examplesArray = [parameter.example]; } const finalSchema: SchemaObject = { @@ -143,9 +146,8 @@ const parameterToIrParameter = ({ example: parameter.example !== undefined ? parameter.example : schema?.example, examples: - examplesArray !== undefined && examplesArray.length > 0 - ? examplesArray - : schema?.examples, + examplesArray || + (schema?.example !== undefined ? [schema.example] : undefined), }; const pagination = paginationField({ diff --git a/packages/openapi-ts/src/plugins/zod/mini/plugin.ts b/packages/openapi-ts/src/plugins/zod/mini/plugin.ts index 2df4a9ec0..aec2e178d 100644 --- a/packages/openapi-ts/src/plugins/zod/mini/plugin.ts +++ b/packages/openapi-ts/src/plugins/zod/mini/plugin.ts @@ -65,15 +65,41 @@ export const irSchemaToAst = ({ ast.expression = typeAst.expression; ast.hasLazyExpression = typeAst.hasLazyExpression; - if (plugin.config.metadata && schema.description) { - ast.expression = ast.expression - .attr(identifiers.register) - .call( - $(z.placeholder).attr(identifiers.globalRegistry), - $.object() - .pretty() - .prop('description', $.literal(schema.description)), - ); + if (plugin.config.metadata) { + // Build metadata object with all available fields + const metadataObj = $.object().pretty(); + let hasMetadata = false; + + if (schema.description) { + metadataObj.prop('description', $.literal(schema.description)); + hasMetadata = true; + } + + // Handle examples - convert single example to array or use examples array + if (schema.examples || schema.example !== undefined) { + const examplesArray = + schema.examples || + (schema.example !== undefined ? [schema.example] : undefined); + if (examplesArray) { + metadataObj.prop( + 'example', + $.array() + .pretty() + .elements( + ...examplesArray.map((ex) => + $.literal(ex as string | number | boolean | null), + ), + ), + ); + hasMetadata = true; + } + } + + if (hasMetadata) { + ast.expression = ast.expression + .attr(identifiers.register) + .call($(z.placeholder).attr(identifiers.globalRegistry), metadataObj); + } } } else if (schema.items) { schema = deduplicateSchema({ schema }); From 9d1b84892d07a1b1032ae20c3771841c68c9803a Mon Sep 17 00:00:00 2001 From: Samuel Will Date: Tue, 25 Nov 2025 14:35:01 +0200 Subject: [PATCH 6/6] fix: ts error in plugin parsing and add tests for CI To reach required test coverage we need to export some private methods form the parameter files --- .../3.0.x/parser/__tests__/parameter.test.ts | 360 ++++++++++++++++++ .../3.0.x/parser/__tests__/schema.test.ts | 134 +++++++ .../src/openApi/3.0.x/parser/parameter.ts | 2 +- .../3.1.x/parser/__tests__/parameter.test.ts | 343 +++++++++++++++++ .../3.1.x/parser/__tests__/schema.test.ts | 134 +++++++ .../src/openApi/3.1.x/parser/parameter.ts | 2 +- .../openapi-ts/src/plugins/zod/v4/plugin.ts | 6 +- 7 files changed, 978 insertions(+), 3 deletions(-) create mode 100644 packages/openapi-ts/src/openApi/3.0.x/parser/__tests__/parameter.test.ts create mode 100644 packages/openapi-ts/src/openApi/3.0.x/parser/__tests__/schema.test.ts create mode 100644 packages/openapi-ts/src/openApi/3.1.x/parser/__tests__/parameter.test.ts create mode 100644 packages/openapi-ts/src/openApi/3.1.x/parser/__tests__/schema.test.ts diff --git a/packages/openapi-ts/src/openApi/3.0.x/parser/__tests__/parameter.test.ts b/packages/openapi-ts/src/openApi/3.0.x/parser/__tests__/parameter.test.ts new file mode 100644 index 000000000..ea91ab9e8 --- /dev/null +++ b/packages/openapi-ts/src/openApi/3.0.x/parser/__tests__/parameter.test.ts @@ -0,0 +1,360 @@ +import { describe, expect, it } from 'vitest'; + +import type { Context } from '~/ir/context'; +import type { IR } from '~/ir/types'; + +import type { ParameterObject, SchemaObject } from '../../types/spec'; +import { parameterToIrParameter } from '../parameter'; + +describe('parameter', () => { + const createMockContext = (): Context => + ({ + config: { + client: { + name: '@hey-api/client-fetch', + }, + output: { + path: 'test', + }, + parser: { + pagination: { + keywords: ['page', 'offset', 'limit'], + }, + }, + plugins: {}, + }, + dereference: (obj: any) => { + // Simple mock dereference that just returns the object + if ('$ref' in obj && obj.$ref === '#/test/example') { + return { value: 'dereferenced-value' }; + } + return obj; + }, + ir: { + components: { + schemas: {}, + }, + paths: {}, + servers: [], + }, + resolve: () => ({}), + resolveRef: (ref: string) => ({ $ref: ref }), + }) as unknown as Context; + + describe('parameter precedence', () => { + it('should prioritize parameter description over schema description', () => { + const context = createMockContext(); + const parameter: ParameterObject = { + description: 'Parameter description', + in: 'query', + name: 'testParam', + schema: { + description: 'Schema description', + type: 'string', + } as SchemaObject, + }; + + const result = parameterToIrParameter({ + $ref: '#/components/parameters/testParam', + context, + parameter, + }); + + expect(result.schema?.description).toBe('Parameter description'); + }); + + it('should use schema description when parameter description is undefined', () => { + const context = createMockContext(); + const parameter: ParameterObject = { + in: 'query', + name: 'testParam', + schema: { + description: 'Schema description', + type: 'string', + } as SchemaObject, + }; + + const result = parameterToIrParameter({ + $ref: '#/components/parameters/testParam', + context, + parameter, + }); + + expect(result.schema?.description).toBe('Schema description'); + }); + + it('should prioritize parameter deprecated over schema deprecated', () => { + const context = createMockContext(); + const parameter: ParameterObject = { + deprecated: true, + in: 'query', + name: 'testParam', + schema: { + deprecated: false, + type: 'string', + } as SchemaObject, + }; + + const result = parameterToIrParameter({ + $ref: '#/components/parameters/testParam', + context, + parameter, + }); + + expect(result.schema?.deprecated).toBe(true); + }); + + it('should use schema deprecated when parameter deprecated is undefined', () => { + const context = createMockContext(); + const parameter: ParameterObject = { + in: 'query', + name: 'testParam', + schema: { + deprecated: true, + type: 'string', + } as SchemaObject, + }; + + const result = parameterToIrParameter({ + $ref: '#/components/parameters/testParam', + context, + parameter, + }); + + expect(result.schema?.deprecated).toBe(true); + }); + + it('should prioritize parameter example over schema example', () => { + const context = createMockContext(); + const parameter: ParameterObject = { + example: 'parameter-example', + in: 'query', + name: 'testParam', + schema: { + example: 'schema-example', + type: 'string', + } as SchemaObject, + }; + + const result = parameterToIrParameter({ + $ref: '#/components/parameters/testParam', + context, + parameter, + }); + + expect((result.schema as IR.SchemaObject)?.examples).toEqual([ + 'parameter-example', + ]); + }); + }); + + describe('examples extraction', () => { + it('should extract examples from parameter.examples object', () => { + const context = createMockContext(); + const parameter: ParameterObject = { + examples: { + example1: { value: 'value1' }, + example2: { value: 'value2' }, + example3: { value: 123 }, + }, + in: 'query', + name: 'testParam', + schema: { + type: 'string', + } as SchemaObject, + }; + + const result = parameterToIrParameter({ + $ref: '#/components/parameters/testParam', + context, + parameter, + }); + + expect((result.schema as IR.SchemaObject)?.examples).toEqual([ + 'value1', + 'value2', + 123, + ]); + }); + + it('should extract example from parameter.example', () => { + const context = createMockContext(); + const parameter: ParameterObject = { + example: 'single-example', + in: 'query', + name: 'testParam', + schema: { + type: 'string', + } as SchemaObject, + }; + + const result = parameterToIrParameter({ + $ref: '#/components/parameters/testParam', + context, + parameter, + }); + + expect((result.schema as IR.SchemaObject)?.examples).toEqual([ + 'single-example', + ]); + }); + + it('should handle $ref in examples', () => { + const context = createMockContext(); + const parameter: ParameterObject = { + examples: { + refExample: { $ref: '#/test/example' }, + }, + in: 'query', + name: 'testParam', + schema: { + type: 'string', + } as SchemaObject, + }; + + const result = parameterToIrParameter({ + $ref: '#/components/parameters/testParam', + context, + parameter, + }); + + expect((result.schema as IR.SchemaObject)?.examples).toEqual([ + 'dereferenced-value', + ]); + }); + + it('should filter out undefined values from examples', () => { + const context = createMockContext(); + const parameter: ParameterObject = { + examples: { + example1: { value: 'value1' }, + example2: { value: undefined }, + example3: { value: 'value3' }, + }, + in: 'query', + name: 'testParam', + schema: { + type: 'string', + } as SchemaObject, + }; + + const result = parameterToIrParameter({ + $ref: '#/components/parameters/testParam', + context, + parameter, + }); + + expect((result.schema as IR.SchemaObject)?.examples).toEqual([ + 'value1', + 'value3', + ]); + }); + + it('should handle missing examples', () => { + const context = createMockContext(); + const parameter: ParameterObject = { + in: 'query', + name: 'testParam', + schema: { + type: 'string', + } as SchemaObject, + }; + + const result = parameterToIrParameter({ + $ref: '#/components/parameters/testParam', + context, + parameter, + }); + + expect((result.schema as IR.SchemaObject)?.examples).toBeUndefined(); + }); + + it('should use schema example as fallback when no parameter examples', () => { + const context = createMockContext(); + const parameter: ParameterObject = { + in: 'query', + name: 'testParam', + schema: { + example: 'schema-example', + type: 'string', + } as SchemaObject, + }; + + const result = parameterToIrParameter({ + $ref: '#/components/parameters/testParam', + context, + parameter, + }); + + expect((result.schema as IR.SchemaObject)?.examples).toEqual([ + 'schema-example', + ]); + }); + + // Skipping test for schema with $ref - requires more complex mocking + it.skip('should handle schema with $ref and parameter metadata', () => { + const context = createMockContext(); + const parameter: ParameterObject = { + deprecated: true, + description: 'Parameter description', + example: 'param-example', + in: 'query', + name: 'testParam', + schema: { + $ref: '#/components/schemas/TestSchema', + }, + }; + + const result = parameterToIrParameter({ + $ref: '#/components/parameters/testParam', + context, + parameter, + }); + + const schema = result.schema as IR.SchemaObject; + expect(schema?.description).toBe('Parameter description'); + expect(schema?.deprecated).toBe(true); + expect(schema?.examples).toEqual(['param-example']); + }); + }); + + describe('combined attributes', () => { + it('should combine parameter and schema attributes correctly', () => { + const context = createMockContext(); + const parameter: ParameterObject = { + deprecated: false, + description: 'Parameter description', + example: 'param-example', + in: 'query', + name: 'testParam', + required: true, + schema: { + default: 'default-value', + description: 'Should be overridden', + example: 'Should be overridden', + maximum: 100, + minimum: 0, + type: 'number', + } as SchemaObject, + }; + + const result = parameterToIrParameter({ + $ref: '#/components/parameters/testParam', + context, + parameter, + }); + + const schema = result.schema as IR.SchemaObject; + expect(schema?.description).toBe('Parameter description'); + expect(schema?.deprecated).toBe(false); + expect(schema?.examples).toEqual(['param-example']); + expect(schema?.type).toBe('number'); + expect(schema?.default).toBe('default-value'); + expect(schema?.maximum).toBe(100); + expect(schema?.minimum).toBe(0); + expect(result.name).toBe('testParam'); + expect(result.location).toBe('query'); + expect(result.required).toBe(true); + }); + }); +}); diff --git a/packages/openapi-ts/src/openApi/3.0.x/parser/__tests__/schema.test.ts b/packages/openapi-ts/src/openApi/3.0.x/parser/__tests__/schema.test.ts new file mode 100644 index 000000000..587542a10 --- /dev/null +++ b/packages/openapi-ts/src/openApi/3.0.x/parser/__tests__/schema.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; + +import type { Context } from '~/ir/context'; + +import type { SchemaObject } from '../../types/spec'; +import { schemaToIrSchema } from '../schema'; + +describe('schema', () => { + const createMockContext = (): Context => + ({ + config: { + client: { + name: '@hey-api/client-fetch', + }, + output: { + path: 'test', + }, + plugins: {}, + }, + ir: { + components: { + schemas: {}, + }, + paths: {}, + servers: [], + }, + resolve: () => ({}), + }) as unknown as Context; + + describe('examples handling', () => { + it('should parse examples array from schema', () => { + const context = createMockContext(); + const schema: SchemaObject & { examples?: ReadonlyArray } = { + examples: ['example1', 'example2', 123, true], + type: 'string', + }; + + const result = schemaToIrSchema({ + context, + schema, + state: { + $ref: '#/components/schemas/Test', + circularReferenceTracker: new Set(), + }, + }); + + expect(result.examples).toEqual(['example1', 'example2', 123, true]); + }); + + it('should parse single example from schema', () => { + const context = createMockContext(); + const schema: SchemaObject = { + example: 'single-example', + type: 'string', + }; + + const result = schemaToIrSchema({ + context, + schema, + state: { + $ref: '#/components/schemas/Test', + circularReferenceTracker: new Set(), + }, + }); + + expect(result.example).toBe('single-example'); + }); + + it('should handle both example and examples', () => { + const context = createMockContext(); + const schema: SchemaObject & { examples?: ReadonlyArray } = { + example: 'single-example', + examples: ['example1', 'example2'], + type: 'string', + }; + + const result = schemaToIrSchema({ + context, + schema, + state: { + $ref: '#/components/schemas/Test', + circularReferenceTracker: new Set(), + }, + }); + + expect(result.example).toBe('single-example'); + expect(result.examples).toEqual(['example1', 'example2']); + }); + + it('should handle missing examples', () => { + const context = createMockContext(); + const schema: SchemaObject = { + type: 'string', + }; + + const result = schemaToIrSchema({ + context, + schema, + state: { + $ref: '#/components/schemas/Test', + circularReferenceTracker: new Set(), + }, + }); + + expect(result.example).toBeUndefined(); + expect(result.examples).toBeUndefined(); + }); + + it('should handle examples with different types', () => { + const context = createMockContext(); + const schema: SchemaObject & { examples?: ReadonlyArray } = { + examples: ['string', 42, true, null, { nested: 'object' }], + type: 'string', + }; + + const result = schemaToIrSchema({ + context, + schema, + state: { + $ref: '#/components/schemas/Test', + circularReferenceTracker: new Set(), + }, + }); + + expect(result.examples).toEqual([ + 'string', + 42, + true, + null, + { nested: 'object' }, + ]); + }); + }); +}); diff --git a/packages/openapi-ts/src/openApi/3.0.x/parser/parameter.ts b/packages/openapi-ts/src/openApi/3.0.x/parser/parameter.ts index dcd41c177..0bd203b9b 100644 --- a/packages/openapi-ts/src/openApi/3.0.x/parser/parameter.ts +++ b/packages/openapi-ts/src/openApi/3.0.x/parser/parameter.ts @@ -93,7 +93,7 @@ export const parametersArrayToObject = ({ return parametersObject; }; -const parameterToIrParameter = ({ +export const parameterToIrParameter = ({ $ref, context, parameter, diff --git a/packages/openapi-ts/src/openApi/3.1.x/parser/__tests__/parameter.test.ts b/packages/openapi-ts/src/openApi/3.1.x/parser/__tests__/parameter.test.ts new file mode 100644 index 000000000..1f377210a --- /dev/null +++ b/packages/openapi-ts/src/openApi/3.1.x/parser/__tests__/parameter.test.ts @@ -0,0 +1,343 @@ +import { describe, expect, it } from 'vitest'; + +import type { Context } from '~/ir/context'; +import type { IR } from '~/ir/types'; + +import type { ParameterObject, SchemaObject } from '../../types/spec'; +import { parameterToIrParameter } from '../parameter'; + +describe('parameter', () => { + const createMockContext = (): Context => + ({ + config: { + client: { + name: '@hey-api/client-fetch', + }, + output: { + path: 'test', + }, + parser: { + pagination: { + keywords: ['page', 'offset', 'limit'], + }, + }, + plugins: {}, + }, + dereference: (obj: any) => { + // Simple mock dereference that just returns the object + if ('$ref' in obj && obj.$ref === '#/test/example') { + return { value: 'dereferenced-value' }; + } + return obj; + }, + ir: { + components: { + schemas: {}, + }, + paths: {}, + servers: [], + }, + resolve: () => ({}), + }) as unknown as Context; + + describe('parameter precedence', () => { + it('should prioritize parameter description over schema description', () => { + const context = createMockContext(); + const parameter: ParameterObject = { + description: 'Parameter description', + in: 'query', + name: 'testParam', + schema: { + description: 'Schema description', + type: 'string', + } as SchemaObject, + }; + + const result = parameterToIrParameter({ + $ref: '#/components/parameters/testParam', + context, + parameter, + }); + + expect(result.schema?.description).toBe('Parameter description'); + }); + + it('should use schema description when parameter description is undefined', () => { + const context = createMockContext(); + const parameter: ParameterObject = { + in: 'query', + name: 'testParam', + schema: { + description: 'Schema description', + type: 'string', + } as SchemaObject, + }; + + const result = parameterToIrParameter({ + $ref: '#/components/parameters/testParam', + context, + parameter, + }); + + expect(result.schema?.description).toBe('Schema description'); + }); + + it('should prioritize parameter deprecated over schema deprecated', () => { + const context = createMockContext(); + const parameter: ParameterObject = { + deprecated: true, + in: 'query', + name: 'testParam', + schema: { + deprecated: false, + type: 'string', + } as SchemaObject, + }; + + const result = parameterToIrParameter({ + $ref: '#/components/parameters/testParam', + context, + parameter, + }); + + expect(result.schema?.deprecated).toBe(true); + }); + + it('should use schema deprecated when parameter deprecated is undefined', () => { + const context = createMockContext(); + const parameter: ParameterObject = { + in: 'query', + name: 'testParam', + schema: { + deprecated: true, + type: 'string', + } as SchemaObject, + }; + + const result = parameterToIrParameter({ + $ref: '#/components/parameters/testParam', + context, + parameter, + }); + + expect(result.schema?.deprecated).toBe(true); + }); + + it('should prioritize parameter example over schema example', () => { + const context = createMockContext(); + const parameter: ParameterObject = { + example: 'parameter-example', + in: 'query', + name: 'testParam', + schema: { + example: 'schema-example', + type: 'string', + } as SchemaObject, + }; + + const result = parameterToIrParameter({ + $ref: '#/components/parameters/testParam', + context, + parameter, + }); + + expect((result.schema as IR.SchemaObject)?.examples).toEqual([ + 'parameter-example', + ]); + expect((result.schema as IR.SchemaObject)?.example).toBe( + 'parameter-example', + ); + }); + }); + + describe('examples extraction', () => { + it('should extract examples from parameter.examples object', () => { + const context = createMockContext(); + const parameter: ParameterObject = { + examples: { + example1: { value: 'value1' }, + example2: { value: 'value2' }, + example3: { value: 123 }, + }, + in: 'query', + name: 'testParam', + schema: { + type: 'string', + } as SchemaObject, + }; + + const result = parameterToIrParameter({ + $ref: '#/components/parameters/testParam', + context, + parameter, + }); + + expect((result.schema as IR.SchemaObject)?.examples).toEqual([ + 'value1', + 'value2', + 123, + ]); + }); + + it('should extract example from parameter.example', () => { + const context = createMockContext(); + const parameter: ParameterObject = { + example: 'single-example', + in: 'query', + name: 'testParam', + schema: { + type: 'string', + } as SchemaObject, + }; + + const result = parameterToIrParameter({ + $ref: '#/components/parameters/testParam', + context, + parameter, + }); + + expect((result.schema as IR.SchemaObject)?.examples).toEqual([ + 'single-example', + ]); + expect((result.schema as IR.SchemaObject)?.example).toBe( + 'single-example', + ); + }); + + it('should handle $ref in examples', () => { + const context = createMockContext(); + const parameter: ParameterObject = { + examples: { + refExample: { $ref: '#/test/example' }, + }, + in: 'query', + name: 'testParam', + schema: { + type: 'string', + } as SchemaObject, + }; + + const result = parameterToIrParameter({ + $ref: '#/components/parameters/testParam', + context, + parameter, + }); + + expect((result.schema as IR.SchemaObject)?.examples).toEqual([ + 'dereferenced-value', + ]); + }); + + it('should filter out undefined values from examples', () => { + const context = createMockContext(); + const parameter: ParameterObject = { + examples: { + example1: { value: 'value1' }, + example2: { value: undefined }, + example3: { value: 'value3' }, + }, + in: 'query', + name: 'testParam', + schema: { + type: 'string', + } as SchemaObject, + }; + + const result = parameterToIrParameter({ + $ref: '#/components/parameters/testParam', + context, + parameter, + }); + + expect((result.schema as IR.SchemaObject)?.examples).toEqual([ + 'value1', + 'value3', + ]); + }); + + it('should handle missing examples', () => { + const context = createMockContext(); + const parameter: ParameterObject = { + in: 'query', + name: 'testParam', + schema: { + type: 'string', + } as SchemaObject, + }; + + const result = parameterToIrParameter({ + $ref: '#/components/parameters/testParam', + context, + parameter, + }); + + expect((result.schema as IR.SchemaObject)?.examples).toBeUndefined(); + }); + + it('should use schema example as fallback when no parameter examples', () => { + const context = createMockContext(); + const parameter: ParameterObject = { + in: 'query', + name: 'testParam', + schema: { + example: 'schema-example', + type: 'string', + } as SchemaObject, + }; + + const result = parameterToIrParameter({ + $ref: '#/components/parameters/testParam', + context, + parameter, + }); + + expect((result.schema as IR.SchemaObject)?.examples).toEqual([ + 'schema-example', + ]); + expect((result.schema as IR.SchemaObject)?.example).toBe( + 'schema-example', + ); + }); + }); + + describe('combined attributes', () => { + it('should combine parameter and schema attributes correctly', () => { + const context = createMockContext(); + const parameter: ParameterObject = { + deprecated: false, + description: 'Parameter description', + example: 'param-example', + in: 'query', + name: 'testParam', + required: true, + schema: { + default: 'default-value', + description: 'Should be overridden', + example: 'Should be overridden', + maximum: 100, + minimum: 0, + type: 'number', + } as SchemaObject, + }; + + const result = parameterToIrParameter({ + $ref: '#/components/parameters/testParam', + context, + parameter, + }); + + const schema = result.schema as IR.SchemaObject; + expect(schema?.description).toBe('Parameter description'); + expect(schema?.deprecated).toBe(false); + expect(schema?.examples).toEqual(['param-example']); + expect(schema?.example).toBe('param-example'); + expect(schema?.type).toBe('number'); + expect(schema?.default).toBe('default-value'); + expect(schema?.maximum).toBe(100); + expect(schema?.minimum).toBe(0); + expect(result.name).toBe('testParam'); + expect(result.location).toBe('query'); + expect(result.required).toBe(true); + }); + }); +}); diff --git a/packages/openapi-ts/src/openApi/3.1.x/parser/__tests__/schema.test.ts b/packages/openapi-ts/src/openApi/3.1.x/parser/__tests__/schema.test.ts new file mode 100644 index 000000000..f95831243 --- /dev/null +++ b/packages/openapi-ts/src/openApi/3.1.x/parser/__tests__/schema.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; + +import type { Context } from '~/ir/context'; + +import type { SchemaObject } from '../../types/spec'; +import { schemaToIrSchema } from '../schema'; + +describe('schema', () => { + const createMockContext = (): Context => + ({ + config: { + client: { + name: '@hey-api/client-fetch', + }, + output: { + path: 'test', + }, + plugins: {}, + }, + ir: { + components: { + schemas: {}, + }, + paths: {}, + servers: [], + }, + resolve: () => ({}), + }) as unknown as Context; + + describe('examples handling', () => { + it('should parse examples array from schema', () => { + const context = createMockContext(); + const schema: SchemaObject = { + examples: ['example1', 'example2', 123, true], + type: 'string', + }; + + const result = schemaToIrSchema({ + context, + schema, + state: { + $ref: '#/components/schemas/Test', + circularReferenceTracker: new Set(), + }, + }); + + expect(result.examples).toEqual(['example1', 'example2', 123, true]); + }); + + it('should parse single example from schema', () => { + const context = createMockContext(); + const schema: SchemaObject = { + example: 'single-example', + type: 'string', + }; + + const result = schemaToIrSchema({ + context, + schema, + state: { + $ref: '#/components/schemas/Test', + circularReferenceTracker: new Set(), + }, + }); + + expect(result.example).toBe('single-example'); + }); + + it('should handle both example and examples', () => { + const context = createMockContext(); + const schema: SchemaObject = { + example: 'single-example', + examples: ['example1', 'example2'], + type: 'string', + }; + + const result = schemaToIrSchema({ + context, + schema, + state: { + $ref: '#/components/schemas/Test', + circularReferenceTracker: new Set(), + }, + }); + + expect(result.example).toBe('single-example'); + expect(result.examples).toEqual(['example1', 'example2']); + }); + + it('should handle missing examples', () => { + const context = createMockContext(); + const schema: SchemaObject = { + type: 'string', + }; + + const result = schemaToIrSchema({ + context, + schema, + state: { + $ref: '#/components/schemas/Test', + circularReferenceTracker: new Set(), + }, + }); + + expect(result.example).toBeUndefined(); + expect(result.examples).toBeUndefined(); + }); + + it('should handle examples with different types', () => { + const context = createMockContext(); + const schema: SchemaObject = { + examples: ['string', 42, true, null, { nested: 'object' }], + type: 'string', + }; + + const result = schemaToIrSchema({ + context, + schema, + state: { + $ref: '#/components/schemas/Test', + circularReferenceTracker: new Set(), + }, + }); + + expect(result.examples).toEqual([ + 'string', + 42, + true, + null, + { nested: 'object' }, + ]); + }); + }); +}); diff --git a/packages/openapi-ts/src/openApi/3.1.x/parser/parameter.ts b/packages/openapi-ts/src/openApi/3.1.x/parser/parameter.ts index 052ba7689..7e6ceaa71 100644 --- a/packages/openapi-ts/src/openApi/3.1.x/parser/parameter.ts +++ b/packages/openapi-ts/src/openApi/3.1.x/parser/parameter.ts @@ -93,7 +93,7 @@ export const parametersArrayToObject = ({ return parametersObject; }; -const parameterToIrParameter = ({ +export const parameterToIrParameter = ({ $ref, context, parameter, diff --git a/packages/openapi-ts/src/plugins/zod/v4/plugin.ts b/packages/openapi-ts/src/plugins/zod/v4/plugin.ts index b52c4ae97..ef21760fa 100644 --- a/packages/openapi-ts/src/plugins/zod/v4/plugin.ts +++ b/packages/openapi-ts/src/plugins/zod/v4/plugin.ts @@ -95,7 +95,11 @@ export const irSchemaToAst = ({ 'example', $.array() .pretty() - .elements(...examplesArray.map((ex) => $.literal(ex))), + .elements( + ...examplesArray.map((ex) => + $.literal(ex as string | number | boolean | null), + ), + ), ); hasMetadata = true; }