Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions packages/core/src/generators/schema-definition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,85 @@ describe('generateSchemasDefinition', () => {
).toContain('as const');
});

// Regression test for #3563 (follow-up to #3340): the explicit OAS 3.1
// composition `anyOf: [{ object }, { type: 'null' }]` (and the analogous
// `oneOf`) must extract nested-object enum properties into named `as const`
// consts, exactly like the `type: ['object', 'null']` shape fixed in #3340.
// This shape routes through `combineSchemas` rather than the type-array
// branch, so it needs its own coverage.
it.each(['anyOf', 'oneOf'] as const)(
'extracts named const enums from a nullable object composition (%s, #3563)',
(separator) => {
const schemas: OpenApiSchemasObject = {
Test: {
type: 'object',
properties: {
monthSelection: {
[separator]: [
{
type: 'object',
properties: {
months: {
type: 'array',
items: {
type: 'string',
enum: ['JANUARY', 'FEBRUARY', 'MARCH'],
},
},
months2: {
type: 'string',
enum: ['JANUARY', 'FEBRUARY', 'MARCH'],
},
},
},
{ type: 'null' },
],
},
},
},
};

const specContext = {
...context,
output: {
...context.output,
override: {
enumGenerationType: 'const',
namingConvention: {},
components: {
schemas: { suffix: '', itemSuffix: 'Item' },
responses: { suffix: '' },
parameters: { suffix: '' },
requestBodies: { suffix: 'RequestBody' },
},
},
},
spec: { components: { schemas } },
} as unknown as ContextSpec;

const result = generateSchemasDefinition(schemas, specContext, '');

// The nullable object references the named enum types instead of inlining
// the union, and preserves its ` | null`.
const monthSelection = result.find(
(s) => s.name === 'TestMonthSelection',
);
expect(monthSelection).toBeDefined();
expect(monthSelection?.model).not.toContain(`'JANUARY'`);
expect(monthSelection?.model).toContain('TestMonthSelectionMonthsItem');
expect(monthSelection?.model).toContain('TestMonthSelectionMonths2');
expect(monthSelection?.model).toContain('| null');

// Each nested enum is emitted as its own `as const` schema.
expect(
result.find((s) => s.name === 'TestMonthSelectionMonthsItem')?.model,
).toContain('as const');
expect(
result.find((s) => s.name === 'TestMonthSelectionMonths2')?.model,
).toContain('as const');
},
);

it('should avoid invalid spreads for nullable or boolean oneOf enums', () => {
const schemas: OpenApiSchemasObject = {
NumberEnum: {
Expand Down
76 changes: 76 additions & 0 deletions packages/core/src/getters/object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,82 @@ export function getObject({
if (itemAllOf || itemOneOf || itemAnyOf) {
const separator = itemAllOf ? 'allOf' : itemOneOf ? 'oneOf' : 'anyOf';

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fe46cb2: members is now derived from separator (anyOf/oneOf only, undefined otherwise) so both paths inspect the same combiner and allOf never diverts.


// A nullable object spelled as the explicit OAS 3.1 composition
// `anyOf|oneOf: [{ inline object with properties }, { type: 'null' }]` must
// keep its `name` so nested enum properties are extracted into named
// `as const` consts. Routing it through combineSchemas resolves the object
// member with `combined: true` and an undefined propName, which drops the
// name and inlines those enums. Divert the single object member to the
// property-iteration path instead — the same fix #3340 applied one level
// down for the `type: ['object', 'null']` shape. See issue #3563.
// Real unions, `$ref` object members, primitive members, and empty objects
// keep the combineSchemas behavior via the guard below.
//
// Read members from the active `separator` (not `itemAnyOf ?? itemOneOf`)
// so this check and the combineSchemas fallback below operate on the same
// composition. `allOf` is intersection, not a nullable union, so it yields
// no members here and always falls through to combineSchemas.
const members =
separator === 'anyOf'
? itemAnyOf
: separator === 'oneOf'
? itemOneOf
: undefined;
if (members) {
const isNullMember = (
member: OpenApiSchemaObject | OpenApiReferenceObject,
): boolean => {
if (isReference(member)) {
return false;
}
const memberType = member.type as string | string[] | undefined;
return (
memberType === 'null' ||
(Array.isArray(memberType) &&
memberType.length === 1 &&
memberType[0] === 'null')
);
};

const nonNullMembers = members.filter((member) => !isNullMember(member));
const nonNullMember = nonNullMembers[0];
// Bridge assertion: AnyOtherAttribute infects member property access to
// `any`; cast to the documented shapes after excluding `$ref` members.
const nonNullMemberType =
nonNullMember && !isReference(nonNullMember)
? (nonNullMember.type as string | string[] | undefined)
: undefined;
const nonNullMemberProperties =
nonNullMember && !isReference(nonNullMember)
? (nonNullMember.properties as
| Record<string, OpenApiSchemaObject | OpenApiReferenceObject>
| undefined)
: undefined;

const isNullableObjectComposition =
members.some(isNullMember) &&
nonNullMembers.length === 1 &&
nonNullMember != null &&
!isReference(nonNullMember) &&
(nonNullMemberType === 'object' ||
(nonNullMemberType == null && nonNullMemberProperties != null)) &&
nonNullMemberProperties != null &&
Object.keys(nonNullMemberProperties).length > 0;

if (isNullableObjectComposition) {
// `nullable` is empty for the composition form (the null lives in a
// member, not on the parent), so synthesize ` | null`; the
// property-iteration path appends it to the rendered object.
return getObject({
item: nonNullMember as OpenApiSchemaObject,
name,
context,
nullable: nullable || ' | null',
formDataContext,
});
}
}

return combineSchemas({
schema: schemaItem,
name,
Expand Down
Loading