Skip to content

Commit 8d6c24a

Browse files
authored
feat: generate zod schema even with params type (orval-labs#2660)
1 parent 0cce06f commit 8d6c24a

File tree

3 files changed

+269
-25
lines changed

3 files changed

+269
-25
lines changed

packages/fetch/src/index.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,6 @@ ${
177177
!isPrimitiveType &&
178178
hasSchema &&
179179
!isNdJson;
180-
const responseZodSchemaName = `${responseType}Schema`;
181180

182181
const allResponses = [...response.types.success, ...response.types.errors];
183182
if (allResponses.length === 0) {
@@ -209,7 +208,7 @@ ${
209208
const hasValidZodSchema = r.value && !PRIMITIVE_TYPES.has(r.value);
210209
const dataType =
211210
override.fetch.useZodSchemaResponse && hasValidZodSchema
212-
? `zod.infer<typeof ${r.value}Schema>`
211+
? `zod.infer<typeof ${r.value}>`
213212
: r.value || 'unknown';
214213

215214
return {
@@ -369,7 +368,7 @@ ${override.fetch.forceSuccessResponse && hasSuccess ? '' : `export type ${respon
369368
${
370369
isValidateResponse
371370
? `const parsedBody = body ? JSON.parse(body${reviver}) : {}
372-
const data = ${responseZodSchemaName}.parse(parsedBody)`
371+
const data = ${responseType}.parse(parsedBody)`
373372
: `const data: ${override.fetch.forceSuccessResponse && hasSuccess ? successName : responseTypeName}${override.fetch.includeHttpResponseReturnType ? `['data']` : ''} = body ? JSON.parse(body${reviver}) : {}`
374373
}
375374
${override.fetch.includeHttpResponseReturnType ? `return { data, status: res.status, headers: res.headers } as ${override.fetch.forceSuccessResponse && hasSuccess ? successName : responseTypeName}` : 'return data'}
@@ -424,23 +423,47 @@ export const generateClient: ClientBuilder = (
424423
verbOptions.override.fetch.useZodSchemaResponse ||
425424
verbOptions.override.fetch.runtimeValidation;
426425

427-
const zodSchemaImports = isZodSchemaImportsRequired
426+
const responseImports = isZodSchemaImportsRequired
428427
? [
429428
...verbOptions.response.types.success,
430429
...verbOptions.response.types.errors,
431430
].flatMap((response) =>
432431
response.imports.map((imp) => ({
433-
name: `${imp.name}Schema`,
432+
name: imp.name,
434433
schemaName: imp.name,
435434
isZodSchema: true,
436435
values: true,
437436
})),
438437
)
439438
: [];
440439

440+
const requestImports = isZodSchemaImportsRequired
441+
? [
442+
...verbOptions.body.imports,
443+
...(verbOptions.queryParams
444+
? [{ name: `${pascal(verbOptions.operationName)}QueryParams` }]
445+
: []),
446+
...(verbOptions.headers
447+
? [{ name: `${pascal(verbOptions.operationName)}Header` }]
448+
: []),
449+
].map((imp) => ({
450+
name: imp.name,
451+
schemaName: imp.name,
452+
isZodSchema: true,
453+
values: true,
454+
}))
455+
: [];
456+
457+
const zodSchemaImports = [...responseImports, ...requestImports];
458+
459+
const zodSchemaNames = new Set(zodSchemaImports.map((imp) => imp.name));
460+
const filteredImports = imports.filter(
461+
(imp) => !zodSchemaNames.has(imp.name),
462+
);
463+
441464
return {
442465
implementation: `${functionImplementation}\n`,
443-
imports: [...imports, ...zodSchemaImports],
466+
imports: [...filteredImports, ...zodSchemaImports],
444467
};
445468
};
446469

packages/orval/src/write-specs.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { unique } from 'remeda';
2424
import type { TypeDocOptions } from 'typedoc';
2525

2626
import { executeHook } from './utils';
27-
import { writeZodSchemas } from './write-zod-specs';
27+
import { writeZodSchemas, writeZodSchemasFromVerbs } from './write-zod-specs';
2828

2929
function getHeader(
3030
option: false | ((info: OpenApiInfoObject) => string | string[]),
@@ -92,6 +92,22 @@ export async function writeSpecs(
9292
header,
9393
output,
9494
);
95+
96+
if (builder.verbOptions) {
97+
await writeZodSchemasFromVerbs(
98+
builder.verbOptions,
99+
output.schemas.path,
100+
fileExtension,
101+
header,
102+
output,
103+
{
104+
spec: builder.spec,
105+
target: builder.target,
106+
workspace,
107+
output,
108+
},
109+
);
110+
}
95111
}
96112
}
97113
}

packages/orval/src/write-zod-specs.ts

Lines changed: 223 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import {
22
conventionName,
33
type ContextSpec,
4+
type GeneratorVerbOptions,
5+
type NamingConvention,
46
type NormalizedOutputOptions,
7+
type OpenApiSchemaObject,
8+
pascal,
59
upath,
610
type WriteSpecBuilder,
711
} from '@orval/core';
@@ -13,6 +17,58 @@ import {
1317
} from '@orval/zod';
1418
import fs from 'fs-extra';
1519

20+
function generateZodSchemaFileContent(
21+
header: string,
22+
schemaName: string,
23+
zodContent: string,
24+
): string {
25+
return `${header}import { z as zod } from 'zod';
26+
27+
export const ${schemaName} = ${zodContent}
28+
29+
export type ${schemaName} = zod.infer<typeof ${schemaName}>;
30+
`;
31+
}
32+
33+
async function writeZodSchemaIndex(
34+
schemasPath: string,
35+
fileExtension: string,
36+
header: string,
37+
schemaNames: string[],
38+
namingConvention: NamingConvention,
39+
shouldMergeExisting: boolean = false,
40+
) {
41+
const importFileExtension = fileExtension.replace(/\.ts$/, '');
42+
const indexPath = upath.join(schemasPath, `index${fileExtension}`);
43+
44+
let existingExports = '';
45+
if (shouldMergeExisting && (await fs.pathExists(indexPath))) {
46+
const existingContent = await fs.readFile(indexPath, 'utf-8');
47+
const headerMatch = existingContent.match(/^(\/\*\*[\s\S]*?\*\/\n)?/);
48+
const headerPart = headerMatch ? headerMatch[0] : '';
49+
existingExports = existingContent.substring(headerPart.length).trim();
50+
}
51+
52+
const newExports = schemaNames
53+
.map((schemaName) => {
54+
const fileName = conventionName(schemaName, namingConvention);
55+
return `export * from './${fileName}${importFileExtension}';`;
56+
})
57+
.sort()
58+
.join('\n');
59+
60+
const allExports = existingExports
61+
? `${existingExports}\n${newExports}`
62+
: newExports;
63+
64+
const uniqueExports = [...new Set(allExports.split('\n'))]
65+
.filter((line) => line.trim())
66+
.sort()
67+
.join('\n');
68+
69+
await fs.outputFile(indexPath, `${header}\n${uniqueExports}\n`);
70+
}
71+
1672
export async function writeZodSchemas(
1773
builder: WriteSpecBuilder,
1874
schemasPath: string,
@@ -21,7 +77,6 @@ export async function writeZodSchemas(
2177
output: NormalizedOutputOptions,
2278
) {
2379
const schemasWithOpenApiDef = builder.schemas.filter((s) => s.schema);
24-
const importFileExtension = fileExtension.replace(/\.ts$/, '');
2580

2681
await Promise.all(
2782
schemasWithOpenApiDef.map(async (generatorSchema) => {
@@ -73,28 +128,178 @@ export async function writeZodSchemas(
73128
isZodV4,
74129
);
75130

76-
const fileContent = `${header}import { z as zod } from 'zod';
77-
${parsedZodDefinition.consts ? `\n${parsedZodDefinition.consts}\n` : ''}
78-
export const ${name}Schema = ${parsedZodDefinition.zod};
79-
export type ${name} = zod.infer<typeof ${name}Schema>;
80-
`;
131+
const zodContent = parsedZodDefinition.consts
132+
? `${parsedZodDefinition.consts}\n${parsedZodDefinition.zod}`
133+
: parsedZodDefinition.zod;
134+
135+
const fileContent = generateZodSchemaFileContent(
136+
header,
137+
name,
138+
zodContent,
139+
);
81140

82141
await fs.outputFile(filePath, fileContent);
83142
}),
84143
);
85144

86145
if (output.indexFiles) {
87-
const schemaFilePath = upath.join(schemasPath, `/index${fileExtension}`);
88-
89-
const exports = schemasWithOpenApiDef
90-
.map((schema) => {
91-
const fileName = conventionName(schema.name, output.namingConvention);
92-
return `export * from './${fileName}${importFileExtension}';`;
93-
})
94-
.toSorted((a, b) => a.localeCompare(b))
95-
.join('\n');
96-
97-
const fileContent = `${header}\n${exports}`;
98-
await fs.outputFile(schemaFilePath, fileContent);
146+
const schemaNames = schemasWithOpenApiDef.map((schema) => schema.name);
147+
await writeZodSchemaIndex(
148+
schemasPath,
149+
fileExtension,
150+
header,
151+
schemaNames,
152+
output.namingConvention,
153+
false,
154+
);
155+
}
156+
}
157+
158+
export async function writeZodSchemasFromVerbs(
159+
verbOptions: Record<string, GeneratorVerbOptions>,
160+
schemasPath: string,
161+
fileExtension: string,
162+
header: string,
163+
output: NormalizedOutputOptions,
164+
context: ContextSpec,
165+
) {
166+
const verbOptionsArray = Object.values(verbOptions);
167+
168+
if (verbOptionsArray.length === 0) {
169+
return;
170+
}
171+
172+
const isZodV4 = !!output.packageJson && isZodVersionV4(output.packageJson);
173+
const strict =
174+
typeof output.override?.zod?.strict === 'object'
175+
? (output.override.zod.strict.body ?? false)
176+
: (output.override?.zod?.strict ?? false);
177+
const coerce =
178+
typeof output.override?.zod?.coerce === 'object'
179+
? (output.override.zod.coerce.body ?? false)
180+
: (output.override?.zod?.coerce ?? false);
181+
182+
const generateVerbsSchemas = verbOptionsArray.flatMap((verbOption) => {
183+
const operation = verbOption.originalOperation;
184+
185+
const bodySchema =
186+
operation.requestBody && 'content' in operation.requestBody
187+
? operation.requestBody.content['application/json']?.schema
188+
: undefined;
189+
190+
const bodySchemas = bodySchema
191+
? [
192+
{
193+
name: `${pascal(verbOption.operationName)}Body`,
194+
schema: dereference(bodySchema as OpenApiSchemaObject, context),
195+
},
196+
]
197+
: [];
198+
199+
const queryParams = operation.parameters?.filter(
200+
(p) => 'in' in p && p.in === 'query',
201+
);
202+
203+
const queryParamsSchemas =
204+
queryParams && queryParams.length > 0
205+
? [
206+
{
207+
name: `${pascal(verbOption.operationName)}Params`,
208+
schema: {
209+
type: 'object' as const,
210+
properties: Object.fromEntries(
211+
queryParams
212+
.filter((p) => 'schema' in p && p.schema)
213+
.map((p) => [
214+
p.name,
215+
dereference(p.schema as OpenApiSchemaObject, context),
216+
]),
217+
),
218+
required: queryParams
219+
.filter((p) => p.required)
220+
.map((p) => p.name),
221+
},
222+
},
223+
]
224+
: [];
225+
226+
const headerParams = operation.parameters?.filter(
227+
(p) => 'in' in p && p.in === 'header',
228+
);
229+
230+
const headerParamsSchemas =
231+
headerParams && headerParams.length > 0
232+
? [
233+
{
234+
name: `${pascal(verbOption.operationName)}Headers`,
235+
schema: {
236+
type: 'object' as const,
237+
properties: Object.fromEntries(
238+
headerParams
239+
.filter((p) => 'schema' in p && p.schema)
240+
.map((p) => [
241+
p.name,
242+
dereference(p.schema as OpenApiSchemaObject, context),
243+
]),
244+
),
245+
required: headerParams
246+
.filter((p) => p.required)
247+
.map((p) => p.name),
248+
},
249+
},
250+
]
251+
: [];
252+
253+
return [...bodySchemas, ...queryParamsSchemas, ...headerParamsSchemas];
254+
});
255+
256+
await Promise.all(
257+
generateVerbsSchemas.map(async ({ name, schema }) => {
258+
const fileName = conventionName(name, output.namingConvention);
259+
const filePath = upath.join(schemasPath, `${fileName}${fileExtension}`);
260+
261+
const zodDefinition = generateZodValidationSchemaDefinition(
262+
schema,
263+
context,
264+
name,
265+
strict,
266+
isZodV4,
267+
{
268+
required: true,
269+
},
270+
);
271+
272+
const parsedZodDefinition = parseZodValidationSchemaDefinition(
273+
zodDefinition,
274+
context,
275+
coerce,
276+
strict,
277+
isZodV4,
278+
);
279+
280+
const zodContent = parsedZodDefinition.consts
281+
? `${parsedZodDefinition.consts}\n${parsedZodDefinition.zod}`
282+
: parsedZodDefinition.zod;
283+
284+
const fileContent = generateZodSchemaFileContent(
285+
header,
286+
name,
287+
zodContent,
288+
);
289+
290+
await fs.outputFile(filePath, fileContent);
291+
}),
292+
);
293+
294+
if (output.indexFiles && generateVerbsSchemas.length > 0) {
295+
const schemaNames = generateVerbsSchemas.map((s) => s.name);
296+
await writeZodSchemaIndex(
297+
schemasPath,
298+
fileExtension,
299+
header,
300+
schemaNames,
301+
output.namingConvention,
302+
true,
303+
);
99304
}
100305
}

0 commit comments

Comments
 (0)