Skip to content

Commit b3b3e79

Browse files
authored
fix(core): resolve required keys through nested allOf $ref compositions (#3749)
1 parent 5516fb9 commit b3b3e79

10 files changed

Lines changed: 627 additions & 11 deletions

File tree

packages/core/src/getters/combine.test.ts

Lines changed: 323 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,9 +130,329 @@ describe('combineSchemas (allOf required handling)', () => {
130130
nullable: '',
131131
});
132132

133-
expect(result.value).toContain(
134-
"Required<Pick<MidWrapper, Extract<keyof (MidWrapper), 'baseProp'>>>",
135-
);
133+
expect(result.value).toContain("Required<Pick<MidWrapper, 'baseProp'>>");
134+
expect(result.value).not.toContain('Extract<');
135+
});
136+
137+
// #3748: required keys whose properties live two $ref hops away (the
138+
// referenced schema is itself an allOf composition) must resolve to a plain
139+
// Required<Pick>. The Extract guard is not equivalent here: with
140+
// `additionalProperties: true` the index signature collapses
141+
// `Extract<keyof T, K>` to `never`, silently dropping the required override.
142+
it('resolves required keys defined in a nested allOf $ref composition (#3748)', () => {
143+
const contextWithNestedComposition = {
144+
...context,
145+
spec: {
146+
components: {
147+
schemas: {
148+
...context.spec.components!.schemas,
149+
Contents: {
150+
type: 'object',
151+
properties: {
152+
id: { type: 'string' },
153+
name: { type: 'string' },
154+
},
155+
additionalProperties: true,
156+
},
157+
ItemBase: {
158+
allOf: [
159+
{ $ref: '#/components/schemas/Contents' },
160+
{
161+
type: 'object',
162+
properties: {
163+
status: { type: 'string' },
164+
},
165+
required: ['status'],
166+
additionalProperties: true,
167+
},
168+
],
169+
additionalProperties: true,
170+
},
171+
},
172+
},
173+
},
174+
} as unknown as ContextSpec;
175+
176+
const schema: OpenApiSchemaObject = {
177+
allOf: [
178+
{ $ref: '#/components/schemas/ItemBase' },
179+
{
180+
type: 'object',
181+
properties: {
182+
extra: { type: 'number' },
183+
},
184+
required: ['id', 'name'],
185+
additionalProperties: true,
186+
},
187+
],
188+
additionalProperties: true,
189+
};
190+
191+
const result = combineSchemas({
192+
schema,
193+
name: 'ItemDetail',
194+
separator: 'allOf',
195+
context: contextWithNestedComposition,
196+
nullable: '',
197+
});
198+
199+
expect(result.value).toContain("'id' | 'name'>>");
200+
expect(result.value).toContain('Required<Pick<');
201+
expect(result.value).not.toContain('Extract<');
202+
});
203+
204+
// Keys reached through a nullable node must stay Extract-guarded: the
205+
// emitted type unions `| null`, so `keyof` is `never` and a plain
206+
// Required<Pick> would fail with TS2344.
207+
it('keeps Extract guard when the nested composition member is nullable', () => {
208+
const contextWithNullableBase = {
209+
...context,
210+
spec: {
211+
components: {
212+
schemas: {
213+
...context.spec.components!.schemas,
214+
NullableBase: {
215+
type: ['object', 'null'],
216+
properties: {
217+
id: { type: 'string' },
218+
},
219+
},
220+
NullableWrapper: {
221+
allOf: [{ $ref: '#/components/schemas/NullableBase' }],
222+
},
223+
},
224+
},
225+
},
226+
} as unknown as ContextSpec;
227+
228+
const schema: OpenApiSchemaObject = {
229+
type: 'object',
230+
required: ['id'],
231+
allOf: [{ $ref: '#/components/schemas/NullableWrapper' }],
232+
};
233+
234+
const result = combineSchemas({
235+
schema,
236+
name: 'NullableItem',
237+
separator: 'allOf',
238+
context: contextWithNullableBase,
239+
nullable: '',
240+
});
241+
242+
expect(result.value).toContain("Extract<keyof (NullableWrapper), 'id'>");
243+
expect(result.value).not.toContain("Pick<NullableWrapper, 'id'>>");
244+
});
245+
246+
// Same reasoning for enum-bearing nodes: the emission is a literal union,
247+
// so property keys collected from the node are not in `keyof`.
248+
it('keeps Extract guard when the nested composition member carries an enum', () => {
249+
const contextWithEnumBase = {
250+
...context,
251+
spec: {
252+
components: {
253+
schemas: {
254+
...context.spec.components!.schemas,
255+
EnumBase: {
256+
type: 'object',
257+
enum: [{ id: 'a' }, { id: 'b' }],
258+
properties: {
259+
id: { type: 'string' },
260+
},
261+
},
262+
EnumWrapper: {
263+
allOf: [{ $ref: '#/components/schemas/EnumBase' }],
264+
},
265+
},
266+
},
267+
},
268+
} as unknown as ContextSpec;
269+
270+
const schema: OpenApiSchemaObject = {
271+
type: 'object',
272+
required: ['id'],
273+
allOf: [{ $ref: '#/components/schemas/EnumWrapper' }],
274+
};
275+
276+
const result = combineSchemas({
277+
schema,
278+
name: 'EnumItem',
279+
separator: 'allOf',
280+
context: contextWithEnumBase,
281+
nullable: '',
282+
});
283+
284+
expect(result.value).toContain("Extract<keyof (EnumWrapper), 'id'>");
285+
expect(result.value).not.toContain("Pick<EnumWrapper, 'id'>>");
286+
});
287+
288+
// A `$ref` member can carry union-producing siblings (`nullable: true`,
289+
// `type: ['object', 'null']`) that the resolver merges into the emission
290+
// (`Wrapper = Base | null`), so the ref-site object must pass the same
291+
// union guard as inline nodes before dereferencing.
292+
it('keeps Extract guard when a nested $ref member carries a nullable sibling', () => {
293+
const contextWithNullableRefSite = {
294+
...context,
295+
spec: {
296+
components: {
297+
schemas: {
298+
...context.spec.components!.schemas,
299+
RefSiteBase: {
300+
type: 'object',
301+
properties: {
302+
id: { type: 'string' },
303+
},
304+
},
305+
RefSiteWrapper: {
306+
allOf: [
307+
{ $ref: '#/components/schemas/RefSiteBase', nullable: true },
308+
],
309+
},
310+
},
311+
},
312+
},
313+
} as unknown as ContextSpec;
314+
315+
const schema: OpenApiSchemaObject = {
316+
type: 'object',
317+
required: ['id'],
318+
allOf: [{ $ref: '#/components/schemas/RefSiteWrapper' }],
319+
};
320+
321+
const result = combineSchemas({
322+
schema,
323+
name: 'RefSiteItem',
324+
separator: 'allOf',
325+
context: contextWithNullableRefSite,
326+
nullable: '',
327+
});
328+
329+
expect(result.value).toContain("Extract<keyof (RefSiteWrapper), 'id'>");
330+
expect(result.value).not.toContain("Pick<RefSiteWrapper, 'id'>>");
331+
});
332+
333+
// A multi-entry type array unions its branches even without `null`
334+
// (`type: ['object', 'string']` emits `{...} | string`), so keys from such
335+
// nodes are not guaranteed in `keyof` either.
336+
it('keeps Extract guard when the nested composition member has a non-null type array union', () => {
337+
const contextWithMixedType = {
338+
...context,
339+
spec: {
340+
components: {
341+
schemas: {
342+
...context.spec.components!.schemas,
343+
MixedBase: {
344+
type: ['object', 'string'],
345+
properties: {
346+
id: { type: 'string' },
347+
},
348+
},
349+
MixedWrapper: {
350+
allOf: [{ $ref: '#/components/schemas/MixedBase' }],
351+
},
352+
},
353+
},
354+
},
355+
} as unknown as ContextSpec;
356+
357+
const schema: OpenApiSchemaObject = {
358+
type: 'object',
359+
required: ['id'],
360+
allOf: [{ $ref: '#/components/schemas/MixedWrapper' }],
361+
};
362+
363+
const result = combineSchemas({
364+
schema,
365+
name: 'MixedItem',
366+
separator: 'allOf',
367+
context: contextWithMixedType,
368+
nullable: '',
369+
});
370+
371+
expect(result.value).toContain("Extract<keyof (MixedWrapper), 'id'>");
372+
expect(result.value).not.toContain("Pick<MixedWrapper, 'id'>>");
373+
});
374+
375+
// Any anyOf/oneOf on a walked node is treated as union emission: deep key
376+
// collection is restricted to plain object/intersection shapes.
377+
it('keeps Extract guard when the nested composition member carries a oneOf', () => {
378+
const contextWithOneOf = {
379+
...context,
380+
spec: {
381+
components: {
382+
schemas: {
383+
...context.spec.components!.schemas,
384+
OneOfBase: {
385+
properties: {
386+
id: { type: 'string' },
387+
},
388+
oneOf: [{ type: 'object' }, { type: 'string' }],
389+
},
390+
OneOfWrapper: {
391+
allOf: [{ $ref: '#/components/schemas/OneOfBase' }],
392+
},
393+
},
394+
},
395+
},
396+
} as unknown as ContextSpec;
397+
398+
const schema: OpenApiSchemaObject = {
399+
type: 'object',
400+
required: ['id'],
401+
allOf: [{ $ref: '#/components/schemas/OneOfWrapper' }],
402+
};
403+
404+
const result = combineSchemas({
405+
schema,
406+
name: 'OneOfItem',
407+
separator: 'allOf',
408+
context: contextWithOneOf,
409+
nullable: '',
410+
});
411+
412+
expect(result.value).toContain("Extract<keyof (OneOfWrapper), 'id'>");
413+
expect(result.value).not.toContain("Pick<OneOfWrapper, 'id'>>");
414+
});
415+
416+
it('terminates on cyclic allOf $ref compositions', () => {
417+
const contextWithCycle = {
418+
...context,
419+
spec: {
420+
components: {
421+
schemas: {
422+
...context.spec.components!.schemas,
423+
CycleA: {
424+
properties: {
425+
aProp: { type: 'string' },
426+
},
427+
allOf: [{ $ref: '#/components/schemas/CycleB' }],
428+
},
429+
CycleB: {
430+
properties: {
431+
bProp: { type: 'string' },
432+
},
433+
allOf: [{ $ref: '#/components/schemas/CycleA' }],
434+
},
435+
},
436+
},
437+
},
438+
} as unknown as ContextSpec;
439+
440+
const schema: OpenApiSchemaObject = {
441+
type: 'object',
442+
required: ['bProp'],
443+
allOf: [{ $ref: '#/components/schemas/CycleA' }],
444+
};
445+
446+
const result = combineSchemas({
447+
schema,
448+
name: 'CycleItem',
449+
separator: 'allOf',
450+
context: contextWithCycle,
451+
nullable: '',
452+
});
453+
454+
expect(result.value).toContain("'bProp'>>");
455+
expect(result.value).not.toContain('Extract<');
136456
});
137457

138458
it('uses Extract guard for required ghost keys missing from all subschema properties', () => {

0 commit comments

Comments
 (0)