Skip to content

Commit 7eff340

Browse files
committed
feat(core): add schemas.importPath for package import specifiers (#3535)
Allow generated client files to import schema types from a package specifier (e.g. '@acme/models') instead of computing a relative filesystem path. This unblocks use cases where the schemas and client outputs sit in separate TypeScript compilation roots or in secondary entrypoint packages. When `schemas.importPath` is set, all four write modes and the factory generators emit imports from the configured package specifier. The filesystem `path` is still used for the on-disk schema output, and validation rejects empty strings, relative paths, absolute paths, and whitespace-only values. - core: add `importPath` to `SchemaOptions` / `NormalizedSchemaOptions` - core: extract `getSchemasImportPath` helper into its own module - core: update all writers (single/split/tags/split-tags) to use the helper - core: update factory.ts to resolve factory and type imports from `importPath` - core: skip per-file extension in `generateImportsForBuilder` for package imports - orval: tighten `normalizeSchemasOption` validation for `importPath` - docs: document `schemas` object form and `importPath` requirements - tests: cover all four modes, zod suffix, faker subpath, and `outputDirectory` bypass Closes #3535
1 parent 7e48991 commit 7eff340

17 files changed

Lines changed: 858 additions & 26 deletions

docs/content/docs/reference/configuration/output.mdx

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,12 @@ export default defineConfig({
7979

8080
## schemas
8181

82-
**Type:** `String`
82+
**Type:** `String | Object | false`
8383
**Default:** Same as `target`
8484

85-
Output path for generated model types.
85+
Output path for generated model types. Set to `false` to disable separate schema file output.
86+
87+
### String form
8688

8789
```ts title="orval.config.ts"
8890
export default defineConfig({
@@ -94,6 +96,77 @@ export default defineConfig({
9496
});
9597
```
9698

99+
### Object form
100+
101+
```ts title="orval.config.ts"
102+
export default defineConfig({
103+
petstore: {
104+
output: {
105+
schemas: {
106+
path: './api/model',
107+
type: 'typescript', // 'typescript' | 'zod'
108+
},
109+
},
110+
},
111+
});
112+
```
113+
114+
| Property | Type | Description |
115+
| ---------- | -------- | ----------------------------------------------- |
116+
| `path` | `string` | Filesystem path for schema output |
117+
| `type` | `string` | `'typescript'` or `'zod'` |
118+
| `importPath` | `string` | Optional package import specifier (see below) |
119+
120+
### importPath
121+
122+
When `importPath` is set, generated client files import schema types from
123+
that package specifier instead of computing a relative filesystem path:
124+
125+
```ts title="orval.config.ts"
126+
export default defineConfig({
127+
petstore: {
128+
output: {
129+
target: './libs/client/angular/src/lib/endpoints',
130+
schemas: {
131+
path: './libs/client/models/src/lib',
132+
type: 'typescript',
133+
importPath: '@acme/client/models',
134+
},
135+
},
136+
},
137+
});
138+
```
139+
140+
```ts
141+
// Without importPath — computed relative path:
142+
import type { Pet } from '../models/pet';
143+
144+
// With importPath: '@acme/client/models':
145+
import type { Pet } from '@acme/client/models';
146+
```
147+
148+
Schemas are still written to the filesystem `path` — only the generated
149+
import statements change.
150+
151+
**Requirements when using `importPath`:**
152+
153+
- The target package must export the types at the specified import path.
154+
- With `indexFiles: true` (recommended), all types are imported from the
155+
single `importPath` (e.g., `@acme/models`).
156+
- With `indexFiles: false`, each schema is imported individually
157+
(e.g., `@acme/models/pet`). The package must support these subpath exports.
158+
For Zod schemas (`type: 'zod'`) the per-file suffix is `.zod`, so the
159+
package must also expose `./pet.zod` (e.g., `@acme/models/pet.zod`).
160+
- If using faker schema factories
161+
(`mock: { generators: [{ type: 'faker', schemas: true }] }`),
162+
the package must also export `./index.faker`.
163+
- If using factory methods (`factoryMethods`), each schema is imported
164+
individually regardless of `indexFiles`.
165+
- When `importPath` is set, the relative-path computation in
166+
`factoryMethods.outputDirectory` is bypassed: factories resolve imports
167+
against the package specifier rather than the on-disk factory output
168+
directory.
169+
97170
## operationSchemas
98171

99172
**Type:** `String`

packages/core/src/generators/factory.test.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,3 +611,168 @@ describe('generateFactory', () => {
611611
);
612612
});
613613
});
614+
615+
describe('generateFactory with schemas.importPath', () => {
616+
it('uses importPath for $ref imports in split mode', () => {
617+
const schema: OpenApiSchemaObject = {
618+
type: 'object',
619+
required: ['target'],
620+
properties: {
621+
target: { $ref: '#/components/schemas/RefTarget' },
622+
},
623+
};
624+
625+
const result = generateFactory(
626+
schema,
627+
'WithRef',
628+
createMockContext({
629+
factoryMethods: { ...baseFactoryMethods, mode: 'split' },
630+
schemas: {
631+
path: '/libs/models',
632+
type: 'typescript',
633+
importPath: '@acme/models',
634+
},
635+
}),
636+
);
637+
expect(result?.imports).toContainEqual({
638+
name: 'createRefTarget',
639+
importPath: '@acme/models/refTarget',
640+
isConstant: true,
641+
});
642+
expect(result?.imports).toContainEqual({
643+
name: 'RefTarget',
644+
importPath: '@acme/models/refTarget',
645+
});
646+
});
647+
648+
it('uses importPath for circular reference casts in split mode', () => {
649+
const schema: OpenApiSchemaObject = {
650+
type: 'object',
651+
required: ['child'],
652+
properties: {
653+
child: { $ref: '#/components/schemas/CircularChild' },
654+
},
655+
};
656+
657+
const result = generateFactory(
658+
schema,
659+
'CircularParent',
660+
createMockContext({
661+
factoryMethods: { ...baseFactoryMethods, mode: 'split' },
662+
schemas: {
663+
path: '/libs/models',
664+
type: 'typescript',
665+
importPath: '@acme/models',
666+
},
667+
}),
668+
);
669+
expect(result?.model).toContain('child: {} as CircularChild');
670+
expect(result?.imports).toContainEqual({
671+
name: 'CircularChild',
672+
importPath: '@acme/models/circularChild',
673+
});
674+
expect(result?.imports).toContainEqual({
675+
name: 'CircularParent',
676+
importPath: '@acme/models/circularParent',
677+
});
678+
});
679+
680+
it('uses importPath for factory function imports in single mode', () => {
681+
const schema: OpenApiSchemaObject = {
682+
type: 'object',
683+
required: ['target'],
684+
properties: {
685+
target: { $ref: '#/components/schemas/RefTarget' },
686+
},
687+
};
688+
689+
const result = generateFactory(
690+
schema,
691+
'WithRef',
692+
createMockContext({
693+
factoryMethods: { ...baseFactoryMethods, mode: 'single' },
694+
schemas: {
695+
path: '/libs/models',
696+
type: 'typescript',
697+
importPath: '@acme/models',
698+
},
699+
}),
700+
);
701+
expect(result?.imports).toContainEqual({
702+
name: 'createRefTarget',
703+
importPath: '@acme/models/refTarget',
704+
isConstant: true,
705+
});
706+
});
707+
708+
it('uses importPath for type imports in single-split mode (factory combined into single file)', () => {
709+
const schema: OpenApiSchemaObject = {
710+
type: 'object',
711+
required: ['target'],
712+
properties: {
713+
target: { $ref: '#/components/schemas/RefTarget' },
714+
},
715+
};
716+
717+
const result = generateFactory(
718+
schema,
719+
'WithRefCombined',
720+
createMockContext({
721+
factoryMethods: { ...baseFactoryMethods, mode: 'single-split' },
722+
schemas: {
723+
path: '/libs/models',
724+
type: 'typescript',
725+
importPath: '@acme/models',
726+
},
727+
}),
728+
);
729+
expect(result?.imports).not.toContainEqual(
730+
expect.objectContaining({ name: 'createRefTarget' }),
731+
);
732+
expect(result?.imports).toContainEqual({
733+
name: 'RefTarget',
734+
importPath: '@acme/models/refTarget',
735+
});
736+
expect(result?.imports).toContainEqual({
737+
name: 'WithRefCombined',
738+
importPath: '@acme/models/withRefCombined',
739+
});
740+
});
741+
742+
it('bypasses factoryMethods.outputDirectory when importPath is set (split mode)', () => {
743+
const schema: OpenApiSchemaObject = {
744+
type: 'object',
745+
required: ['target'],
746+
properties: {
747+
target: { $ref: '#/components/schemas/RefTarget' },
748+
},
749+
};
750+
751+
const result = generateFactory(
752+
schema,
753+
'WithRef',
754+
createMockContext({
755+
factoryMethods: {
756+
...baseFactoryMethods,
757+
mode: 'split',
758+
outputDirectory: '/libs/factories',
759+
},
760+
schemas: {
761+
path: '/libs/models',
762+
type: 'typescript',
763+
importPath: '@acme/models',
764+
},
765+
}),
766+
);
767+
768+
const factoryImport = result?.imports.find(
769+
(i) => i.name === 'createRefTarget',
770+
);
771+
expect(factoryImport?.importPath).toBe('@acme/models/refTarget');
772+
expect(factoryImport?.importPath).not.toContain('factories');
773+
774+
const typeImport = result?.imports.find((i) => i.name === 'RefTarget');
775+
expect(typeImport?.importPath).toBe('@acme/models/refTarget');
776+
expect(typeImport?.importPath).not.toContain('factories');
777+
});
778+
});

