Skip to content

Commit 448994f

Browse files
authored
Merge pull request #2978 from hey-api/feat/validator-resolvers-2
feat: add object base resolvers
2 parents 262425b + ab4edd4 commit 448994f

File tree

8 files changed

+375
-349
lines changed

8 files changed

+375
-349
lines changed

dev/openapi-ts.config.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,12 @@ export default defineConfig(() => {
4141
// 'circular.yaml',
4242
// 'dutchie.json',
4343
// 'invalid',
44-
// 'full.yaml',
44+
'full.yaml',
4545
// 'openai.yaml',
4646
// 'opencode.yaml',
4747
// 'sdk-instance.yaml',
4848
// 'string-with-format.yaml',
49-
'transformers.json',
49+
// 'transformers.json',
5050
// 'type-format.yaml',
5151
// 'validators.yaml',
5252
// 'validators-circular-ref.json',
@@ -396,6 +396,14 @@ export default defineConfig(() => {
396396
},
397397
},
398398
'~resolvers': {
399+
object: {
400+
// base({ $, additional, pipes, shape }) {
401+
// if (additional === undefined) {
402+
// return pipes.push($('v').attr('looseObject').call(shape));
403+
// }
404+
// return;
405+
// },
406+
},
399407
string: {
400408
formats: {
401409
// date: ({ $, pipes }) => pipes.push($('v').attr('isoDateTime').call()),
@@ -407,7 +415,7 @@ export default defineConfig(() => {
407415
{
408416
// case: 'snake_case',
409417
// comments: false,
410-
compatibilityVersion: 3,
418+
compatibilityVersion: 4,
411419
dates: {
412420
// local: true,
413421
// offset: true,
@@ -456,6 +464,15 @@ export default defineConfig(() => {
456464
},
457465
},
458466
'~resolvers': {
467+
object: {
468+
base({ $, additional, shape }) {
469+
if (!additional) {
470+
// return $('z').attr('object').call(shape).attr('passthrough').call()
471+
return $('z').attr('looseObject').call(shape);
472+
}
473+
return;
474+
},
475+
},
459476
string: {
460477
formats: {
461478
// date: ({ $ }) => $('z').attr('date').call(),

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

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import type ts from 'typescript';
2+
13
import type { IR } from '~/ir/types';
24
import type { DefinePlugin, Plugin } from '~/plugins';
3-
import type { CallTsDsl, DollarTsDsl } from '~/ts-dsl';
5+
import type { CallTsDsl, DollarTsDsl, ObjectTsDsl } from '~/ts-dsl';
46
import type { StringCase, StringName } from '~/types/case';
57

68
import type { IApi } from './api';
@@ -317,7 +319,7 @@ export type Config = Plugin.Name<'valibot'> &
317319
};
318320
};
319321

320-
export type FormatResolverArgs = DollarTsDsl & {
322+
type SharedResolverArgs = DollarTsDsl & {
321323
/**
322324
* The current builder state being processed by this resolver.
323325
*
@@ -330,10 +332,42 @@ export type FormatResolverArgs = DollarTsDsl & {
330332
*/
331333
pipes: Array<CallTsDsl>;
332334
plugin: ValibotPlugin['Instance'];
335+
};
336+
337+
export type FormatResolverArgs = SharedResolverArgs & {
338+
schema: IR.SchemaObject;
339+
};
340+
341+
export type ObjectBaseResolverArgs = SharedResolverArgs & {
342+
/** Null = never */
343+
additional?: ts.Expression | null;
333344
schema: IR.SchemaObject;
345+
shape: ObjectTsDsl;
334346
};
335347

336348
type Resolvers = Plugin.Resolvers<{
349+
/**
350+
* Resolvers for object schemas.
351+
*
352+
* Allows customization of how object types are rendered.
353+
*
354+
* Example path: `~resolvers.object.base`
355+
*
356+
* Returning `undefined` from a resolver will apply the default
357+
* generation behavior for the object schema.
358+
*/
359+
object?: {
360+
/**
361+
* Controls how object schemas are constructed.
362+
*
363+
* Called with the fully assembled shape (properties) and any additional
364+
* property schema, allowing the resolver to choose the correct Valibot
365+
* base constructor and modify the schema chain if needed.
366+
*
367+
* Returning `undefined` will execute the default resolver logic.
368+
*/
369+
base?: (args: ObjectBaseResolverArgs) => CallTsDsl | undefined;
370+
};
337371
/**
338372
* Resolvers for string schemas.
339373
*
Lines changed: 79 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,60 @@
1-
import ts from 'typescript';
1+
import type ts from 'typescript';
22

33
import type { SchemaWithType } from '~/plugins';
44
import { toRef } from '~/plugins/shared/utils/refs';
5-
import { tsc } from '~/tsc';
6-
import { numberRegExp } from '~/utils/regexp';
5+
import { $, type CallTsDsl } from '~/ts-dsl';
76

87
import { pipesToAst } from '../../shared/pipesToAst';
98
import type { Ast, IrSchemaToAstOptions } from '../../shared/types';
9+
import type { ObjectBaseResolverArgs } from '../../types';
1010
import { identifiers } from '../constants';
1111
import { irSchemaToAst } from '../plugin';
1212

13+
function defaultObjectBaseResolver({
14+
additional,
15+
pipes,
16+
plugin,
17+
shape,
18+
}: ObjectBaseResolverArgs): number {
19+
const v = plugin.referenceSymbol({
20+
category: 'external',
21+
resource: 'valibot.v',
22+
});
23+
24+
// Handle `additionalProperties: { type: 'never' }` → v.strictObject()
25+
if (additional === null) {
26+
return pipes.push(
27+
$(v.placeholder).attr(identifiers.schemas.strictObject).call(shape),
28+
);
29+
}
30+
31+
// Handle additionalProperties as schema → v.record() or v.objectWithRest()
32+
if (additional) {
33+
if (shape.isEmpty) {
34+
return pipes.push(
35+
$(v.placeholder)
36+
.attr(identifiers.schemas.record)
37+
.call(
38+
$(v.placeholder).attr(identifiers.schemas.string).call(),
39+
additional,
40+
),
41+
);
42+
}
43+
44+
// If there are named properties, use v.objectWithRest() to validate both
45+
return pipes.push(
46+
$(v.placeholder)
47+
.attr(identifiers.schemas.objectWithRest)
48+
.call(shape, additional),
49+
);
50+
}
51+
52+
// Default case → v.object()
53+
return pipes.push(
54+
$(v.placeholder).attr(identifiers.schemas.object).call(shape),
55+
);
56+
}
57+
1358
export const objectToAst = ({
1459
plugin,
1560
schema,
@@ -18,10 +63,11 @@ export const objectToAst = ({
1863
schema: SchemaWithType<'object'>;
1964
}): Omit<Ast, 'typeName'> => {
2065
const result: Partial<Omit<Ast, 'typeName'>> = {};
66+
const pipes: Array<CallTsDsl> = [];
2167

2268
// TODO: parser - handle constants
23-
const properties: Array<ts.PropertyAssignment> = [];
2469

70+
const shape = $.object().pretty();
2571
const required = schema.required ?? [];
2672

2773
for (const name in schema.properties) {
@@ -37,130 +83,40 @@ export const objectToAst = ({
3783
path: toRef([...state.path.value, 'properties', name]),
3884
},
3985
});
40-
if (propertyAst.hasLazyExpression) {
41-
result.hasLazyExpression = true;
42-
}
86+
if (propertyAst.hasLazyExpression) result.hasLazyExpression = true;
4387

44-
numberRegExp.lastIndex = 0;
45-
let propertyName;
46-
if (numberRegExp.test(name)) {
47-
// For numeric literals, we'll handle negative numbers by using a string literal
48-
// instead of trying to use a PrefixUnaryExpression
49-
propertyName = name.startsWith('-')
50-
? ts.factory.createStringLiteral(name)
51-
: ts.factory.createNumericLiteral(name);
52-
} else {
53-
propertyName = name;
54-
}
55-
// TODO: parser - abstract safe property name logic
56-
if (
57-
((name.match(/^[0-9]/) && name.match(/\D+/g)) || name.match(/\W/g)) &&
58-
!name.startsWith("'") &&
59-
!name.endsWith("'")
60-
) {
61-
propertyName = `'${name}'`;
62-
}
63-
properties.push(
64-
tsc.propertyAssignment({
65-
initializer: pipesToAst({ pipes: propertyAst.pipes, plugin }),
66-
name: propertyName,
67-
}),
68-
);
69-
}
70-
71-
const v = plugin.referenceSymbol({
72-
category: 'external',
73-
resource: 'valibot.v',
74-
});
75-
76-
// Handle additionalProperties: false (which becomes type: 'never' in IR)
77-
// Use v.strictObject() to forbid additional properties
78-
if (
79-
schema.additionalProperties &&
80-
typeof schema.additionalProperties === 'object' &&
81-
schema.additionalProperties.type === 'never'
82-
) {
83-
result.pipes = [
84-
tsc.callExpression({
85-
functionName: tsc.propertyAccessExpression({
86-
expression: v.placeholder,
87-
name: identifiers.schemas.strictObject,
88-
}),
89-
parameters: [
90-
ts.factory.createObjectLiteralExpression(properties, true),
91-
],
92-
}),
93-
];
94-
return result as Omit<Ast, 'typeName'>;
88+
shape.prop(name, pipesToAst({ pipes: propertyAst.pipes, plugin }));
9589
}
9690

97-
// Handle additionalProperties with a schema (not just true/false)
98-
// This supports objects with dynamic keys (e.g., Record<string, T>)
99-
if (
100-
schema.additionalProperties &&
101-
typeof schema.additionalProperties === 'object' &&
102-
schema.additionalProperties.type !== undefined
103-
) {
104-
const additionalAst = irSchemaToAst({
105-
plugin,
106-
schema: schema.additionalProperties,
107-
state: {
108-
...state,
109-
path: toRef([...state.path.value, 'additionalProperties']),
110-
},
111-
});
112-
if (additionalAst.hasLazyExpression) {
113-
result.hasLazyExpression = true;
114-
}
115-
116-
// If there are no named properties, use v.record() directly
117-
if (!Object.keys(properties).length) {
118-
result.pipes = [
119-
tsc.callExpression({
120-
functionName: tsc.propertyAccessExpression({
121-
expression: v.placeholder,
122-
name: identifiers.schemas.record,
123-
}),
124-
parameters: [
125-
tsc.callExpression({
126-
functionName: tsc.propertyAccessExpression({
127-
expression: v.placeholder,
128-
name: identifiers.schemas.string,
129-
}),
130-
parameters: [],
131-
}),
132-
pipesToAst({ pipes: additionalAst.pipes, plugin }),
133-
],
134-
}),
135-
];
136-
return result as Omit<Ast, 'typeName'>;
91+
let additional: ts.Expression | null | undefined;
92+
if (schema.additionalProperties && schema.additionalProperties.type) {
93+
if (schema.additionalProperties.type === 'never') {
94+
additional = null;
95+
} else {
96+
const additionalAst = irSchemaToAst({
97+
plugin,
98+
schema: schema.additionalProperties,
99+
state: {
100+
...state,
101+
path: toRef([...state.path.value, 'additionalProperties']),
102+
},
103+
});
104+
if (additionalAst.hasLazyExpression) result.hasLazyExpression = true;
105+
additional = pipesToAst({ pipes: additionalAst.pipes, plugin });
137106
}
138-
139-
// If there are named properties, use v.objectWithRest() to validate both
140-
// The rest parameter is the schema for each additional property value
141-
result.pipes = [
142-
tsc.callExpression({
143-
functionName: tsc.propertyAccessExpression({
144-
expression: v.placeholder,
145-
name: identifiers.schemas.objectWithRest,
146-
}),
147-
parameters: [
148-
ts.factory.createObjectLiteralExpression(properties, true),
149-
pipesToAst({ pipes: additionalAst.pipes, plugin }),
150-
],
151-
}),
152-
];
153-
return result as Omit<Ast, 'typeName'>;
154107
}
155108

156-
result.pipes = [
157-
tsc.callExpression({
158-
functionName: tsc.propertyAccessExpression({
159-
expression: v.placeholder,
160-
name: identifiers.schemas.object,
161-
}),
162-
parameters: [ts.factory.createObjectLiteralExpression(properties, true)],
163-
}),
164-
];
109+
const args: ObjectBaseResolverArgs = {
110+
$,
111+
additional,
112+
pipes,
113+
plugin,
114+
schema,
115+
shape,
116+
};
117+
const resolver = plugin.config['~resolvers']?.object?.base;
118+
if (!resolver?.(args)) defaultObjectBaseResolver(args);
119+
120+
result.pipes = [pipesToAst({ pipes, plugin })];
165121
return result as Omit<Ast, 'typeName'>;
166122
};

0 commit comments

Comments
 (0)