Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions packages/orval/src/generate-spec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,50 @@ describe('generateSpec - schemas: false', () => {
}
});
});

describe('generateSpec - generateReusableSchemas inline (single mode)', () => {
// Regression for #3463 follow-up: with `client: 'zod'` +
// `generateReusableSchemas` + operations + no `schemas:` dir, operations
// reference component schemas by name, so the component definitions must be
// emitted inline in the same single file (previously they were skipped
// because operations were present, leaving dangling references).
it('emits referenced component schemas inline alongside operations', async () => {
const workspace = await createTempWorkspace();
const targetFile = path.join(workspace, 'zod.ts');

try {
const options = await normalizeOptions(
{
input: { target: PETSTORE_SPEC },
output: {
target: './zod.ts',
mode: 'single',
client: 'zod',
override: { zod: { generateReusableSchemas: true } },
},
},
workspace,
);

await generateSpec(workspace, options);

const content = await fs.readFile(targetFile, 'utf8');

// The component schema referenced by the operation is defined inline...
expect(content).toContain('export const pet = zod.object(');
// ...and the operation references it by name.
expect(content).toContain('= pet');
// The inline definition must come before the operation that uses it.
expect(content.indexOf('export const pet =')).toBeLessThan(
content.indexOf('Item = pet'),
);
Comment thread
z4o4z marked this conversation as resolved.
Outdated
// Exactly one zod import — the inline schemas must not redeclare `zod`
// on top of the zod client's `import * as zod from 'zod'`.
expect(content.match(/from 'zod'/g) ?? []).toHaveLength(1);
// No unresolved sentinels.
expect(content).not.toContain('__REF_');
} finally {
await rm(workspace, { recursive: true, force: true });
}
});
});
18 changes: 16 additions & 2 deletions packages/orval/src/write-specs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,18 @@ function shouldGenerateZodSchemasInline(
output: NormalizedOptions['output'],
hasOperations: boolean,
): boolean {
return output.client === 'zod' && !output.schemas && !hasOperations;
if (output.client !== 'zod' || output.schemas) {
return false;
}
// With `generateReusableSchemas`, operations reference component schemas by
// name, so the component definitions must be emitted inline alongside the
// operations (otherwise the references are dangling). Without the flag,
// operations inline their own schemas, so we only emit the component
// schemas inline when there are no operations.
if (output.override.zod.generateReusableSchemas) {
return true;
}
Comment thread
z4o4z marked this conversation as resolved.
return !hasOperations;
}

function shouldGenerateSchemas(
Expand Down Expand Up @@ -446,7 +457,10 @@ export async function writeSpecs(
header,
needSchema: shouldGenerateSchemas(output, hasOperations),
generateSchemasInline: needZodSchemasInline
? () => generateZodSchemasInline(builder, output)
? // Skip the inline `import { z as zod }` when operations are present:
// the zod client already emits `import * as zod from 'zod'`, so a
// second import would redeclare the `zod` binding.
() => generateZodSchemasInline(builder, output, !hasOperations)
Comment thread
z4o4z marked this conversation as resolved.
Outdated
: undefined,
});
}
Expand Down
25 changes: 18 additions & 7 deletions packages/orval/src/write-zod-specs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,16 +122,21 @@ interface WriteZodSchemasFromVerbsContext {
function generateZodSchemaFileContent(
header: string,
schemas: ZodSchemaFileEntry[],
// Omit the `import { z as zod }` line when the content is concatenated into a
// file that already imports zod (e.g. inline single-mode output, where the
// zod client already emits `import * as zod from 'zod'`).
includeZodImport = true,
): string {
// Group the zod import with any reusable-schema imports (deduped across the
// usually-single entries written to this file), then separate that block
// from the schema content with a single blank line.
const refImports = [
...new Set(schemas.flatMap((s) => s.importStatements ?? [])),
].toSorted();
const importBlock = [`import { z as zod } from 'zod';`, ...refImports].join(
'\n',
);
const importBlock = [
...(includeZodImport ? [`import { z as zod } from 'zod';`] : []),
...refImports,
].join('\n');

const schemaContent = schemas
.map(({ schemaName, consts, zodExpression }) => {
Expand All @@ -144,7 +149,8 @@ export type ${schemaName}Output = zod.output<typeof ${schemaName}>;`;
})
.join('\n\n');

return `${header}${importBlock}\n\n${schemaContent}\n`;
const separator = importBlock ? `${importBlock}\n\n` : '';
return `${header}${separator}${schemaContent}\n`;
}

const isValidSchemaIdentifier = (name: string) =>
Expand Down Expand Up @@ -234,12 +240,13 @@ async function writeZodSchemaIndex(
export function generateZodSchemasInline(
builder: WriteZodSchemasInput,
output: WriteZodOutputOptions,
includeZodImport = true,
): string {
const useReusableSchemas =
output.override.zod.generateReusableSchemas === true;

if (useReusableSchemas) {
return generateZodSchemasInlineReusable(builder, output);
return generateZodSchemasInlineReusable(builder, output, includeZodImport);
}

const schemasWithOpenApiDef = builder.schemas.filter((s) => s.schema);
Expand Down Expand Up @@ -297,12 +304,13 @@ export function generateZodSchemasInline(
return '';
}

return generateZodSchemaFileContent('', schemas);
return generateZodSchemaFileContent('', schemas, includeZodImport);
}

function generateZodSchemasInlineReusable(
builder: WriteZodSchemasInput,
output: WriteZodOutputOptions,
includeZodImport = true,
): string {
const schemasWithOpenApiDef = builder.schemas.filter((s) => s.schema);
if (schemasWithOpenApiDef.length === 0) return '';
Expand Down Expand Up @@ -342,7 +350,10 @@ function generateZodSchemasInlineReusable(
})
.join('\n\n');

return `import { z as zod } from 'zod';\n\n${body}\n`;
// Omit the zod import when concatenated into a file that already imports it
// (inline single-mode output where the zod client emits `import * as zod`).
const prefix = includeZodImport ? `import { z as zod } from 'zod';\n\n` : '';
return `${prefix}${body}\n`;
}

export async function writeZodSchemas(
Expand Down
Loading