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] 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..0f2e86a7f --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.0.x/mini/validators-parameter-example/zod.gen.ts @@ -0,0 +1,46 @@ +// 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', + example: [ + 'name,desc' + ] + })), + filter: z.optional(z.string().register(z.globalRegistry, { + 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', + example: [ + 25 + ] + })), + search: z.optional(z.string().register(z.globalRegistry, { + description: 'Search query', + example: [ + 'john doe' + ] + })) + })) +}); + +/** + * 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..481e7d5d4 --- /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('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() +}); + +/** + * 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..2c39a29ac --- /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: 'Filter criteria', + 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/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..7111ae37f --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/mini/validators-metadata-global/zod.gen.ts @@ -0,0 +1,94 @@ +// 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', + example: [ + 1, + 42, + 999 + ] + })), + username: z.optional(z.string().register(z.globalRegistry, { + description: 'The user login name', + example: [ + 'john_doe', + 'jane_smith' + ] + })), + email: z.optional(z.email().register(z.globalRegistry, { + description: 'User email address', + example: [ + 'user@example.com', + 'test@test.org' + ] + })), + age: z.optional(z.int().register(z.globalRegistry, { + description: 'User age in years', + example: [ + 25, + 30, + 45 + ] + })), + role: z.optional(z.string().register(z.globalRegistry, { + 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', + example: [ + 'active' + ] + })) +}).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', + example: [ + 'ABC-1234', + 'XYZ-9999' + ] + })), + price: z.optional(z.number().register(z.globalRegistry, { + description: 'Price in USD', + example: [ + 19.99, + 49.95, + 99.99 + ] + })) +}).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..7111ae37f --- /dev/null +++ b/packages/openapi-ts-tests/zod/v4/__snapshots__/3.1.x/mini/validators-metadata-local/zod.gen.ts @@ -0,0 +1,94 @@ +// 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', + example: [ + 1, + 42, + 999 + ] + })), + username: z.optional(z.string().register(z.globalRegistry, { + description: 'The user login name', + example: [ + 'john_doe', + 'jane_smith' + ] + })), + email: z.optional(z.email().register(z.globalRegistry, { + description: 'User email address', + example: [ + 'user@example.com', + 'test@test.org' + ] + })), + age: z.optional(z.int().register(z.globalRegistry, { + description: 'User age in years', + example: [ + 25, + 30, + 45 + ] + })), + role: z.optional(z.string().register(z.globalRegistry, { + 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', + example: [ + 'active' + ] + })) +}).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', + example: [ + 'ABC-1234', + 'XYZ-9999' + ] + })), + price: z.optional(z.number().register(z.globalRegistry, { + description: 'Price in USD', + example: [ + 19.99, + 49.95, + 99.99 + ] + })) +}).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..c13f52a35 --- /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', + example: [ + 1, + 42, + 999 + ] + })), + username: z.optional(z.string().register(z.globalRegistry, { + description: 'The user login name', + title: 'Username', + example: [ + 'john_doe', + 'jane_smith' + ] + })), + email: z.optional(z.email().register(z.globalRegistry, { + description: 'User email address', + title: 'Email Address', + example: [ + 'user@example.com', + 'test@test.org' + ] + })), + age: z.optional(z.int().register(z.globalRegistry, { + description: 'User age in years', + title: 'Age', + example: [ + 25, + 30, + 45 + ] + })), + role: z.optional(z.string().register(z.globalRegistry, { + description: 'The role of the user', + title: 'User Role', + deprecated: true, + example: [ + 'admin', + 'user', + 'guest' + ] + })), + status: z.optional(z.enum([ + 'active', + 'inactive', + 'suspended' + ]).register(z.globalRegistry, { + description: 'Current status of the account', + title: 'Account Status', + example: [ + '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', + example: [ + 'ABC-1234', + 'XYZ-9999' + ] + })), + price: z.optional(z.number().register(z.globalRegistry, { + description: 'Price in USD', + title: 'Product Price', + example: [ + 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..f7fb04bb0 --- /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', + example: [ + 1, + 42, + 999 + ] + })), + username: z.optional(z.string().meta({ + description: 'The user login name', + title: 'Username', + example: [ + 'john_doe', + 'jane_smith' + ] + })), + email: z.optional(z.email().meta({ + description: 'User email address', + title: 'Email Address', + example: [ + 'user@example.com', + 'test@test.org' + ] + })), + age: z.optional(z.int().meta({ + description: 'User age in years', + title: 'Age', + example: [ + 25, + 30, + 45 + ] + })), + role: z.optional(z.string().meta({ + description: 'The role of the user', + title: 'User Role', + deprecated: true, + example: [ + 'admin', + 'user', + 'guest' + ] + })), + status: z.optional(z.enum([ + 'active', + 'inactive', + 'suspended' + ]).meta({ + description: 'Current status of the account', + title: 'Account Status', + example: [ + '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', + example: [ + 'ABC-1234', + 'XYZ-9999' + ] + })), + price: z.optional(z.number().meta({ + description: 'Price in USD', + title: 'Product Price', + example: [ + 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.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-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.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 82b3ee383..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 @@ -3,6 +3,7 @@ import type { IR } from '~/ir/types'; import { refToName } from '~/utils/ref'; import type { + ExampleObject, ParameterObject, ReferenceObject, SchemaObject, @@ -92,7 +93,7 @@ export const parametersArrayToObject = ({ return parametersObject; }; -const parameterToIrParameter = ({ +export const parameterToIrParameter = ({ $ref, context, parameter, @@ -114,17 +115,47 @@ 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); + } else if (parameter.example !== undefined) { + // Convert single example to array + parameterExamples = [parameter.example]; + } + + const finalSchema: SchemaObject & { examples?: ReadonlyArray } = schema && '$ref' in schema ? { allOf: [{ ...schema }], deprecated: parameter.deprecated, description: parameter.description, + examples: parameterExamples, } : { - deprecated: parameter.deprecated, - description: parameter.description, ...schema, + deprecated: + parameter.deprecated !== undefined + ? parameter.deprecated + : schema?.deprecated, + description: + parameter.description !== undefined + ? parameter.description + : schema?.description, + 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/__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 c10c7510f..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 @@ -3,6 +3,7 @@ import type { IR } from '~/ir/types'; import { refToName } from '~/utils/ref'; import type { + ExampleObject, ParameterObject, ReferenceObject, SchemaObject, @@ -92,7 +93,7 @@ export const parametersArrayToObject = ({ return parametersObject; }; -const parameterToIrParameter = ({ +export const parameterToIrParameter = ({ $ref, context, parameter, @@ -114,10 +115,39 @@ 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); + } else if (parameter.example !== undefined) { + // Convert single example to array + examplesArray = [parameter.example]; + } + const finalSchema: SchemaObject = { - deprecated: parameter.deprecated, - description: parameter.description, ...schema, + 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: + examplesArray || + (schema?.example !== undefined ? [schema.example] : undefined), }; const pagination = paginationField({ 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/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 }); 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..ef21760fa 100644 --- a/packages/openapi-ts/src/plugins/zod/v4/plugin.ts +++ b/packages/openapi-ts/src/plugins/zod/v4/plugin.ts @@ -65,15 +65,62 @@ 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( + 'example', + $.array() + .pretty() + .elements( + ...examplesArray.map((ex) => + $.literal(ex as string | number | boolean | null), + ), + ), + ); + 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.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 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]