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
277 changes: 277 additions & 0 deletions packages/core/src/getters/discriminators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,283 @@ describe('resolveDiscriminators getter', () => {
});
});

it('rewrites variant allOf $ref to parent when parent has top-level oneOf (#3432)', () => {
// When a discriminator parent has top-level `oneOf` listing variants that
// inherit via `allOf: [$ref: parent, ...]`, the variant's $ref-back-to-parent
// creates a circular type alias in the generated TS. We break the cycle at
// the schema level: drop the parent $ref (or inline its non-discriminator
// properties) so the variant no longer depends on the parent's alias.
const schemas: OpenApiSchemasObject = {
DiscriminatorTest: {
type: 'object',
required: ['type'],
properties: {
type: {
type: 'string',
enum: ['item1', 'item2'],
},
},
discriminator: {
propertyName: 'type',
mapping: {
item1: '#/components/schemas/Item1',
item2: '#/components/schemas/Item2',
},
},
oneOf: [
{ $ref: '#/components/schemas/Item1' },
{ $ref: '#/components/schemas/Item2' },
],
},
Item1: {
allOf: [
{ $ref: '#/components/schemas/DiscriminatorTest' },
{
type: 'object',
properties: {
property1: { type: 'string' },
},
},
],
},
Item2: {
allOf: [
{ $ref: '#/components/schemas/DiscriminatorTest' },
{
type: 'object',
properties: {
property2: { type: 'string' },
},
},
],
},
};

const result = resolveDiscriminators(structuredClone(schemas), context);
const item1 = result.Item1 as NonNullable<OpenApiSchemasObject[string]>;
const item2 = result.Item2 as NonNullable<OpenApiSchemasObject[string]>;

// Parent had only the discriminator key, so inheritable props are empty —
// the $ref-to-parent entry should be dropped, leaving only the inline
// object that contributed `property1` / `property2`.
const item1AllOf = item1.allOf as
| (OpenApiSchemaObject | OpenApiReferenceObject)[]
| undefined;
expect(item1AllOf).toHaveLength(1);
expect(item1AllOf?.[0]).not.toHaveProperty('$ref');
const item2AllOf = item2.allOf as
| (OpenApiSchemaObject | OpenApiReferenceObject)[]
| undefined;
expect(item2AllOf).toHaveLength(1);
expect(item2AllOf?.[0]).not.toHaveProperty('$ref');

// The existing discriminator-key injection still runs, so each variant's
// own `properties.type` is constrained to its mapping value.
const item1Props = item1.properties as
| Record<string, OpenApiSchemaObject | OpenApiReferenceObject>
| undefined;
expect(item1Props?.type).toMatchObject({
type: 'string',
enum: ['item1'],
});
});

it('inlines parent non-discriminator properties into variant allOf (#3432)', () => {
// When the parent has additional properties beyond the discriminator key,
// those properties must survive on each variant. Replace the $ref with an
// inline object carrying parent's properties minus the discriminator key.
Comment on lines +381 to +384
const schemas: OpenApiSchemasObject = {
Parent: {
type: 'object',
required: ['kind', 'commonField'],
properties: {
kind: { type: 'string', enum: ['a', 'b'] },
commonField: { type: 'string' },
},
discriminator: {
propertyName: 'kind',
mapping: {
a: '#/components/schemas/VariantA',
b: '#/components/schemas/VariantB',
},
},
oneOf: [
{ $ref: '#/components/schemas/VariantA' },
{ $ref: '#/components/schemas/VariantB' },
],
},
VariantA: {
allOf: [
{ $ref: '#/components/schemas/Parent' },
{ type: 'object', properties: { extraA: { type: 'number' } } },
],
},
VariantB: {
allOf: [
{ $ref: '#/components/schemas/Parent' },
{ type: 'object', properties: { extraB: { type: 'boolean' } } },
],
},
};

const result = resolveDiscriminators(structuredClone(schemas), context);
const variantA = result.VariantA as NonNullable<
OpenApiSchemasObject[string]
>;

const allOf = variantA.allOf as
| (OpenApiSchemaObject | OpenApiReferenceObject)[]
| undefined;
expect(allOf).toHaveLength(2);
expect(allOf?.[0]).not.toHaveProperty('$ref');
const inlined = allOf?.[0] as OpenApiSchemaObject | undefined;
const inlinedProps = inlined?.properties as
| Record<string, OpenApiSchemaObject | OpenApiReferenceObject>
| undefined;
expect(inlinedProps).toHaveProperty('commonField');
// The discriminator key must not appear in the inlined props.
expect(inlinedProps).not.toHaveProperty('kind');
expect(inlined?.required).toEqual(['commonField']);
});

