Skip to content

Commit 9ef7809

Browse files
authored
fix(core): normalize allOf schemas with inline objects (orval-labs#2458) (orval-labs#2908)
1 parent d294285 commit 9ef7809

File tree

2 files changed

+222
-8
lines changed

2 files changed

+222
-8
lines changed
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import type { ContextSpec, OpenApiSchemaObject } from '../types';
4+
import { combineSchemas } from './combine';
5+
6+
const petSchema: OpenApiSchemaObject = {
7+
type: 'object',
8+
required: ['id', 'name', 'petType'],
9+
properties: {
10+
id: { type: 'integer' },
11+
name: { type: 'string' },
12+
petType: { type: 'string' },
13+
},
14+
};
15+
16+
const context: ContextSpec = {
17+
output: {
18+
override: {
19+
enumGenerationType: 'const',
20+
components: {
21+
schemas: { suffix: '', itemSuffix: 'Item' },
22+
responses: { suffix: '' },
23+
parameters: { suffix: '' },
24+
requestBodies: { suffix: 'RequestBody' },
25+
},
26+
},
27+
unionAddMissingProperties: false,
28+
},
29+
target: 'spec',
30+
workspace: '',
31+
spec: {
32+
components: {
33+
schemas: {
34+
Pet: petSchema,
35+
Base: {
36+
type: 'object',
37+
properties: {
38+
name: { type: 'string' },
39+
},
40+
},
41+
},
42+
},
43+
},
44+
} as ContextSpec;
45+
46+
describe('combineSchemas (allOf required handling)', () => {
47+
it('does not add Required<Pick> when required properties are defined on parent', () => {
48+
const schema: OpenApiSchemaObject = {
49+
type: 'object',
50+
required: ['name', 'sound'],
51+
properties: {
52+
name: { type: 'string' },
53+
sound: { type: 'string' },
54+
},
55+
allOf: [{ $ref: '#/components/schemas/Pet' }],
56+
};
57+
58+
const result = combineSchemas({
59+
schema,
60+
name: 'Dog',
61+
separator: 'allOf',
62+
context,
63+
nullable: '',
64+
});
65+
66+
expect(result.value).toContain('Pet &');
67+
expect(result.value).not.toContain('Required<Pick');
68+
});
69+
70+
it('keeps Required<Pick> when parent requires properties defined only in subschemas', () => {
71+
const schema: OpenApiSchemaObject = {
72+
type: 'object',
73+
required: ['name'],
74+
allOf: [{ $ref: '#/components/schemas/Base' }],
75+
};
76+
77+
const result = combineSchemas({
78+
schema,
79+
name: 'PetWrapper',
80+
separator: 'allOf',
81+
context,
82+
nullable: '',
83+
});
84+
85+
expect(result.value).toContain('Required<Pick');
86+
});
87+
88+
it('normalizes inline object in allOf to match parent object form', () => {
89+
const variantA: OpenApiSchemaObject = {
90+
allOf: [
91+
{ $ref: '#/components/schemas/Pet' },
92+
{
93+
type: 'object',
94+
required: ['name', 'sound'],
95+
properties: {
96+
name: { type: 'string' },
97+
sound: { type: 'string' },
98+
},
99+
},
100+
],
101+
};
102+
103+
const variantB: OpenApiSchemaObject = {
104+
type: 'object',
105+
required: ['name', 'sound'],
106+
properties: {
107+
name: { type: 'string' },
108+
sound: { type: 'string' },
109+
},
110+
allOf: [{ $ref: '#/components/schemas/Pet' }],
111+
};
112+
113+
const resultA = combineSchemas({
114+
schema: variantA,
115+
name: 'DogA',
116+
separator: 'allOf',
117+
context,
118+
nullable: '',
119+
});
120+
121+
const resultB = combineSchemas({
122+
schema: variantB,
123+
name: 'DogB',
124+
separator: 'allOf',
125+
context,
126+
nullable: '',
127+
});
128+
129+
expect(resultA.value).toBe(resultB.value);
130+
});
131+
});

packages/core/src/getters/combine.ts

Lines changed: 91 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { unique } from 'remeda';
1+
import { isNullish, unique } from 'remeda';
22

33
import { resolveExampleRefs, resolveObject } from '../resolvers';
44
import {
55
type ContextSpec,
66
EnumGeneration,
77
type GeneratorImport,
88
type GeneratorSchema,
9+
type OpenApiReferenceObject,
910
type OpenApiSchemaObject,
1011
type ScalarValue,
1112
SchemaType,
@@ -35,19 +36,85 @@ type CombinedData = {
3536
};
3637

3738
type Separator = 'allOf' | 'anyOf' | 'oneOf';
39+
const mergeableAllOfKeys = new Set(['type', 'properties', 'required']);
40+
41+
function isMergeableAllOfObject(schema: OpenApiSchemaObject): boolean {
42+
// Must have properties to be worth merging
43+
if (isNullish(schema.properties)) {
44+
return false;
45+
}
46+
47+
// Cannot merge if it contains nested composition
48+
if (schema.allOf || schema.anyOf || schema.oneOf) {
49+
return false;
50+
}
51+
52+
// Only object types can be merged
53+
if (!isNullish(schema.type) && schema.type !== 'object') {
54+
return false;
55+
}
56+
57+
// Only merge schemas with safe keys (type, properties, required)
58+
return Object.keys(schema).every((key) => mergeableAllOfKeys.has(key));
59+
}
60+
61+
function normalizeAllOfSchema(
62+
schema: OpenApiSchemaObject,
63+
): OpenApiSchemaObject {
64+
if (!schema.allOf) {
65+
return schema;
66+
}
67+
68+
let didMerge = false;
69+
const mergedProperties = { ...schema.properties };
70+
const mergedRequired = new Set(schema.required);
71+
const remainingAllOf: (OpenApiSchemaObject | OpenApiReferenceObject)[] = [];
72+
73+
for (const subSchema of schema.allOf) {
74+
if (isSchema(subSchema) && isMergeableAllOfObject(subSchema)) {
75+
didMerge = true;
76+
if (subSchema.properties) {
77+
Object.assign(mergedProperties, subSchema.properties);
78+
}
79+
if (subSchema.required) {
80+
for (const prop of subSchema.required) {
81+
mergedRequired.add(prop);
82+
}
83+
}
84+
continue;
85+
}
86+
87+
remainingAllOf.push(subSchema);
88+
}
89+
90+
if (!didMerge || remainingAllOf.length === 0) {
91+
return schema;
92+
}
93+
94+
return {
95+
...schema,
96+
...(Object.keys(mergedProperties).length > 0 && {
97+
properties: mergedProperties,
98+
}),
99+
...(mergedRequired.size > 0 && { required: [...mergedRequired] }),
100+
...(remainingAllOf.length > 0 && { allOf: remainingAllOf }),
101+
};
102+
}
38103

39104
interface CombineValuesOptions {
40105
resolvedData: CombinedData;
41106
resolvedValue?: ScalarValue;
42107
separator: Separator;
43108
context: ContextSpec;
109+
parentSchema?: OpenApiSchemaObject;
44110
}
45111

46112
function combineValues({
47113
resolvedData,
48114
resolvedValue,
49115
separator,
50116
context,
117+
parentSchema,
51118
}: CombineValuesOptions) {
52119
const isAllEnums = resolvedData.isEnum.every(Boolean);
53120

@@ -89,6 +156,10 @@ function combineValues({
89156
!resolvedData.originalSchema.some(
90157
(schema) =>
91158
schema?.properties?.[prop] && schema.required?.includes(prop),
159+
) &&
160+
!(
161+
parentSchema?.properties?.[prop] &&
162+
parentSchema.required?.includes(prop)
92163
),
93164
);
94165
if (overrideRequiredProperties.length > 0) {
@@ -147,9 +218,21 @@ export function combineSchemas({
147218
nullable: string;
148219
formDataContext?: FormDataContext;
149220
}): ScalarValue {
150-
const items = schema[separator] ?? [];
221+
// Normalize allOf schemas by merging inline objects into parent (fixes #2458)
222+
// Only applies when: using allOf, not in v7 compat mode, no sibling oneOf/anyOf
223+
const canMergeInlineAllOf =
224+
separator === 'allOf' &&
225+
!context.output.override.aliasCombinedTypes &&
226+
!schema.oneOf &&
227+
!schema.anyOf;
228+
229+
const normalizedSchema = canMergeInlineAllOf
230+
? normalizeAllOfSchema(schema)
231+
: schema;
232+
233+
const items = normalizedSchema[separator] ?? [];
151234

152-
const resolvedData: CombinedData[] = items.reduce<CombinedData>(
235+
const resolvedData: CombinedData = items.reduce<CombinedData>(
153236
(acc, subSchema) => {
154237
// aliasCombinedTypes (v7 compat): create intermediate types like ResponseAnyOf
155238
// v8 default: propName stays undefined so combined types are inlined directly
@@ -283,13 +366,14 @@ export function combineSchemas({
283366

284367
let resolvedValue: ScalarValue | undefined;
285368

286-
if (schema.properties) {
369+
if (normalizedSchema.properties) {
287370
resolvedValue = getScalar({
288371
item: Object.fromEntries(
289-
Object.entries(schema).filter(([key]) => key !== separator),
372+
Object.entries(normalizedSchema).filter(([key]) => key !== separator),
290373
),
291374
name,
292375
context,
376+
formDataContext,
293377
});
294378
} else if (separator === 'allOf' && (schema.oneOf || schema.anyOf)) {
295379
// Handle sibling pattern: allOf + oneOf/anyOf at same level
@@ -309,6 +393,7 @@ export function combineSchemas({
309393
separator,
310394
resolvedValue,
311395
context,
396+
parentSchema: normalizedSchema,
312397
});
313398

314399
return {
@@ -326,9 +411,7 @@ export function combineSchemas({
326411
type: 'object' as SchemaType,
327412
isRef: false,
328413
hasReadonlyProps:
329-
resolvedData?.hasReadonlyProps ||
330-
resolvedValue?.hasReadonlyProps ||
331-
false,
414+
resolvedData.hasReadonlyProps || resolvedValue?.hasReadonlyProps || false,
332415
example: schema.example,
333416
examples: resolveExampleRefs(schema.examples, context),
334417
};

0 commit comments

Comments
 (0)