Skip to content

Commit 3ae2b77

Browse files
authored
Merge pull request #3497 from hey-api/copilot/expand-metadata-option-zod-valibot
Expand `metadata` option for Zod and Valibot to support builder functions
2 parents d2c3705 + 714698d commit 3ae2b77

13 files changed

Lines changed: 376 additions & 43 deletions

File tree

.changeset/soft-comics-walk.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hey-api/openapi-ts": patch
3+
---
4+
5+
**plugin(zod)**: support function in `metadata` option

.changeset/soft-comics-walks.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@hey-api/openapi-ts": patch
3+
---
4+
5+
**plugin(valibot)**: support function in `metadata` option

packages/openapi-ts-tests/main/test/3.1.x.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -943,6 +943,23 @@ describe(`OpenAPI ${version}`, () => {
943943
}),
944944
description: 'generates validator schemas with metadata',
945945
},
946+
{
947+
config: createConfig({
948+
input: 'validators.yaml',
949+
output: 'validators-metadata-fn',
950+
plugins: [
951+
{
952+
metadata: ({ $, node, schema }) => {
953+
node
954+
.prop('custom', $.literal('value'))
955+
.prop('title', $.literal(schema.description ?? schema.type ?? ''));
956+
},
957+
name: 'valibot',
958+
},
959+
],
960+
}),
961+
description: 'generates validator schemas with metadata function',
962+
},
946963
{
947964
config: createConfig({
948965
input: 'validators.yaml',
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// This file is auto-generated by @hey-api/openapi-ts
2+
3+
import * as v from 'valibot';
4+
5+
export const vBaz = v.optional(v.pipe(v.pipe(v.string(), v.regex(/foo\nbar/)), v.metadata({ custom: 'value', title: 'string' }), v.readonly()), 'baz');
6+
7+
export const vQux = v.pipe(v.record(v.string(), v.pipe(v.object({
8+
qux: v.optional(v.pipe(v.string(), v.metadata({ custom: 'value', title: 'string' })))
9+
}), v.metadata({ custom: 'value', title: 'object' }))), v.metadata({ custom: 'value', title: 'object' }));
10+
11+
/**
12+
* This is Foo schema.
13+
*/
14+
export const vFoo: v.GenericSchema = v.nullish(v.pipe(v.object({
15+
foo: v.optional(v.pipe(v.pipe(v.string(), v.regex(/^\d{3}-\d{2}-\d{4}$/)), v.metadata({ custom: 'value', title: 'This is foo property.' }))),
16+
bar: v.optional(v.lazy(() => vBar)),
17+
baz: v.optional(v.pipe(v.array(v.lazy(() => vFoo)), v.metadata({ custom: 'value', title: 'This is baz property.' }))),
18+
qux: v.optional(v.pipe(v.pipe(v.number(), v.integer(), v.gtValue(0)), v.metadata({ custom: 'value', title: 'This is qux property.' })), 0)
19+
}), v.metadata({ custom: 'value', title: 'object' })), null);
20+
21+
/**
22+
* This is Bar schema.
23+
*/
24+
export const vBar = v.pipe(v.object({
25+
foo: v.optional(vFoo)
26+
}), v.metadata({ custom: 'value', title: 'This is Bar schema.' }));
27+
28+
/**
29+
* This is Foo parameter.
30+
*/
31+
export const vFoo2 = v.pipe(v.string(), v.metadata({ custom: 'value', title: 'This is Foo parameter.' }));
32+
33+
export const vFoo3 = v.pipe(v.object({
34+
foo: v.optional(vBar)
35+
}), v.metadata({ custom: 'value', title: 'object' }));
36+
37+
export const vPatchFooData = v.pipe(v.object({
38+
body: v.pipe(v.object({
39+
foo: v.optional(v.pipe(v.string(), v.metadata({ custom: 'value', title: 'string' })))
40+
}), v.metadata({ custom: 'value', title: 'object' })),
41+
path: v.optional(v.pipe(v.never(), v.metadata({ custom: 'value', title: 'never' }))),
42+
query: v.optional(v.pipe(v.object({
43+
foo: v.optional(v.pipe(v.string(), v.metadata({ custom: 'value', title: 'This is Foo parameter.' }))),
44+
bar: v.optional(vBar),
45+
baz: v.optional(v.pipe(v.object({
46+
baz: v.optional(v.pipe(v.string(), v.metadata({ custom: 'value', title: 'string' })))
47+
}), v.metadata({ custom: 'value', title: 'object' }))),
48+
qux: v.optional(v.pipe(v.pipe(v.string(), v.isoDate()), v.metadata({ custom: 'value', title: 'string' }))),
49+
quux: v.optional(v.pipe(v.pipe(v.string(), v.isoTimestamp()), v.metadata({ custom: 'value', title: 'string' })))
50+
}), v.metadata({ custom: 'value', title: 'object' })))
51+
}), v.metadata({ custom: 'value', title: 'object' }));
52+
53+
export const vPostFooData = v.pipe(v.object({
54+
body: vFoo3,
55+
path: v.optional(v.pipe(v.never(), v.metadata({ custom: 'value', title: 'never' }))),
56+
query: v.optional(v.pipe(v.never(), v.metadata({ custom: 'value', title: 'never' })))
57+
}), v.metadata({ custom: 'value', title: 'object' }));
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// This file is auto-generated by @hey-api/openapi-ts
2+
3+
import * as z from 'zod/mini';
4+
5+
export const zBaz = z._default(z.readonly(z.string().check(z.regex(/foo\nbar/)).register(z.globalRegistry, { custom: 'value', title: 'string' })), 'baz');
6+
7+
export const zQux = z.record(z.string(), z.object({
8+
qux: z.optional(z.string().register(z.globalRegistry, { custom: 'value', title: 'string' }))
9+
}).register(z.globalRegistry, { custom: 'value', title: 'object' })).register(z.globalRegistry, { custom: 'value', title: 'object' });
10+
11+
/**
12+
* This is Foo schema.
13+
*/
14+
export const zFoo = z._default(z.nullable(z.object({
15+
foo: z.optional(z.string().check(z.regex(/^\d{3}-\d{2}-\d{4}$/)).register(z.globalRegistry, { custom: 'value', title: 'This is foo property.' })),
16+
bar: z.optional(z.lazy((): any => zBar)),
17+
baz: z.optional(z.array(z.lazy((): any => zFoo)).register(z.globalRegistry, { custom: 'value', title: 'This is baz property.' })),
18+
qux: z._default(z.optional(z.int().check(z.gt(0)).register(z.globalRegistry, { custom: 'value', title: 'This is qux property.' })), 0)
19+
}).register(z.globalRegistry, { custom: 'value', title: 'object' })), null);
20+
21+
/**
22+
* This is Bar schema.
23+
*/
24+
export const zBar = z.object({
25+
foo: z.optional(zFoo)
26+
}).register(z.globalRegistry, { custom: 'value', title: 'This is Bar schema.' });
27+
28+
/**
29+
* This is Foo parameter.
30+
*/
31+
export const zFoo2 = z.string().register(z.globalRegistry, { custom: 'value', title: 'This is Foo parameter.' });
32+
33+
export const zFoo3 = z.object({
34+
foo: z.optional(zBar)
35+
}).register(z.globalRegistry, { custom: 'value', title: 'object' });
36+
37+
export const zPatchFooData = z.object({
38+
body: z.object({
39+
foo: z.optional(z.string().register(z.globalRegistry, { custom: 'value', title: 'string' }))
40+
}).register(z.globalRegistry, { custom: 'value', title: 'object' }),
41+
path: z.optional(z.never().register(z.globalRegistry, { custom: 'value', title: 'never' })),
42+
query: z.optional(z.object({
43+
foo: z.optional(z.string().register(z.globalRegistry, { custom: 'value', title: 'This is Foo parameter.' })),
44+
bar: z.optional(zBar),
45+
baz: z.optional(z.object({
46+
baz: z.optional(z.string().register(z.globalRegistry, { custom: 'value', title: 'string' }))
47+
}).register(z.globalRegistry, { custom: 'value', title: 'object' })),
48+
qux: z.optional(z.iso.date().register(z.globalRegistry, { custom: 'value', title: 'string' })),
49+
quux: z.optional(z.iso.datetime().register(z.globalRegistry, { custom: 'value', title: 'string' }))
50+
}).register(z.globalRegistry, { custom: 'value', title: 'object' }))
51+
}).register(z.globalRegistry, { custom: 'value', title: 'object' });
52+
53+
export const zPostFooData = z.object({
54+
body: zFoo3,
55+
path: z.optional(z.never().register(z.globalRegistry, { custom: 'value', title: 'never' })),
56+
query: z.optional(z.never().register(z.globalRegistry, { custom: 'value', title: 'never' }))
57+
}).register(z.globalRegistry, { custom: 'value', title: 'object' });
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// This file is auto-generated by @hey-api/openapi-ts
2+
3+
import { z } from 'zod/v3';
4+
5+
export const zBaz = z.string().regex(/foo\nbar/).readonly().default('baz');
6+
7+
export const zQux = z.record(z.object({
8+
qux: z.string().optional()
9+
}));
10+
11+
/**
12+
* This is Foo schema.
13+
*/
14+
export const zFoo: z.ZodTypeAny = z.object({
15+
foo: z.string().regex(/^\d{3}-\d{2}-\d{4}$/).describe('This is foo property.').optional(),
16+
bar: z.lazy(() => zBar).optional(),
17+
baz: z.array(z.lazy(() => zFoo)).describe('This is baz property.').optional(),
18+
qux: z.number().int().gt(0).describe('This is qux property.').optional().default(0)
19+
}).nullable().default(null);
20+
21+
/**
22+
* This is Bar schema.
23+
*/
24+
export const zBar = z.object({
25+
foo: zFoo.optional()
26+
}).describe('This is Bar schema.');
27+
28+
/**
29+
* This is Foo parameter.
30+
*/
31+
export const zFoo2 = z.string().describe('This is Foo parameter.');
32+
33+
export const zFoo3 = z.object({
34+
foo: zBar.optional()
35+
});
36+
37+
export const zPatchFooData = z.object({
38+
body: z.object({
39+
foo: z.string().optional()
40+
}),
41+
path: z.never().optional(),
42+
query: z.object({
43+
foo: z.string().describe('This is Foo parameter.').optional(),
44+
bar: zBar.optional(),
45+
baz: z.object({
46+
baz: z.string().optional()
47+
}).optional(),
48+
qux: z.string().date().optional(),
49+
quux: z.string().datetime().optional()
50+
}).optional()
51+
});
52+
53+
export const zPostFooData = z.object({
54+
body: zFoo3,
55+
path: z.never().optional(),
56+
query: z.never().optional()
57+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// This file is auto-generated by @hey-api/openapi-ts
2+
3+
import * as z from 'zod';
4+
5+
export const zBaz = z.string().regex(/foo\nbar/).register(z.globalRegistry, { custom: 'value', title: 'string' }).readonly().default('baz');
6+
7+
export const zQux = z.record(z.string(), z.object({
8+
qux: z.string().register(z.globalRegistry, { custom: 'value', title: 'string' }).optional()
9+
}).register(z.globalRegistry, { custom: 'value', title: 'object' })).register(z.globalRegistry, { custom: 'value', title: 'object' });
10+
11+
/**
12+
* This is Foo schema.
13+
*/
14+
export const zFoo = z.object({
15+
foo: z.string().regex(/^\d{3}-\d{2}-\d{4}$/).register(z.globalRegistry, { custom: 'value', title: 'This is foo property.' }).optional(),
16+
bar: z.lazy((): any => zBar).optional(),
17+
baz: z.array(z.lazy((): any => zFoo)).register(z.globalRegistry, { custom: 'value', title: 'This is baz property.' }).optional(),
18+
qux: z.int().gt(0).register(z.globalRegistry, { custom: 'value', title: 'This is qux property.' }).optional().default(0)
19+
}).register(z.globalRegistry, { custom: 'value', title: 'object' }).nullable().default(null);
20+
21+
/**
22+
* This is Bar schema.
23+
*/
24+
export const zBar = z.object({
25+
foo: zFoo.optional()
26+
}).register(z.globalRegistry, { custom: 'value', title: 'This is Bar schema.' });
27+
28+
/**
29+
* This is Foo parameter.
30+
*/
31+
export const zFoo2 = z.string().register(z.globalRegistry, { custom: 'value', title: 'This is Foo parameter.' });
32+
33+
export const zFoo3 = z.object({
34+
foo: zBar.optional()
35+
}).register(z.globalRegistry, { custom: 'value', title: 'object' });
36+
37+
export const zPatchFooData = z.object({
38+
body: z.object({
39+
foo: z.string().register(z.globalRegistry, { custom: 'value', title: 'string' }).optional()
40+
}).register(z.globalRegistry, { custom: 'value', title: 'object' }),
41+
path: z.never().register(z.globalRegistry, { custom: 'value', title: 'never' }).optional(),
42+
query: z.object({
43+
foo: z.string().register(z.globalRegistry, { custom: 'value', title: 'This is Foo parameter.' }).optional(),
44+
bar: zBar.optional(),
45+
baz: z.object({
46+
baz: z.string().register(z.globalRegistry, { custom: 'value', title: 'string' }).optional()
47+
}).register(z.globalRegistry, { custom: 'value', title: 'object' }).optional(),
48+
qux: z.iso.date().register(z.globalRegistry, { custom: 'value', title: 'string' }).optional(),
49+
quux: z.iso.datetime().register(z.globalRegistry, { custom: 'value', title: 'string' }).optional()
50+
}).register(z.globalRegistry, { custom: 'value', title: 'object' }).optional()
51+
}).register(z.globalRegistry, { custom: 'value', title: 'object' });
52+
53+
export const zPostFooData = z.object({
54+
body: zFoo3,
55+
path: z.never().register(z.globalRegistry, { custom: 'value', title: 'never' }).optional(),
56+
query: z.never().register(z.globalRegistry, { custom: 'value', title: 'never' }).optional()
57+
}).register(z.globalRegistry, { custom: 'value', title: 'object' });

packages/openapi-ts-tests/zod/v4/test/3.1.x.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,24 @@ for (const zodVersion of zodVersions) {
8585
}),
8686
description: 'generates validator schemas with metadata',
8787
},
88+
{
89+
config: createConfig({
90+
input: 'validators.yaml',
91+
output: 'validators-metadata-fn',
92+
plugins: [
93+
{
94+
compatibilityVersion: zodVersion.compatibilityVersion,
95+
metadata: ({ $, node, schema }) => {
96+
node
97+
.prop('custom', $.literal('value'))
98+
.prop('title', $.literal(schema.description ?? schema.type ?? ''));
99+
},
100+
name: 'zod',
101+
},
102+
],
103+
}),
104+
description: 'generates validator schemas with metadata function',
105+
},
88106
{
89107
config: createConfig({
90108
input: 'validators.yaml',

packages/openapi-ts/src/plugins/valibot/types.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ import type {
22
Casing,
33
DefinePlugin,
44
FeatureToggle,
5+
IR,
56
NameTransformer,
67
NamingOptions,
78
Plugin,
89
} from '@hey-api/shared';
910

11+
import type { $, DollarTsDsl } from '../../ts-dsl';
1012
import type { IApi } from './api';
1113
import type { Resolvers } from './resolvers';
1214

@@ -62,9 +64,22 @@ export type UserConfig = Plugin.Name<'valibot'> &
6264
* with some additional metadata for documentation, code generation, AI
6365
* structured outputs, form validation, and other purposes.
6466
*
67+
* Can be:
68+
* - `boolean`: Shorthand for the default metadata builder. When `true`,
69+
* attaches `{ description }` from the schema (if present) to the
70+
* generated Valibot schema via the metadata action.
71+
* - `function`: Custom metadata builder. Receives `{ $, node, schema }`,
72+
* where `node` is a pre-initialized `$.object()` node. Add properties to
73+
* `node` to populate the metadata object. Return value is ignored; an
74+
* empty `node` skips metadata for that schema.
75+
*
6576
* @default false
6677
*/
67-
metadata?: boolean;
78+
metadata?:
79+
| boolean
80+
| ((
81+
ctx: DollarTsDsl & { node: ReturnType<typeof $.object>; schema: IR.SchemaObject },
82+
) => void);
6883
/**
6984
* Configuration for request-specific Valibot schemas.
7085
*
@@ -184,7 +199,11 @@ export type Config = Plugin.Name<'valibot'> &
184199
/** Configuration for reusable schema definitions. */
185200
definitions: NamingOptions & FeatureToggle;
186201
/** Enable Valibot metadata support? */
187-
metadata: boolean;
202+
metadata:
203+
| boolean
204+
| ((
205+
ctx: DollarTsDsl & { node: ReturnType<typeof $.object>; schema: IR.SchemaObject },
206+
) => void);
188207
/** Configuration for request-specific Valibot schemas. */
189208
requests: NamingOptions & FeatureToggle;
190209
/** Configuration for response-specific Valibot schemas. */

packages/openapi-ts/src/plugins/valibot/v1/walker.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -202,19 +202,26 @@ export function createVisitor(
202202
};
203203
},
204204
postProcess(result, schema, ctx) {
205-
if (ctx.plugin.config.metadata && schema.description) {
206-
const v = ctx.plugin.external('valibot.v');
207-
const metadataExpr = $(v)
208-
.attr(identifiers.actions.metadata)
209-
.call($.object().prop('description', $.literal(schema.description)));
210-
211-
return {
212-
meta: result.meta,
213-
pipes: [...result.pipes, metadataExpr],
214-
};
205+
const metadata = ctx.plugin.config.metadata;
206+
if (!metadata) {
207+
return result;
208+
}
209+
const node = $.object();
210+
if (typeof metadata === 'function') {
211+
metadata({ $, node, schema });
212+
} else if (schema.description) {
213+
node.prop('description', $.literal(schema.description));
215214
}
215+
if (node.isEmpty) {
216+
return result;
217+
}
218+
const v = ctx.plugin.external('valibot.v');
219+
const metadataExpr = $(v).attr(identifiers.actions.metadata).call(node);
216220

217-
return result;
221+
return {
222+
meta: result.meta,
223+
pipes: [...result.pipes, metadataExpr],
224+
};
218225
},
219226
reference($ref, schema, ctx) {
220227
const v = ctx.plugin.external('valibot.v');

0 commit comments

Comments
 (0)