Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
49 changes: 49 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,52 @@ 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
// (PascalCase identifier, consistent with operation wrappers)...
expect(content).toContain('export const Pet = zod.object(');
// ...and an operation schema references it by name.
expect(content).toMatch(/\bPet\b/);
// The inline definition must come before the operation exports that use
// it (anchor on the operation name section, derived from operationId).
expect(content.indexOf('export const Pet =')).toBeLessThan(
content.indexOf('export const ListPets'),
);
// 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 });
}
});
});
66 changes: 32 additions & 34 deletions packages/orval/src/reusable-schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,52 +12,50 @@ import {
} from './reusable-schemas';

describe('resolveSchemaName', () => {
it('returns the last $ref segment with camelCase by default', () => {
expect(resolveSchemaName('#/components/schemas/Pet', 'camelCase')).toBe(
'pet',
// Identifiers are always PascalCase (matching operation wrappers + TS model
// types); `namingConvention` only affects file names, not identifiers.
it('returns a PascalCase identifier regardless of namingConvention', () => {
const camel = createTestContextSpec({
output: { namingConvention: 'camelCase' as never },
});
expect(resolveSchemaName('#/components/schemas/Pet', camel)).toBe('Pet');
expect(resolveSchemaName('#/components/schemas/Pet_Owner', camel)).toBe(
'PetOwner',
);
expect(
resolveSchemaName('#/components/schemas/Pet_Owner', 'camelCase'),
).toBe('petOwner');
});

it('respects PascalCase / snake_case', () => {
expect(
resolveSchemaName('#/components/schemas/pet_owner', 'PascalCase'),
).toBe('PetOwner');
expect(
resolveSchemaName('#/components/schemas/PetOwner', 'snake_case'),
).toBe('pet_owner');
const kebab = createTestContextSpec({
output: { namingConvention: 'kebab-case' as never },
});
// kebab-case files, but the identifier is still a valid PascalCase symbol.
expect(resolveSchemaName('#/components/schemas/pet_owner', kebab)).toBe(
'PetOwner',
);
});
});

describe('resolveSchemaNames (validation)', () => {
it('returns a mapping when names are unique', () => {
describe('resolveSchemaNames (conflict guard)', () => {
const context = createTestContextSpec();

it('returns a mapping of ref -> PascalCase identifier', () => {
const result = resolveSchemaNames(
['#/components/schemas/Pet', '#/components/schemas/Owner'],
'camelCase',
context,
);
expect(result).toEqual(
new Map([
['#/components/schemas/Pet', 'pet'],
['#/components/schemas/Owner', 'owner'],
['#/components/schemas/Pet', 'Pet'],
['#/components/schemas/Owner', 'Owner'],
]),
);
});

it('throws when two refs collapse to the same converted name', () => {
it('throws when two refs collapse to the same identifier', () => {
expect(() =>
resolveSchemaNames(
['#/components/schemas/Pet', '#/components/schemas/pet'],
'camelCase',
['#/components/schemas/pet_owner', '#/components/schemas/PetOwner'],
context,
),
).toThrow(/Pet.*pet|pet.*Pet/);
});

it('throws when a converted name is not a valid JS identifier (kebab-case)', () => {
expect(() =>
resolveSchemaNames(['#/components/schemas/PetOwner'], 'kebab-case'),
).toThrow(/not a valid JS identifier/);
).toThrow(/pet_owner.*PetOwner|PetOwner.*pet_owner/);
});
});

Expand Down Expand Up @@ -148,13 +146,13 @@ describe('generateReusableSchemaSet', () => {

expect(result).toHaveLength(2);

const petEntry = result.find((e) => e.name === 'pet');
const ownerEntry = result.find((e) => e.name === 'owner');
const petEntry = result.find((e) => e.name === 'Pet');
const ownerEntry = result.find((e) => e.name === 'Owner');
expect(petEntry).toBeDefined();
expect(ownerEntry).toBeDefined();

expect(petEntry?.zod).toContain('__REF_owner__');
expect(petEntry?.usedRefs).toEqual(new Set(['owner']));
expect(petEntry?.zod).toContain('__REF_Owner__');
expect(petEntry?.usedRefs).toEqual(new Set(['Owner']));

expect(ownerEntry?.zod).not.toContain('__REF_');
expect(ownerEntry?.usedRefs).toEqual(new Set());
Expand Down Expand Up @@ -191,7 +189,7 @@ describe('generateReusableSchemaSet', () => {

// Owner must be in the result even though only Pet was seeded — the
// orchestrator follows usedRefs to avoid dangling identifiers.
expect(result.map((e) => e.name).toSorted()).toEqual(['owner', 'pet']);
expect(result.map((e) => e.name).toSorted()).toEqual(['Owner', 'Pet']);
});
});

Expand Down
60 changes: 20 additions & 40 deletions packages/orval/src/reusable-schemas.ts
Original file line number Diff line number Diff line change
@@ -1,64 +1,44 @@
import type { ContextSpec, ZodCoerceType } from '@orval/core';
import { conventionName, type NamingConvention } from '@orval/core';
import { getRefInfo } from '@orval/core';
import {
generateZodValidationSchemaDefinition,
parseZodValidationSchemaDefinition,
} from '@orval/zod';
import type { OpenAPIV3_1 } from '@scalar/openapi-types';

// Mirror `@orval/core`'s `getRefInfo`: split the JSON pointer, URL-decode each
// segment, and unescape RFC 6901 tokens (`~1` → `/`, `~0` → `~`). The zod
// generator uses `getRefInfo(...).originalName` to derive the namedRef export
// name, so we must follow the same rules here or the orchestrator's exported
// names would diverge for refs containing escaped characters.
const lastRefSegment = (ref: string): string => {
const raw = ref.split('/').pop() ?? '';
return decodeURIComponent(raw).replaceAll('~1', '/').replaceAll('~0', '~');
};

/**
* Convert a single `#/components/schemas/X` ref into the export name we will
* emit for it: the last `$ref` segment with `namingConvention` applied.
* Resolve the export identifier for a `#/components/schemas/X` ref. We reuse
* `@orval/core`'s `getRefInfo(...).name` (`pascal` + sanitize + component
* suffix) so reusable zod schema exports match the operation wrappers and the
* TS model types exactly. `namingConvention` deliberately does NOT influence
* the identifier — it governs file names only, consistent with the rest of
* orval. The same call powers the generator's `namedRef` emission, so the
* definition name and every reference stay in sync.
*/
export const resolveSchemaName = (
ref: string,
namingConvention: NamingConvention,
): string => conventionName(lastRefSegment(ref), namingConvention);

// JavaScript identifier (loose): start with letter/_/$, continue with word chars.
// Reusable schema names are emitted as `export const <name> = ...`, so they
// must be valid identifiers regardless of naming convention.
const JS_IDENTIFIER_PATTERN = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
export const resolveSchemaName = (ref: string, context: ContextSpec): string =>
getRefInfo(ref, context).name;

/**
* Resolve names for a set of refs, throwing on conflicts or on names that
* aren't valid JS identifiers (e.g. `kebab-case` produces dashes). The mapping
* is the single source of truth for cross-schema references — the generator,
* the orchestrator's graph, and the sentinel rewriter all consult it.
* Resolve names for a set of refs, throwing on conflicts (two distinct refs
* collapsing to the same identifier). The mapping is the single source of
* truth for cross-schema references — the generator, the orchestrator's graph,
* and the sentinel rewriter all consult it.
*/
export const resolveSchemaNames = (
refs: readonly string[],
namingConvention: NamingConvention,
context: ContextSpec,
): Map<string, string> => {
const resolved = new Map<string, string>();
const reverse = new Map<string, string>();

for (const ref of refs) {
const name = resolveSchemaName(ref, namingConvention);
if (!JS_IDENTIFIER_PATTERN.test(name)) {
throw new Error(
`[orval/zod] generateReusableSchemas: ref ${ref} converts to "${name}" ` +
`under namingConvention=${namingConvention}, which is not a valid JS ` +
`identifier. Use camelCase, PascalCase, or snake_case for the project's ` +
`namingConvention when this flag is enabled.`,
);
}
const name = resolveSchemaName(ref, context);
const previous = reverse.get(name);
if (previous !== undefined && previous !== ref) {
throw new Error(
`[orval/zod] generateReusableSchemas: refs ${previous} and ${ref} ` +
`both convert to "${name}" under namingConvention=${namingConvention}. ` +
`Rename one in the OpenAPI source or change the convention.`,
`both resolve to the export name "${name}". ` +
`Rename one in the OpenAPI source.`,
);
}
resolved.set(ref, name);
Expand Down Expand Up @@ -170,7 +150,7 @@ export const generateReusableSchemaSet = (
const nameToRef = new Map<string, string>();
for (const schemaName of Object.keys(componentSchemas)) {
const ref = `#/components/schemas/${schemaName}`;
nameToRef.set(resolveSchemaName(ref, context.output.namingConvention), ref);
nameToRef.set(resolveSchemaName(ref, context), ref);
}

// Expand to the transitive closure of component-schema refs reachable from
Expand All @@ -186,7 +166,7 @@ export const generateReusableSchemaSet = (
const schema = componentSchemas[schemaName];
if (!schema) continue;

const name = resolveSchemaName(ref, context.output.namingConvention);
const name = resolveSchemaName(ref, context);

const definition = generateZodValidationSchemaDefinition(
schema,
Expand Down
21 changes: 19 additions & 2 deletions packages/orval/src/write-specs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,20 @@ 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.
// `NormalizedOutputOptions` types this as a required `boolean`, so use it
// directly (a `=== true` compare trips no-unnecessary-boolean-literal-compare).
if (output.override.zod.generateReusableSchemas) {
return true;
}
Comment thread
z4o4z marked this conversation as resolved.
return !hasOperations;
}

function shouldGenerateSchemas(
Expand Down Expand Up @@ -437,6 +450,10 @@ export async function writeSpecs(
output,
hasOperations,
);
// Only emit the inline `import { z as zod }` when there are no operations.
// With operations the zod client already emits `import * as zod from 'zod'`,
// so a second import would redeclare the `zod` binding.
const includeZodImport = !hasOperations;

implementationPaths = await writeMode({
builder,
Expand All @@ -446,7 +463,7 @@ export async function writeSpecs(
header,
needSchema: shouldGenerateSchemas(output, hasOperations),
generateSchemasInline: needZodSchemasInline
? () => generateZodSchemasInline(builder, output)
? () => generateZodSchemasInline(builder, output, includeZodImport)
: undefined,
});
}
Expand Down
12 changes: 6 additions & 6 deletions packages/orval/src/write-zod-specs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -512,8 +512,8 @@ describe('writeZodSchemasFromVerbs with generateReusableSchemas', () => {
} as never;

const options = createOutputOptions();
// createOutputOptions() uses PascalCase; force camelCase so the export
// name (`petStatus`) and file (`petStatus.ts`) match the issue repro.
// camelCase namingConvention → file names are camelCased (`petStatus.ts`),
// but the exported identifier is always PascalCase (`PetStatus`).
(options as { namingConvention: string }).namingConvention = 'camelCase';
(options.override.zod as Record<string, unknown>).generateReusableSchemas =
true;
Expand Down Expand Up @@ -552,11 +552,11 @@ describe('writeZodSchemasFromVerbs with generateReusableSchemas', () => {
'utf8',
);

// Sentinel resolved to the bare identifier...
// Sentinel resolved to the bare PascalCase identifier...
expect(content).not.toContain('__REF_');
expect(content).toContain('petStatus');
// ...and the matching import is emitted.
expect(content).toContain("import { petStatus } from './petStatus';");
expect(content).toContain('zod.union([PetStatus,');
// ...and the matching import is emitted (PascalCase symbol, camelCase file).
expect(content).toContain("import { PetStatus } from './petStatus';");

await fs.remove(root);
});
Expand Down
Loading
Loading