Skip to content

Commit 1de3ed2

Browse files
feat(core): add operationSchemas option for schema separation (orval-labs#2785)
* feat(core): add operationSchemas option for schema separation Adds `operationSchemas` config option to write operation-derived types (params, bodies, responses) to a separate directory from regular schemas. This enables cleaner organization when generating large API clients where param/body/response types can overwhelm the core domain types. Schema detection uses patterns: *Params, *Body, *Parameter, *Query, *Header, *Response Usage: ```js output: { schemas: './models', operationSchemas: './models/params', } ``` Closes orval-labs#2783 * docs: add operationSchemas configuration option * fix: include operationSchemas in workspace index and formatter paths
1 parent 31bac35 commit 1de3ed2

File tree

6 files changed

+509
-14
lines changed

6 files changed

+509
-14
lines changed

docs/src/pages/reference/configuration/output.mdx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,43 @@ export default defineConfig({
8686
});
8787
```
8888

89+
## operationSchemas
90+
91+
Type: `String`.
92+
93+
Valid values: path to the folder where operation-derived types (params, bodies, responses) are written.
94+
95+
Default Value: `undefined` (all schemas written to `schemas` path).
96+
97+
When set, types matching operation patterns (`*Params`, `*Body`, `*Parameter`, `*Query`, `*Header`, `*Response`) are written to this path instead of the main `schemas` path. This helps organize large API clients by separating auto-generated operation types from core domain models.
98+
99+
```js
100+
import { defineConfig } from 'orval';
101+
102+
export default defineConfig({
103+
petstore: {
104+
output: {
105+
schemas: './api/model',
106+
operationSchemas: './api/model/params',
107+
},
108+
},
109+
});
110+
```
111+
112+
Result:
113+
114+
```
115+
api/model/
116+
├── user.ts # Domain models
117+
├── pet.ts
118+
├── index.ts
119+
└── params/
120+
├── getUserParams.ts # Operation types
121+
├── createPetBody.ts
122+
├── listPets200Response.ts
123+
└── index.ts
124+
```
125+
89126
## fileExtension
90127

91128
Type: `String`.

packages/core/src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export type NormalizedOutputOptions = {
2828
workspace?: string;
2929
target: string;
3030
schemas?: string | SchemaOptions;
31+
operationSchemas?: string;
3132
namingConvention: NamingConvention;
3233
fileExtension: string;
3334
mode: OutputMode;
@@ -215,6 +216,12 @@ export type OutputOptions = {
215216
workspace?: string;
216217
target: string;
217218
schemas?: string | SchemaOptions;
219+
/**
220+
* Separate path for operation-derived types (params, bodies, responses).
221+
* When set, types matching operation patterns (e.g., *Params, *Body) are written here
222+
* while regular schema types remain in the `schemas` path.
223+
*/
224+
operationSchemas?: string;
218225
namingConvention?: NamingConvention;
219226
fileExtension?: string;
220227
mode?: OutputMode;
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { type GeneratorSchema, NamingConvention } from '../types';
4+
import { fixCrossDirectoryImports, splitSchemasByType } from './schemas';
5+
6+
const createMockSchema = (name: string): GeneratorSchema => ({
7+
name,
8+
model: `export type ${name} = {};`,
9+
imports: [],
10+
schema: {},
11+
});
12+
13+
describe('splitSchemasByType', () => {
14+
it('should return empty arrays for empty input', () => {
15+
const result = splitSchemasByType([]);
16+
expect(result.regularSchemas).toEqual([]);
17+
expect(result.operationSchemas).toEqual([]);
18+
});
19+
20+
it('should classify *Params as operation schemas', () => {
21+
const schemas = [
22+
createMockSchema('GetUserParams'),
23+
createMockSchema('ListUsersParams'),
24+
createMockSchema('User'),
25+
];
26+
27+
const result = splitSchemasByType(schemas);
28+
29+
expect(result.operationSchemas.map((s) => s.name)).toEqual([
30+
'GetUserParams',
31+
'ListUsersParams',
32+
]);
33+
expect(result.regularSchemas.map((s) => s.name)).toEqual(['User']);
34+
});
35+
36+
it('should classify *Body as operation schemas', () => {
37+
const schemas = [
38+
createMockSchema('CreateUserBody'),
39+
createMockSchema('UpdatePostBody'),
40+
createMockSchema('Post'),
41+
];
42+
43+
const result = splitSchemasByType(schemas);
44+
45+
expect(result.operationSchemas.map((s) => s.name)).toEqual([
46+
'CreateUserBody',
47+
'UpdatePostBody',
48+
]);
49+
expect(result.regularSchemas.map((s) => s.name)).toEqual(['Post']);
50+
});
51+
52+
it('should classify *Parameter as operation schemas', () => {
53+
const schemas = [
54+
createMockSchema('PageParameter'),
55+
createMockSchema('LimitParameter'),
56+
createMockSchema('Pagination'),
57+
];
58+
59+
const result = splitSchemasByType(schemas);
60+
61+
expect(result.operationSchemas.map((s) => s.name)).toEqual([
62+
'PageParameter',
63+
'LimitParameter',
64+
]);
65+
expect(result.regularSchemas.map((s) => s.name)).toEqual(['Pagination']);
66+
});
67+
68+
it('should classify *Query as operation schemas', () => {
69+
const schemas = [
70+
createMockSchema('SearchQuery'),
71+
createMockSchema('FilterQuery'),
72+
createMockSchema('QueryResult'),
73+
];
74+
75+
const result = splitSchemasByType(schemas);
76+
77+
expect(result.operationSchemas.map((s) => s.name)).toEqual([
78+
'SearchQuery',
79+
'FilterQuery',
80+
]);
81+
// QueryResult doesn't end with Query, so it's a regular schema
82+
expect(result.regularSchemas.map((s) => s.name)).toEqual(['QueryResult']);
83+
});
84+
85+
it('should classify *Header as operation schemas', () => {
86+
const schemas = [
87+
createMockSchema('AuthHeader'),
88+
createMockSchema('ContentTypeHeader'),
89+
createMockSchema('HeaderInfo'),
90+
];
91+
92+
const result = splitSchemasByType(schemas);
93+
94+
expect(result.operationSchemas.map((s) => s.name)).toEqual([
95+
'AuthHeader',
96+
'ContentTypeHeader',
97+
]);
98+
// HeaderInfo doesn't end with Header, so it's regular
99+
expect(result.regularSchemas.map((s) => s.name)).toEqual(['HeaderInfo']);
100+
});
101+
102+
it('should classify *Response and *Response{N} as operation schemas', () => {
103+
const schemas = [
104+
createMockSchema('GetUser200Response'),
105+
createMockSchema('NotFoundResponse'),
106+
createMockSchema('ErrorResponse'),
107+
createMockSchema('UserResponseDto'),
108+
];
109+
110+
const result = splitSchemasByType(schemas);
111+
112+
expect(result.operationSchemas.map((s) => s.name)).toEqual([
113+
'GetUser200Response',
114+
'NotFoundResponse',
115+
'ErrorResponse',
116+
]);
117+
expect(result.regularSchemas.map((s) => s.name)).toEqual([
118+
'UserResponseDto',
119+
]);
120+
});
121+
122+
it('should be case-insensitive for pattern matching', () => {
123+
const schemas = [
124+
createMockSchema('getUserPARAMS'),
125+
createMockSchema('createUserBODY'),
126+
createMockSchema('User'),
127+
];
128+
129+
const result = splitSchemasByType(schemas);
130+
131+
expect(result.operationSchemas.map((s) => s.name)).toEqual([
132+
'getUserPARAMS',
133+
'createUserBODY',
134+
]);
135+
expect(result.regularSchemas.map((s) => s.name)).toEqual(['User']);
136+
});
137+
138+
it('should correctly separate mixed schema types', () => {
139+
const schemas = [
140+
createMockSchema('User'),
141+
createMockSchema('GetUserParams'),
142+
createMockSchema('Post'),
143+
createMockSchema('CreatePostBody'),
144+
createMockSchema('Comment'),
145+
createMockSchema('GetPosts200Response'),
146+
createMockSchema('Tag'),
147+
];
148+
149+
const result = splitSchemasByType(schemas);
150+
151+
expect(result.regularSchemas.map((s) => s.name)).toEqual([
152+
'User',
153+
'Post',
154+
'Comment',
155+
'Tag',
156+
]);
157+
expect(result.operationSchemas.map((s) => s.name)).toEqual([
158+
'GetUserParams',
159+
'CreatePostBody',
160+
'GetPosts200Response',
161+
]);
162+
});
163+
164+
it('should preserve schema properties when splitting', () => {
165+
const schema: GeneratorSchema = {
166+
name: 'GetUserParams',
167+
model: 'export type GetUserParams = { id: string };',
168+
imports: [{ name: 'SomeType', importPath: './some-type' }],
169+
schema: { type: 'object' },
170+
dependencies: ['SomeType'],
171+
};
172+
173+
const result = splitSchemasByType([schema]);
174+
175+
expect(result.operationSchemas[0]).toEqual(schema);
176+
});
177+
});
178+
179+
describe('fixCrossDirectoryImports', () => {
180+
const createSchemaWithImports = (
181+
name: string,
182+
importNames: string[],
183+
): GeneratorSchema => ({
184+
name,
185+
model: `export type ${name} = {};`,
186+
imports: importNames.map((n) => ({ name: n })),
187+
schema: {},
188+
});
189+
190+
it('should fix imports from operation schemas to regular schemas', () => {
191+
const opSchemas = [
192+
createSchemaWithImports('GetUserParams', ['User', 'OtherParam']),
193+
];
194+
const regularSchemaNames = new Set(['User']);
195+
196+
fixCrossDirectoryImports(
197+
opSchemas,
198+
regularSchemaNames,
199+
'./models',
200+
'./models/params',
201+
NamingConvention.CAMEL_CASE,
202+
);
203+
204+
expect(opSchemas[0].imports).toEqual([
205+
{ name: 'User', importPath: '../user' },
206+
{ name: 'OtherParam' },
207+
]);
208+
});
209+
210+
it('should handle deeper directory nesting', () => {
211+
const opSchemas = [createSchemaWithImports('GetUserParams', ['User'])];
212+
const regularSchemaNames = new Set(['User']);
213+
214+
fixCrossDirectoryImports(
215+
opSchemas,
216+
regularSchemaNames,
217+
'./src/models',
218+
'./src/models/api/params',
219+
NamingConvention.CAMEL_CASE,
220+
);
221+
222+
expect(opSchemas[0].imports[0].importPath).toBe('../../user');
223+
});
224+
225+
it('should respect naming convention', () => {
226+
const opSchemas = [createSchemaWithImports('GetUserParams', ['UserInfo'])];
227+
const regularSchemaNames = new Set(['UserInfo']);
228+
229+
fixCrossDirectoryImports(
230+
opSchemas,
231+
regularSchemaNames,
232+
'./models',
233+
'./models/params',
234+
NamingConvention.PASCAL_CASE,
235+
);
236+
237+
expect(opSchemas[0].imports[0].importPath).toBe('../UserInfo');
238+
});
239+
240+
it('should not modify imports that are not regular schemas', () => {
241+
const opSchemas = [
242+
createSchemaWithImports('GetUserParams', ['OtherParams', 'SomeBody']),
243+
];
244+
const regularSchemaNames = new Set(['User']);
245+
246+
fixCrossDirectoryImports(
247+
opSchemas,
248+
regularSchemaNames,
249+
'./models',
250+
'./models/params',
251+
NamingConvention.CAMEL_CASE,
252+
);
253+
254+
expect(opSchemas[0].imports).toEqual([
255+
{ name: 'OtherParams' },
256+
{ name: 'SomeBody' },
257+
]);
258+
});
259+
260+
it('should handle multiple schemas with multiple imports', () => {
261+
const opSchemas = [
262+
createSchemaWithImports('GetUserParams', ['User', 'Role']),
263+
createSchemaWithImports('CreatePostBody', ['Post', 'Author']),
264+
];
265+
const regularSchemaNames = new Set(['User', 'Post', 'Role']);
266+
267+
fixCrossDirectoryImports(
268+
opSchemas,
269+
regularSchemaNames,
270+
'./models',
271+
'./models/params',
272+
NamingConvention.CAMEL_CASE,
273+
);
274+
275+
expect(opSchemas[0].imports).toEqual([
276+
{ name: 'User', importPath: '../user' },
277+
{ name: 'Role', importPath: '../role' },
278+
]);
279+
expect(opSchemas[1].imports).toEqual([
280+
{ name: 'Post', importPath: '../post' },
281+
{ name: 'Author' },
282+
]);
283+
});
284+
});

0 commit comments

Comments
 (0)