it('preserves parent object-level constraints when inlining (#3432)', () => {
// Replacing the parent $ref with `{type:'object', properties, required}`
// alone would silently drop other parent constraints like
// `additionalProperties`, `minProperties`, `description`, etc. The inline
// schema must carry those forward so variant validation semantics match
// the dereferenced behavior we replaced.
const schemas: OpenApiSchemasObject = {
Parent: {
type: 'object',
additionalProperties: false,
minProperties: 1,
description: 'parent shape',
required: ['kind', 'commonField'],
properties: {
kind: { type: 'string', enum: ['a'] },
commonField: { type: 'string' },
},
discriminator: {
propertyName: 'kind',
mapping: {
a: '#/components/schemas/VariantA',
},
},
oneOf: [{ $ref: '#/components/schemas/VariantA' }],
},
VariantA: {
allOf: [
{ $ref: '#/components/schemas/Parent' },
{ type: 'object', properties: { extraA: { type: 'number' } } },
],
},
};

const result = resolveDiscriminators(structuredClone(schemas), context);
const variantA = result.VariantA as NonNullable<
OpenApiSchemasObject[string]
>;
const allOf = variantA.allOf as
| (OpenApiSchemaObject | OpenApiReferenceObject)[]
| undefined;
const inlined = allOf?.[0] as Record<string, unknown> | undefined;

expect(inlined?.additionalProperties).toBe(false);
expect(inlined?.minProperties).toBe(1);
expect(inlined?.description).toBe('parent shape');
// Composition keys that would re-create the cycle must NOT be copied.
expect(inlined).not.toHaveProperty('oneOf');
expect(inlined).not.toHaveProperty('discriminator');
expect(inlined).not.toHaveProperty('allOf');
});

it('gives each variant its own properties object (#3432)', () => {
// The inlined parent properties must be cloned per variant — sharing the
// same Record across siblings would couple downstream in-place mutations
// (e.g. one variant's property tweak leaking into the other).
const schemas: OpenApiSchemasObject = {
Parent: {
type: 'object',
required: ['kind', 'shared'],
properties: {
kind: { type: 'string', enum: ['a', 'b'] },
shared: { type: 'string' },
},
discriminator: {
propertyName: 'kind',
mapping: {
a: '#/components/schemas/VariantA',
b: '#/components/schemas/VariantB',
},
},
oneOf: [
{ $ref: '#/components/schemas/VariantA' },
{ $ref: '#/components/schemas/VariantB' },
],
},
VariantA: {
allOf: [
{ $ref: '#/components/schemas/Parent' },
{ type: 'object', properties: { extraA: { type: 'number' } } },
],
},
VariantB: {
allOf: [
{ $ref: '#/components/schemas/Parent' },
{ type: 'object', properties: { extraB: { type: 'boolean' } } },
],
},
};

const result = resolveDiscriminators(structuredClone(schemas), context);
const aAllOf = (
result.VariantA as NonNullable<OpenApiSchemasObject[string]>
).allOf as (OpenApiSchemaObject | OpenApiReferenceObject)[] | undefined;
const bAllOf = (
result.VariantB as NonNullable<OpenApiSchemasObject[string]>
).allOf as (OpenApiSchemaObject | OpenApiReferenceObject)[] | undefined;
const aInlined = aAllOf?.[0] as OpenApiSchemaObject | undefined;
const bInlined = bAllOf?.[0] as OpenApiSchemaObject | undefined;

expect(aInlined?.properties).not.toBe(bInlined?.properties);
expect(aInlined).not.toBe(bInlined);
});

it('leaves allOf untouched when parent has no top-level oneOf (#3432 guard)', () => {
// Sanity check: the existing recursive-discriminator-allof shape (parent
// is a plain interface, variants inherit via allOf) must keep emitting an
// unmodified `$ref` so the interface-based emit path remains intact.
const schemas: OpenApiSchemasObject = {
Base: {
type: 'object',
required: ['kind'],
properties: {
kind: { type: 'string' },
},
discriminator: {
propertyName: 'kind',
mapping: {
a: '#/components/schemas/Derived',
},
},
},
Derived: {
allOf: [
{ $ref: '#/components/schemas/Base' },
{ type: 'object', properties: { extra: { type: 'string' } } },
],
},
};

const result = resolveDiscriminators(structuredClone(schemas), context);
const derived = result.Derived as NonNullable<OpenApiSchemasObject[string]>;
const allOf = derived.allOf as
| (OpenApiSchemaObject | OpenApiReferenceObject)[]
| undefined;
expect(allOf).toHaveLength(2);
expect(allOf?.[0]).toHaveProperty('$ref', '#/components/schemas/Base');
});

it('hoists oneOf from discriminator when nested incorrectly', () => {
const schemas: OpenApiSchemasObject = {
Animal: {
Expand Down
Loading
Loading