packages/core/src/generators/factory.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { PropertySortOrder } from '../types';
99
import {
1010
conventionName,
1111
getFileInfo,
12+
getSchemasImportPath,
1213
isString,
1314
logWarning,
1415
pascal,
@@ -34,6 +35,13 @@ function getSchemaImportPath(
3435
if (context.output.factoryMethods?.mode === 'single') {
3536
return undefined;
3637
}
38+
39+
const importPathBase = getSchemasImportPath(context.output.schemas);
40+
if (importPathBase) {
41+
const baseName = conventionName(refName, context.output.namingConvention);
42+
return upath.joinSafe(importPathBase, baseName);
43+
}
44+
3745
let outputDir = context.output.factoryMethods?.outputDirectory;
3846
let schemasPath = getSchemasPath(context);
3947

@@ -328,15 +336,24 @@ function resolveImportPath(
328336
context: ContextSpec,
329337
): string | undefined {
330338
const baseName = conventionName(refName, context.output.namingConvention);
339+
const pkgBase = getSchemasImportPath(context.output.schemas);
340+
331341
switch (mode) {
332342
case 'split': {
333-
return `./${baseName}.factory`;
343+
return pkgBase
344+
? upath.joinSafe(pkgBase, baseName)
345+
: `./${baseName}.factory`;
334346
}
335347
case 'single-split': {
336-
return `./${conventionName('factoryMethods', context.output.namingConvention)}`;
348+
return pkgBase
349+
? upath.joinSafe(
350+
pkgBase,
351+
conventionName('factoryMethods', context.output.namingConvention),
352+
)
353+
: `./${conventionName('factoryMethods', context.output.namingConvention)}`;
337354
}
338355
case 'single': {
339-
return `./${baseName}`;
356+
return pkgBase ? upath.joinSafe(pkgBase, baseName) : `./${baseName}`;
340357
}
341358
}
342359
}

packages/core/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,11 +308,13 @@ export interface NormalizedFactoryMethodsOptions {
308308
export interface SchemaOptions {
309309
path: string;
310310
type: SchemaGenerationType;
311+
importPath?: string;
311312
}
312313

313314
export interface NormalizedSchemaOptions {
314315
path: string;
315316
type: SchemaGenerationType;
317+
importPath?: string;
316318
}
317319

318320
export interface OutputOptions {

packages/core/src/utils/assertion.test.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ describe('assertion testing', () => {
2323
it('checks for reference objects', () => {
2424
expect(isReference({ $ref: '#/components/schemas/User' })).toBeTruthy();
2525
expect(isReference({} as Record<string, unknown>)).toBeFalsy();
26+
expect(isReference({ $dynamicRef: '#category' })).toBeFalsy();
2627
// eslint-disable-next-line unicorn/no-null -- testing null handling
2728
expect(isReference(null as unknown as object)).toBeFalsy();
2829
});
@@ -136,10 +137,6 @@ describe('isDynamicReference', () => {
136137
);
137138
});
138139

139-
it('returns false for objects with $dynamicRef but not $ref in isReference', () => {
140-
expect(isReference({ $dynamicRef: '#category' })).toBe(false);
141-
});
142-
143140
it('returns true for objects with $ref in isReference', () => {
144141
expect(isReference({ $ref: '#/components/schemas/Foo' })).toBe(true);
145142
});

packages/core/src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export * from './merge-deep';
1717
export * from './occurrence';
1818
export * as upath from './path';
1919
export * from './resolve-version';
20+
export * from './schemas-options';
2021
export * from './sort';
2122
export * from './string';
2223
export * from './tsconfig';
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { getSchemasImportPath } from './schemas-options';
4+
5+
describe('getSchemasImportPath', () => {
6+
it('returns importPath when schemas is an object with importPath', () => {
7+
expect(
8+
getSchemasImportPath({
9+
path: '/libs/models',
10+
type: 'typescript',
11+
importPath: '@acme/models',
12+
}),
13+
).toBe('@acme/models');
14+
});
15+
16+
it('returns undefined when schemas is an object without importPath', () => {
17+
expect(
18+
getSchemasImportPath({ path: '/libs/models', type: 'typescript' }),
19+
).toBeUndefined();
20+
});
21+
22+
it('returns undefined when schemas is a string', () => {
23+
expect(getSchemasImportPath('./models')).toBeUndefined();
24+
});
25+
26+
it('returns undefined when schemas is undefined', () => {
27+
expect(getSchemasImportPath()).toBeUndefined();
28+
});
29+
30+
it('returns undefined when schemas is false', () => {
31+
expect(getSchemasImportPath(false)).toBeUndefined();
32+
});
33+
});

0 commit comments

Comments
 (0)