Skip to content

Commit 162a96c

Browse files
authored
feat(schemas): add splitByTags option to organize schemas by tag (#3595)
* feat(schemas): add splitByTags option to organize schemas by tag Adds schemas.splitByTags and schemas.sharedDirName options that organize generated schema files into per-tag subdirectories when using output.mode: 'tags-split'. Schemas referenced by multiple tags are placed in a shared directory (default '_shared'). Closes #3592 * fix(schemas): allow splitByTags with schemas.importPath The importPath guard was unnecessary — when indexFiles is true (the default), operation and factory files import schemas through the barrel index, which re-exports tag subdirectories. The importPath option just replaces the barrel specifier (e.g. @acme/models instead of ../model). * refactor(types): make splitByTags required in NormalizedSchemaOptions normalizeSchemasOption always materializes splitByTags as a boolean, so the optional designation weakened type safety for downstream consumers. * feat(schemas): support splitByTags with zod schemas Thread schemaTagMap through writeZodSchemas, writeZodSchemasReusable, and writeZodSchemasFromVerbs so zod schemas are organized into per-tag subdirectories the same way TypeScript schemas are. - buildSiblingImports computes cross-directory paths when schemaTagMap is set - Verb schemas are routed to their operation's tag directory - writeZodSchemaTagsSplitBarrel writes per-tag + root barrel indexes - Remove zod rejection guard from write-specs.ts * fix(zod): use getImportExtension for NodeNext in write-zod-specs Replace fileExtension.replace(/\.ts$/, '') with getImportExtension at all 4 remaining sites in write-zod-specs.ts: - writeZodSchemaIndex: add tsconfig param, use getImportExtension - writeZodSchemaTagsSplitBarrel: fix importExt (was using old pattern alongside getImportExtension for indexImportExt in the same function) - writeZodSchemasReusable: use output.tsconfig - writeZodSchemasFromVerbs: use output.tsconfig Add tsconfig?: Tsconfig to WriteZodOutputOptions interface and pass output.tsconfig from all writeZodSchemaIndex call sites. Aligns with #3603 which applies the same fix to the non-zod writers. * refactor(hono): pass full handler file path to generateModuleSpecifier Construct the complete handler file path (including tag name and extension) upfront and pass it to generateModuleSpecifier, instead of passing only the directory and manually appending the filename and extension afterward. Matches the pattern already used by the per-operation handler block above. * fix(hono): construct composite route handler imports per output mode Handler files are written flat (tag.handlers.ts) in tags mode but in a subdirectory (tag/tag.handlers.ts) in tags-split mode. The composite route import construction assumed tags-split layout unconditionally, producing wrong import paths for tags mode. * fix(zod): handle ../ mutator paths + cleanup test fixtures + import SHARED_DIR - adjustMutatorPathForDir: prepend ../ to ../-prefixed mutator paths to account for tag subdirectory depth (previously only handled ./-prefixed) - schemas-tags-split: import SHARED_DIR from schema-tag-mapper instead of redefining as local ROOT constant (single source of truth) - schemas-tags-split.test: centralize temp dir cleanup in afterEach hook so cleanup runs even when assertions throw - schema-tag-mapper.test: add regression test verifying imports are matched by GeneratorImport.name (TS identifier) not schemaName * test(schemas): add #3592 splitByTags regression tests Pin the end-to-end behaviors of schemas.splitByTags that the snapshot suite alone cannot express intent for: - tags-split + splitByTags places per-tag schemas under <tag>/ subdirs with their own barrels, while cross-tag-shared schemas stay at the model root. Pagination is the interesting case: shared indirectly via PetList and StoreList, both tag-scoped. - Endpoint and faker files import via the '../model' barrel rather than reaching into a specific tag subdir, keeping tag files agnostic to where a schema physically lives. - mode: 'split' + splitByTags produces the same per-tag layout; the combined endpoints.ts imports every cross-tag schema from the single './model' barrel. Mirrors the style of the #3596 regression tests added in 76de240. * fix(schemas): reject splitByTags + indexFiles:false When schemas are split by tag and there is no root barrel, operation files (pets/pets.ts) and the consolidated <schemas>/index.faker.ts file both emit schema imports that don't resolve. Operation files import '../model/pet' when the file lives at '../model/pets/pet.ts'; the faker factory file imports types from '.' when no root index.ts exists. The root cause is that generateImportsForBuilder (used by every mode writer) and writeFakerSchemaMocks both assume a flat schemas directory or a root barrel. Neither consults the schema-to-tag map computed by writeSchemasTagsSplit. Reject the combination at config normalization time until the import resolvers are taught to honor splitByTags directly. Tracked as a follow-up. - packages/orval/src/utils/options.ts: throw when schemas.splitByTags + !indexFiles. - packages/orval/src/utils/options.test.ts: rejection test plus two positive cases (splitByTags + indexFiles:true accepted; indexFiles:false alone accepted). - docs/content/docs/reference/configuration/output.mdx: note the indexFiles requirement under splitByTags. * fix(schemas): support splitByTags with indexFiles:false When schemas are split by tag and there is no root barrel, operation files (pets/pets.ts) and the consolidated <schemas>/index.faker.ts file both previously emitted schema imports that didn't resolve. Operation files imported '../model/pet' when the file lived at '../model/pets/pet.ts'; the faker factory file imported types from '.' when no root index.ts existed. The root cause was that generateImportsForBuilder (used by every mode writer) and writeFakerSchemaMocks both assumed a flat schemas directory or a root barrel. Neither consulted the schema-to-tag map computed by writeSchemasTagsSplit. Compute the schema-to-tag map once in writeSpecs and thread it through: - WriteModeProps: new optional schemaTagMap field - generateImportsForBuilder: optional schemaTagMap param; the indexFiles:false branch routes each import into its tag subdir (or keeps shared schemas at the schemas root) - single-mode / split-mode / tags-mode / split-tags-mode: forward schemaTagMap at all 13 generateImportsForBuilder call sites - writeFakerSchemaMocks: same per-schema routing for the consolidated index.faker.ts file under indexFiles:false Removes the validation guard that previously rejected the combination, since the underlying import resolution now handles it correctly. - packages/core/src/types.ts: add schemaTagMap? to WriteModeProps - packages/core/src/writers/generate-imports-for-builder.ts: optional schemaTagMap param + tag-aware routing in indexFiles:false branch - packages/core/src/writers/{single,split,tags,split-tags}-mode.ts: destructure schemaTagMap from WriteModeProps, forward to call sites - packages/orval/src/write-specs.ts: hoist schemaTagMap computation to writeSpecs scope; thread to writeFakerSchemaMocks and writeMode; per-schema routing in writeFakerSchemaMocks under indexFiles:false - packages/orval/src/utils/options.ts: remove the guard - packages/orval/src/utils/options.test.ts: remove guard tests - packages/core/src/writers/generate-imports-for-builder.test.ts: add splitByTags: false to two #3618 schemasImportPath test cases that now require it (those test cases predate splitByTags becoming required in NormalizedSchemaOptions) - docs/content/docs/reference/configuration/output.mdx: drop the indexFiles:true requirement note under splitByTags - tests/configs/axios.config.ts: new splitByTagsFakerSchemasNoIndex config exercising the previously-broken combination - tests/__snapshots__/axios/split-by-tags-faker-schemas-no-index/: snapshots for the new config - tests/api-generation.spec.ts: 3 regression tests pinning per-tag import paths for operation files, faker factory file, and tag isolation * fix(schemas): route splitByTags imports by TS identifier, not schemaName generateImportsForBuilder's splitByTags tag lookup was using `baseName` (which prefers `schemaImport.schemaName` over `.name`) to key into the schema-to-tag map. The map is built by `buildSchemaTagMap`, which keys exclusively on `schema.name` — the pascal-cased TS identifier produced by `getRefInfo`. When an import's `schemaName` differs from its TS `name` (e.g. `PetSchema` vs `Pet`), the lookup silently missed and the import was placed at the schemas root instead of its tag subdirectory. Use `schemaImport.name` directly for the tag lookup. The filename computation still uses `baseName` (preferring `schemaName`), which is unchanged from the existing flat-layout behavior — `conventionName` is idempotent on already-pascal-cased input in the common case. - packages/core/src/writers/generate-imports-for-builder.ts: lookup by `schemaImport.name`; clarify the comment. - packages/core/src/writers/generate-imports-for-builder.test.ts: two new tests pinning tag routing — one with `name`/`schemaName` differing to exercise the lookup, one with a missing entry to exercise the root fallback. * fix(schemas): address review findings on cleanup hook and dirSchemas skip Two issues found in review: 1. `schemas-tags-split.test.ts`: the outer-scope `dir` was shadowed by per-test `const dir` declarations, so the `afterEach` cleanup hook never removed temp directories. Replaced `const dir = await tmpDir()` with `dir = await tmpDir()` so the outer variable is populated. 2. `write-zod-specs.ts`: the dirSchemas loop in `writeZodSchemasFromVerbs` iterated `uniqueVerbsSchemas` directly, but the writing loop above skips pure-$ref wrappers via `continue` when `useReusableSchemas` is on. Skipped entries still landed in dirSchemas, so the tag barrel re-exported files that were never written. Replicated the skip condition in the dirSchemas loop with a comment explaining why. * test(schemas): prune redundant splitByTags config and fold symmetry test `petstoreTagsSplitSchemas` generated 27 snapshot files but no test in `api-generation.spec.ts` actually inspected any of them — the cross- tag split logic was already covered by `tagsSplitSharedModels` with explicit assertions. Removed the config block and its snapshots. Also folded the standalone 'stores files do not import peer tag subdirectories' test into the operation-imports test as additional assertions — same coverage, one less test. Updated existing snapshots for the v8.18.0 version bump brought in by the master rebase. * test(schemas): complete unresolved .resolves assertions in splitByTags test Three `await expect(readFile(...)).resolves;` chains had no trailing matcher, so the promise was awaited but no assertion ran. The file- exists intent only surfaced via promise rejection. Added `.toBeDefined()` to make the assertion explicit. The broader readFile-vs-pathExists inconsistency is tracked in #3623.
1 parent 60b84f2 commit 162a96c

108 files changed

Lines changed: 3904 additions & 77 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

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

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,12 @@ export default defineConfig({
111111
});
112112
```
113113

114-
| Property | Type | Description |
115-
| ---------- | -------- | ----------------------------------------------- |
116-
| `path` | `string` | Filesystem path for schema output |
117-
| `type` | `string` | `'typescript'` (default) or `'zod'` — optional |
118-
| `importPath` | `string` | Optional package import specifier (see below) |
114+
| Property | Type | Description |
115+
| ------------- | --------- | ----------------------------------------------- |
116+
| `path` | `string` | Filesystem path for schema output |
117+
| `type` | `string` | `'typescript'` (default) or `'zod'` — optional |
118+
| `importPath` | `string` | Optional package import specifier (see below) |
119+
| `splitByTags` | `boolean` | Organize schemas into per-tag subdirectories (default `false`, see below) |
119120

120121
### importPath
121122

@@ -173,6 +174,57 @@ import statements change.
173174
relative, or absolute paths) before generation runs — see
174175
[Validation of `importPath`](#validation-of-importpath) below.
175176

177+
### splitByTags
178+
179+
When `splitByTags` is `true`, schemas are organized into per-tag
180+
subdirectories instead of a single flat directory. Schemas referenced by
181+
only one tag go in that tag's directory; schemas referenced by multiple
182+
tags (or not referenced by any operation) remain at the root of the schema
183+
directory. Works with any `mode` (`single`, `split`, `tags`, `tags-split`).
184+
185+
```ts title="orval.config.ts"
186+
export default defineConfig({
187+
petstore: {
188+
output: {
189+
mode: 'tags-split',
190+
schemas: {
191+
path: './api/model',
192+
splitByTags: true,
193+
},
194+
},
195+
},
196+
});
197+
```
198+
199+
Result:
200+
201+
```
202+
api/model/
203+
├── error.ts ← schemas used by 2+ tags (or unreferenced)
204+
├── pagination.ts
205+
├── pets/ ← schemas only used by "pets" operations
206+
│ ├── pet.ts
207+
│ ├── createPetsBody.ts
208+
│ ├── listPetsParams.ts
209+
│ └── index.ts
210+
└── index.ts ← root barrel re-exporting shared files + tag dirs
211+
```
212+
213+
Cross-tag imports from within a tag subdirectory resolve to the parent:
214+
215+
```ts
216+
// pets/pet.ts
217+
import type { Error } from '../error';
218+
```
219+
220+
**Requirements:**
221+
222+
- Works with any `mode` (`single`, `split`, `tags`, `tags-split`).
223+
- Incompatible with `operationSchemas` — operation-derived types are placed
224+
within their tag directories automatically.
225+
- Schema-to-tag mapping is transitive: if `Pet` imports `Dog` which imports
226+
`Dachshund`, all three land in the same directory.
227+
176228
### Validation of `importPath`
177229

178230
During config normalization, orval rejects the following `importPath` values
@@ -352,6 +404,28 @@ my-app/src/
352404
└── users.ts ← imports from ../models
353405
```
354406

407+
To organize schemas into per-tag subdirectories instead of a flat directory, use [`splitByTags`](#splitbytags):
408+
409+
```
410+
my-app/src/
411+
├── models/
412+
│ ├── pagination.ts ← shared schemas at root
413+
│ ├── error.ts
414+
│ ├── pets/
415+
│ │ ├── pet.ts
416+
│ │ ├── listPetsParams.ts
417+
│ │ └── index.ts
418+
│ ├── users/
419+
│ │ ├── user.ts
420+
│ │ ├── listUsersParams.ts
421+
│ │ └── index.ts
422+
│ └── index.ts ← root barrel
423+
├── pets/
424+
│ └── pets.ts
425+
└── users/
426+
└── users.ts
427+
```
428+
355429
## baseUrl
356430

357431
**Type:** `String | Object`

packages/core/src/types.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export interface NormalizedOptions {
3636
export interface NormalizedOutputOptions {
3737
workspace?: string;
3838
target: string;
39-
schemas?: string | SchemaOptions;
39+
schemas?: string | NormalizedSchemaOptions;
4040
operationSchemas?: string;
4141
namingConvention: NamingConvention;
4242
fileExtension: string;
@@ -311,12 +311,21 @@ export interface SchemaOptions {
311311
path: string;
312312
type?: SchemaGenerationType;
313313
importPath?: string;
314+
/**
315+
* When `true`, schemas are organized into per-tag subdirectories instead of
316+
* a single flat directory. Schemas referenced by multiple tags remain at the
317+
* root of the schema directory.
318+
*
319+
* @default false
320+
*/
321+
splitByTags?: boolean;
314322
}
315323

316324
export interface NormalizedSchemaOptions {
317325
path: string;
318326
type: SchemaGenerationType;
319327
importPath?: string;
328+
splitByTags: boolean;
320329
}
321330

322331
export interface OutputOptions {
@@ -1783,6 +1792,14 @@ export interface WriteModeProps {
17831792
header: string;
17841793
needSchema: boolean;
17851794
generateSchemasInline?: () => string;
1795+
// Schema-to-tag map computed by `writeSpecs` when `schemas.splitByTags` is
1796+
// enabled. Mode writers forward it to `generateImportsForBuilder` so the
1797+
// `indexFiles: false` branch can route each schema import into its tag
1798+
// subdirectory instead of assuming a flat layout. `undefined` when
1799+
// `splitByTags` is disabled, in which case routing falls back to the flat
1800+
// layout. The `'.'` sentinel marks schemas referenced by 0 or 2+ tags
1801+
// (shared, kept at the schemas root).
1802+
schemaTagMap?: Map<string, string>;
17861803
}
17871804

17881805
export interface GeneratorApiOperations {

packages/core/src/utils/schemas-options.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,18 @@ describe('getSchemasImportPath', () => {
99
path: '/libs/models',
1010
type: 'typescript',
1111
importPath: '@acme/models',
12+
splitByTags: false,
1213
}),
1314
).toBe('@acme/models');
1415
});
1516

1617
it('returns undefined when schemas is an object without importPath', () => {
1718
expect(
18-
getSchemasImportPath({ path: '/libs/models', type: 'typescript' }),
19+
getSchemasImportPath({
20+
path: '/libs/models',
21+
type: 'typescript',
22+
splitByTags: false,
23+
}),
1924
).toBeUndefined();
2025
});
2126

packages/core/src/writers/generate-imports-for-builder.test.ts

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ describe('generateImportsForBuilder', () => {
122122
const output = createMockOutput({
123123
indexFiles: false,
124124
fileExtension: '.gen.ts',
125-
schemas: { path: './schemas', type: 'zod' },
125+
schemas: { path: './schemas', type: 'zod', splitByTags: false },
126126
});
127127
const imports = [createMockImport('User')];
128128

@@ -140,7 +140,7 @@ describe('generateImportsForBuilder', () => {
140140
const output = createMockOutput({
141141
indexFiles: false,
142142
fileExtension: '.ts',
143-
schemas: { path: './schemas', type: 'zod' },
143+
schemas: { path: './schemas', type: 'zod', splitByTags: false },
144144
});
145145
const imports = [
146146
createMockImport('PortfolioResponseSchema', 'PortfolioResponse'),
@@ -184,7 +184,7 @@ describe('generateImportsForBuilder', () => {
184184
const output = createMockOutput({
185185
indexFiles: true,
186186
fileExtension: '.gen.ts',
187-
schemas: { path: './schemas', type: 'zod' },
187+
schemas: { path: './schemas', type: 'zod', splitByTags: false },
188188
});
189189
const imports = [createMockImport('User')];
190190

@@ -208,6 +208,7 @@ describe('generateImportsForBuilder', () => {
208208
path: '/libs/models',
209209
type: 'typescript',
210210
importPath: '@acme/models',
211+
splitByTags: false,
211212
},
212213
});
213214
const imports = [createMockImport('User'), createMockImport('Pet')];
@@ -230,6 +231,7 @@ describe('generateImportsForBuilder', () => {
230231
path: '/libs/models',
231232
type: 'typescript',
232233
importPath: '@acme/models',
234+
splitByTags: false,
233235
},
234236
});
235237
const imports = [createMockImport('User'), createMockImport('Pet')];
@@ -262,6 +264,7 @@ describe('generateImportsForBuilder', () => {
262264
path: '/libs/models',
263265
type: 'typescript',
264266
importPath: '@acme/models',
267+
splitByTags: false,
265268
},
266269
});
267270
const imports = [createMockImport('User')];
@@ -284,6 +287,7 @@ describe('generateImportsForBuilder', () => {
284287
path: '/libs/models',
285288
type: 'zod',
286289
importPath: '@acme/models',
290+
splitByTags: false,
287291
},
288292
});
289293
const imports = [createMockImport('User')];
@@ -306,6 +310,7 @@ describe('generateImportsForBuilder', () => {
306310
path: '/libs/models',
307311
type: 'typescript',
308312
importPath: '@acme/models',
313+
splitByTags: false,
309314
},
310315
});
311316
const imports: GeneratorImport[] = [
@@ -330,6 +335,7 @@ describe('generateImportsForBuilder', () => {
330335
path: '/libs/models',
331336
type: 'typescript',
332337
importPath: '@acme/models',
338+
splitByTags: false,
333339
},
334340
mock: {
335341
indexMockFiles: false,
@@ -368,6 +374,7 @@ describe('generateImportsForBuilder', () => {
368374
path: '/libs/models',
369375
type: 'typescript',
370376
importPath: '@acme/models',
377+
splitByTags: false,
371378
},
372379
mock: {
373380
indexMockFiles: false,
@@ -426,4 +433,48 @@ describe('generateImportsForBuilder', () => {
426433
]);
427434
});
428435
});
436+
437+
describe('splitByTags routing', () => {
438+
// `buildSchemaTagMap` keys on `schema.name`, which is the pascal-cased
439+
// TS identifier produced by `getRefInfo`. The lookup here must use
440+
// `schemaImport.name` (same identifier), not `schemaName` (the original
441+
// `components.schemas` key). When they differ, routing by `schemaName`
442+
// silently misses the map and places the import at the schemas root
443+
// instead of the tag subdirectory.
444+
it('routes by the TS identifier (name), not schemaName, when they differ', () => {
445+
const output = createMockOutput({ indexFiles: false });
446+
// `name: 'Pet'` is the TS identifier the map is keyed by.
447+
// `schemaName: 'PetSchema'` is the original components.schemas key.
448+
// The tag dir ('pets') must come from looking up `Pet`, not `PetSchema`
449+
// (which would miss the map and produce no tag segment).
450+
const imports = [createMockImport('Pet', 'PetSchema')];
451+
const schemaTagMap = new Map<string, string>([['Pet', 'pets']]);
452+
453+
const result = generateImportsForBuilder(
454+
output,
455+
imports,
456+
'../models',
457+
schemaTagMap,
458+
);
459+
460+
expect(result).toHaveProperty('0.dependency', '../models/pets/petSchema');
461+
});
462+
463+
it('inserts the tag subdir for matched schemas and leaves unmatched at root', () => {
464+
const output = createMockOutput({ indexFiles: false });
465+
// `Pet` is in the map; `Error` is not. Only `Pet` gets the tag segment.
466+
const imports = [createMockImport('Pet'), createMockImport('Error')];
467+
const schemaTagMap = new Map<string, string>([['Pet', 'pets']]);
468+
469+
const result = generateImportsForBuilder(
470+
output,
471+
imports,
472+
'../models',
473+
schemaTagMap,
474+
);
475+
476+
const deps = result.map((r) => r.dependency).sort();
477+
expect(deps).toEqual(['../models/error', '../models/pets/pet']);
478+
});
479+
});
429480
});

packages/core/src/writers/generate-imports-for-builder.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ export function generateImportsForBuilder(
2020
output: NormalizedOutputOptions,
2121
imports: readonly GeneratorImport[],
2222
relativeSchemasPath: string,
23+
// Schema→tag map computed by `writeSpecs` when `schemas.splitByTags` is
24+
// enabled. Used only in the `indexFiles: false` branch to insert each
25+
// schema's tag subdirectory into the import path. `'.'` is the sentinel
26+
// for shared schemas (referenced by 0 or 2+ tags).
27+
schemaTagMap?: Map<string, string>,
2328
): GeneratorDependency[] {
2429
const isPackageImport =
2530
isObject(output.schemas) && !!output.schemas.importPath;
@@ -93,9 +98,20 @@ export function generateImportsForBuilder(
9398
const importExtension = isPackageImport
9499
? ''
95100
: getImportExtension(output.fileExtension, output.tsconfig);
101+
// When schemas are split by tag, route each import into its tag
102+
// subdirectory. Schemas referenced by 0 or 2+ tags land at the schemas
103+
// root (sentinel `'.'`); their path is unchanged from the flat layout.
104+
// The lookup uses the TS identifier (`schemaImport.name`), not
105+
// `schemaName`, because `buildSchemaTagMap` keys on `schema.name`
106+
// which is the pascal-cased TS identifier produced by `getRefInfo`.
107+
// `baseName` (which prefers `schemaName`) is only correct for the
108+
// filename computation below, where `conventionName` happens to be
109+
// idempotent on already-pascal-cased input.
110+
const tagDir = schemaTagMap?.get(schemaImport.name);
111+
const tagSegment = tagDir && tagDir !== '.' ? `${tagDir}/` : '';
96112
const dependency = upath.joinSafe(
97113
relativeSchemasPath,
98-
`${normalizedName}${suffix}${importExtension}`,
114+
`${tagSegment}${normalizedName}${suffix}${importExtension}`,
99115
);
100116

101117
if (!importsByDependency.has(dependency)) {

packages/core/src/writers/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
export * from './file';
2+
export * from './schema-tag-mapper';
23
export * from './schemas';
4+
export * from './schemas-tags-split';
35
export * from './single-mode';
46
export * from './split-mode';
57
export * from './split-tags-mode';

0 commit comments

Comments
 (0)