Skip to content

Commit 7879bc4

Browse files
authored
fix(core): safely collect nested allOf properties (#3751)
1 parent b3b3e79 commit 7879bc4

55 files changed

Lines changed: 2324 additions & 39 deletions

Some content is hidden

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

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

Lines changed: 859 additions & 6 deletions
Large diffs are not rendered by default.

packages/core/src/getters/combine.ts

Lines changed: 289 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -123,32 +123,195 @@ function normalizeAllOfSchema(
123123
} as OpenApiSchemaObject;
124124
}
125125

126+
/** True when the schema node itself is not a single object shape. */
127+
function directlyEmitsNonObjectType(
128+
schema: OpenApiSchemaObject | OpenApiReferenceObject,
129+
): boolean {
130+
// Bridge assertions: AnyOtherAttribute infects all schema property access
131+
if (schema.enum || (schema.nullable as boolean | undefined) === true) {
132+
return true;
133+
}
134+
const type = schema.type as string | string[] | undefined;
135+
const isObjectType =
136+
!type ||
137+
type === 'object' ||
138+
(Array.isArray(type) && type.length === 1 && type[0] === 'object');
139+
if (!isObjectType) {
140+
return true;
141+
}
142+
return false;
143+
}
144+
145+
function isDirectlyNullable(
146+
schema: OpenApiSchemaObject | OpenApiReferenceObject,
147+
): boolean {
148+
if ((schema.nullable as boolean | undefined) === true) {
149+
return true;
150+
}
151+
const type = schema.type as string | string[] | undefined;
152+
return type === 'null' || (Array.isArray(type) && type.includes('null'));
153+
}
154+
155+
function directlyEmitsOnlyObjectOrNull(
156+
schema: OpenApiSchemaObject | OpenApiReferenceObject,
157+
): boolean {
158+
if (schema.enum) {
159+
return false;
160+
}
161+
const type = schema.type as string | string[] | undefined;
162+
const hasObjectType =
163+
!type ||
164+
type === 'object' ||
165+
(Array.isArray(type) && type.includes('object'));
166+
const hasOnlyObjectAndNull =
167+
!type ||
168+
type === 'object' ||
169+
(Array.isArray(type) &&
170+
type.every((memberType) => ['object', 'null'].includes(memberType)));
171+
return (
172+
hasObjectType &&
173+
hasOnlyObjectAndNull &&
174+
((schema.nullable as boolean | undefined) === true ||
175+
(Array.isArray(type) && type.includes('null')))
176+
);
177+
}
178+
179+
function isEnumMember(
180+
schema: OpenApiSchemaObject | OpenApiReferenceObject,
181+
context: ContextSpec,
182+
): boolean {
183+
return resolveObject({ schema, combined: true, context }).isEnum;
184+
}
185+
186+
function hasAllEnumMembers(
187+
schema: OpenApiSchemaObject | OpenApiReferenceObject,
188+
context: ContextSpec,
189+
): boolean {
190+
if (isReference(schema)) {
191+
return false;
192+
}
193+
const compositions = [schema.allOf, schema.oneOf, schema.anyOf] as (
194+
| (OpenApiSchemaObject | OpenApiReferenceObject)[]
195+
| undefined
196+
)[];
197+
return compositions.some(
198+
(members) =>
199+
!!members?.length &&
200+
members.every((member) => isEnumMember(member, context)),
201+
);
202+
}
203+
204+
function usesCanonicalNullableOneOfObject(
205+
schema: OpenApiSchemaObject | OpenApiReferenceObject,
206+
): boolean {
207+
if (isReference(schema)) {
208+
return false;
209+
}
210+
const members = schema.oneOf as
211+
| (OpenApiSchemaObject | OpenApiReferenceObject)[]
212+
| undefined;
213+
if (!members) {
214+
return false;
215+
}
216+
const isNullMember = (
217+
member: OpenApiSchemaObject | OpenApiReferenceObject,
218+
): boolean => {
219+
if (isReference(member)) {
220+
return false;
221+
}
222+
const type = member.type as string | string[] | undefined;
223+
return (
224+
type === 'null' ||
225+
(Array.isArray(type) && type.length === 1 && type[0] === 'null')
226+
);
227+
};
228+
const nonNullMembers = members.filter((member) => !isNullMember(member));
229+
const nonNullMember = nonNullMembers[0];
230+
if (
231+
!members.some(isNullMember) ||
232+
nonNullMembers.length !== 1 ||
233+
!nonNullMember ||
234+
isReference(nonNullMember)
235+
) {
236+
return false;
237+
}
238+
const type = nonNullMember.type as string | string[] | undefined;
239+
const properties = nonNullMember.properties as
240+
| Record<string, unknown>
241+
| undefined;
242+
return (
243+
(type === 'object' || (!type && !!properties)) &&
244+
!!properties &&
245+
Object.keys(properties).length > 0
246+
);
247+
}
248+
126249
/**
127-
* True when the schema's emitted type may union something other than a single
128-
* object shape — a `null` branch, an enum literal union, a multi-entry type
129-
* array (`type: ['object', 'string']` emits `{...} | string`), or any
130-
* anyOf/oneOf variants. Property keys collected from such a node are not
131-
* guaranteed in `keyof` of the emitted type, so a plain `Required<Pick<T, 'k'>>`
132-
* on them could fail with TS2344; callers must leave those keys to the
133-
* `Extract` guard instead. Also applied to `$ref`-site objects, whose
134-
* `nullable`/type-array siblings are merged into the emission by the resolver.
250+
* True when this node can emit a branch that also omits keys contributed by
251+
* its `allOf` descendants. Direct non-object output escapes the full
252+
* intersection. Direct inline anyOf nullability does so only when `resolveValue`
253+
* resolves this node through a component `$ref` and appends `| null` to the
254+
* imported alias. Canonical nullable oneOf and all-enum sibling compositions
255+
* only make the node's own properties unsafe: `combineSchemas` still
256+
* intersects every `allOf` member with their emitted union. A non-null object
257+
* sibling or guaranteed properties on the parent also eliminate an
258+
* object-or-null member's null branch from the parent intersection, preserving
259+
* that member's object keys.
135260
*/
136-
function emitsUnionType(
261+
function cannotGuaranteeAllOfPropertyKeys(
137262
schema: OpenApiSchemaObject | OpenApiReferenceObject,
263+
crossesComponentRefBoundary: boolean,
264+
nullBranchesEliminated = false,
138265
): boolean {
139-
// Bridge assertions: AnyOtherAttribute infects all schema property access
140266
if (
141-
schema.enum ||
142-
schema.anyOf ||
143-
schema.oneOf ||
144-
(schema.nullable as boolean | undefined) === true
267+
directlyEmitsNonObjectType(schema) &&
268+
!(nullBranchesEliminated && directlyEmitsOnlyObjectOrNull(schema))
145269
) {
146270
return true;
147271
}
148-
const type = schema.type as string | string[] | undefined;
272+
const anyOfMembers = (schema.anyOf ?? []) as (
273+
| OpenApiSchemaObject
274+
| OpenApiReferenceObject
275+
)[];
276+
return anyOfMembers.some(
277+
(member) =>
278+
crossesComponentRefBoundary &&
279+
!nullBranchesEliminated &&
280+
!isReference(member) &&
281+
isDirectlyNullable(member),
282+
);
283+
}
284+
285+
/**
286+
* True when this node's own property keys are not guaranteed in `keyof` of the
287+
* referenced output. Nullable, enum, scalar, array, or mixed-type nodes fail
288+
* directly; a missing type and OAS 3.1 `type: ['object']` remain object-capable.
289+
*
290+
* anyOf/oneOf members otherwise remain safe because `combineSchemas`
291+
* intersects the node's own properties into every grouped branch. The exception
292+
* is direct nullability in an inline anyOf member on a component `$ref` target:
293+
* `resolveValue` propagates it to the referenced wrapper as a separate `| null`.
294+
* Inline allOf members, reference members, non-null scalars, oneOf members, and
295+
* nested unions stay inside the grouped intersection. An all-enum composition
296+
* is also unsafe because `combineValues` emits the node's properties as a
297+
* separate union branch instead. Finally, the canonical nullable-oneOf object
298+
* shortcut emits only its inline object and `null`, dropping the node's own
299+
* properties.
300+
*/
301+
function cannotGuaranteeOwnPropertyKeys(
302+
schema: OpenApiSchemaObject | OpenApiReferenceObject,
303+
context: ContextSpec,
304+
crossesComponentRefBoundary: boolean,
305+
nullBranchesEliminated = false,
306+
): boolean {
149307
return (
150-
type === 'null' ||
151-
(Array.isArray(type) && (type.length > 1 || type.includes('null')))
308+
cannotGuaranteeAllOfPropertyKeys(
309+
schema,
310+
crossesComponentRefBoundary,
311+
nullBranchesEliminated,
312+
) ||
313+
hasAllEnumMembers(schema, context) ||
314+
usesCanonicalNullableOneOfObject(schema)
152315
);
153316
}
154317

@@ -185,8 +348,8 @@ function derefComponentSchema(
185348
return undefined;
186349
}
187350
if (isReference(target)) {
188-
// Intermediate chain hops can carry union-producing siblings too
189-
if (emitsUnionType(target)) {
351+
// Intermediate chain hops can carry non-object-producing siblings too
352+
if (cannotGuaranteeAllOfPropertyKeys(target, true)) {
190353
return undefined;
191354
}
192355
current = target.$ref;
@@ -197,40 +360,123 @@ function derefComponentSchema(
197360
return undefined;
198361
}
199362

363+
/** A guaranteed own property proves this member cannot emit null. */
364+
function guaranteesNonNullableObject(
365+
schema: OpenApiSchemaObject | OpenApiReferenceObject,
366+
context: ContextSpec,
367+
): boolean {
368+
const crossesComponentRefBoundary = isReference(schema);
369+
if (
370+
cannotGuaranteeOwnPropertyKeys(schema, context, crossesComponentRefBoundary)
371+
) {
372+
return false;
373+
}
374+
const resolvedSchema = isReference(schema)
375+
? derefComponentSchema(schema.$ref, context, new Set<string>())
376+
: schema;
377+
if (!resolvedSchema) {
378+
return false;
379+
}
380+
const properties = resolvedSchema.properties as
381+
| Record<string, unknown>
382+
| undefined;
383+
return (
384+
!!properties &&
385+
Object.keys(properties).length > 0 &&
386+
!cannotGuaranteeOwnPropertyKeys(
387+
resolvedSchema,
388+
context,
389+
crossesComponentRefBoundary,
390+
)
391+
);
392+
}
393+
200394
/**
201395
* Collect the property keys reachable through a schema's `allOf` composition,
202396
* resolving component `$ref` members against the spec. Feeds the
203397
* pickable/unresolved split for required-override keys: a key found here is
204398
* provably in `keyof` of the emitted intersection, so a plain
205399
* `Required<Pick<T, 'k'>>` is safe even when an `additionalProperties` index
206-
* signature would collapse the `Extract` guard to `never` (#3748). Unions are
207-
* deliberately not walked — anyOf/oneOf members, nullable or enum nodes
208-
* contribute keys that are not guaranteed in `keyof`, and skipping them only
209-
* degrades to the compile-safe `Extract` guard.
400+
* signature would collapse the `Extract` guard to `never` (#3748). Union
401+
* members are deliberately not walked, while a node's own top-level properties
402+
* are collected when the generator intersects them into every anyOf/oneOf
403+
* branch. A node's own unsafe properties are skipped without discarding keys
404+
* from sibling `allOf` descendants that remain in the emitted intersection.
405+
* Nodes that can emit a branch outside that full intersection are skipped
406+
* entirely, degrading to the compile-safe `Extract` guard. The component-ref
407+
* boundary flag mirrors the only `resolveValue` path that lifts inline anyOf
408+
* nullability outside the alias intersection. Parent allOf traversal also uses
409+
* guaranteed properties on the parent or a sibling as proof that null cannot
410+
* survive the full intersection.
210411
*/
211412
function collectDeepPropertyKeys(
212413
schema: OpenApiSchemaObject | OpenApiReferenceObject,
213414
context: ContextSpec,
415+
crossesComponentRefBoundary = false,
416+
nullBranchesEliminated = false,
214417
seenRefs = new Set<string>(),
215418
): string[] {
419+
const resolvesComponentRef =
420+
crossesComponentRefBoundary || isReference(schema);
216421
// Checked before dereferencing: `$ref`-site siblings (`nullable: true`,
217-
// `type: ['object', 'null']`) union the emission just like inline nodes.
218-
if (emitsUnionType(schema)) {
422+
// scalar or mixed `type`) can change the emission just like inline nodes.
423+
if (
424+
cannotGuaranteeAllOfPropertyKeys(
425+
schema,
426+
resolvesComponentRef,
427+
nullBranchesEliminated,
428+
)
429+
) {
219430
return [];
220431
}
221432
if (isReference(schema)) {
222433
const target = derefComponentSchema(schema.$ref, context, seenRefs);
223-
return target ? collectDeepPropertyKeys(target, context, seenRefs) : [];
434+
return target
435+
? collectDeepPropertyKeys(
436+
target,
437+
context,
438+
true,
439+
nullBranchesEliminated,
440+
seenRefs,
441+
)
442+
: [];
224443
}
225444
// Bridge assertion: properties is infected by AnyOtherAttribute
226445
const properties = schema.properties as Record<string, unknown> | undefined;
227-
const keys = properties ? Object.keys(properties) : [];
446+
const keys =
447+
properties &&
448+
!cannotGuaranteeOwnPropertyKeys(
449+
schema,
450+
context,
451+
resolvesComponentRef,
452+
nullBranchesEliminated,
453+
)
454+
? Object.keys(properties)
455+
: [];
456+
const parentPropertiesEliminateNull = keys.length > 0;
228457
const members = (schema.allOf ?? []) as (
229458
| OpenApiSchemaObject
230459
| OpenApiReferenceObject
231460
)[];
232-
for (const member of members) {
233-
keys.push(...collectDeepPropertyKeys(member, context, seenRefs));
461+
const guaranteedObjectMembers = members.map((member) =>
462+
guaranteesNonNullableObject(member, context),
463+
);
464+
for (const [index, member] of members.entries()) {
465+
const hasObjectSibling = guaranteedObjectMembers.some(
466+
(isGuaranteedObject, siblingIndex) =>
467+
siblingIndex !== index && isGuaranteedObject,
468+
);
469+
keys.push(
470+
...collectDeepPropertyKeys(
471+
member,
472+
context,
473+
false,
474+
nullBranchesEliminated ||
475+
parentPropertiesEliminateNull ||
476+
hasObjectSibling,
477+
seenRefs,
478+
),
479+
);
234480
}
235481
return keys;
236482
}
@@ -358,9 +604,15 @@ function combineValues({
358604
}
359605

360606
if (resolvedValue) {
361-
return `(${values.join(` & ${resolvedValue.value}) | (`)} & ${
362-
resolvedValue.value
363-
})`;
607+
const resolvedValueStr = resolvedValue.value.includes(' | ')
608+
? `(${resolvedValue.value})`
609+
: resolvedValue.value;
610+
return values
611+
.map((value) => {
612+
const valueStr = value.includes(' | ') ? `(${value})` : value;
613+
return `(${valueStr} & ${resolvedValueStr})`;
614+
})
615+
.join(' | ');
364616
}
365617

366618
return values.join(' | ');
@@ -491,7 +743,11 @@ export function combineSchemas({
491743
// unionAddMissingProperties (#935), which must only see each member's
492744
// own top-level keys.
493745
resolvedData.allProperties.push(
494-
...collectDeepPropertyKeys(resolvedValue.originalSchema, context),
746+
...collectDeepPropertyKeys(
747+
resolvedValue.originalSchema,
748+
context,
749+
isReference(subSchema),
750+
),
495751
);
496752
} else {
497753
// Bridge: originalSchema.properties is infected by AnyOtherAttribute
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* Generated by orval v8.22.0 🍺
3+
* Do not edit manually.
4+
* Orval OpenAPI 3.1 Regression Tests
5+
* OpenAPI spec version: 0.0.0
6+
*/

0 commit comments

Comments
 (0)