fork of hey-api/openapi-ts because I need some additional things
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: explicit discriminator mapping wins over fallback in nested allOf

When a schema extends a parent whose allOf contains both a $ref schema
(with a discriminator that doesn't list the child) and an inline schema
(with a discriminator that does explicitly map the child), the generated
type was getting the wrong $type literal — the schema name fallback from
the $ref discriminator — instead of the value from the explicit inline
mapping.

The root cause is that findDiscriminatorsInSchema traverses allOf entries
in order, so the $ref schema's discriminator is seen first. Since the
child isn't in that mapping, discriminatorValues falls back to the schema
name, marks the property name as seen, and the correct inline discriminator
is then skipped as a duplicate.

Fix: track whether each collected discriminator value came from an explicit
mapping match or a schema-name fallback. If a later discriminator for the
same property has an explicit match while the existing one is only a
fallback, replace it. Fallback-only cases (schema genuinely not in any
mapping) are unaffected.

Applied to both the 3.0.x and 3.1.x parsers, with a minimal reproduction
spec and snapshot test for each.

+367 -55
+8
packages/openapi-ts-tests/main/test/3.0.x.test.ts
··· 219 219 }, 220 220 { 221 221 config: createConfig({ 222 + input: 'discriminator-allof-inline.json', 223 + output: 'discriminator-allof-inline', 224 + }), 225 + description: 226 + 'handles allOf where inline schema discriminator mapping should take priority over $ref discriminator fallback', 227 + }, 228 + { 229 + config: createConfig({ 222 230 input: 'discriminator-non-string.yaml', 223 231 output: 'discriminator-non-string', 224 232 }),
+8
packages/openapi-ts-tests/main/test/3.1.x.test.ts
··· 245 245 }, 246 246 { 247 247 config: createConfig({ 248 + input: 'discriminator-allof-inline.json', 249 + output: 'discriminator-allof-inline', 250 + }), 251 + description: 252 + 'handles allOf where inline schema discriminator mapping should take priority over $ref discriminator fallback', 253 + }, 254 + { 255 + config: createConfig({ 248 256 input: 'discriminator-non-string.yaml', 249 257 output: 'discriminator-non-string', 250 258 }),
+3
packages/openapi-ts-tests/main/test/__snapshots__/3.0.x/discriminator-allof-inline/index.ts
··· 1 + // This file is auto-generated by @hey-api/openapi-ts 2 + 3 + export type { Bar, Baz, ClientOptions, Foo, GetFoosData, GetFoosResponse, GetFoosResponses, Qux } from './types.gen';
+41
packages/openapi-ts-tests/main/test/__snapshots__/3.0.x/discriminator-allof-inline/types.gen.ts
··· 1 + // This file is auto-generated by @hey-api/openapi-ts 2 + 3 + export type ClientOptions = { 4 + baseUrl: `${string}://${string}` | (string & {}); 5 + }; 6 + 7 + export type Foo = { 8 + $type: string; 9 + foo?: string; 10 + }; 11 + 12 + export type Bar = Omit<Foo, '$type'> & { 13 + $type: 'FooBar'; 14 + bar?: string; 15 + }; 16 + 17 + export type Baz = Omit<Foo, '$type'> & { 18 + baz?: string; 19 + $type: 'FooBaz'; 20 + }; 21 + 22 + export type Qux = Omit<Bar, '$type'> & { 23 + qux?: string; 24 + $type: 'BarQux'; 25 + }; 26 + 27 + export type GetFoosData = { 28 + body?: never; 29 + path?: never; 30 + query?: never; 31 + url: '/foos'; 32 + }; 33 + 34 + export type GetFoosResponses = { 35 + /** 36 + * OK 37 + */ 38 + 200: Bar | Baz | Qux; 39 + }; 40 + 41 + export type GetFoosResponse = GetFoosResponses[keyof GetFoosResponses];
+3
packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/discriminator-allof-inline/index.ts
··· 1 + // This file is auto-generated by @hey-api/openapi-ts 2 + 3 + export type { Bar, Baz, ClientOptions, Foo, GetFoosData, GetFoosResponse, GetFoosResponses, Qux } from './types.gen';
+41
packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/discriminator-allof-inline/types.gen.ts
··· 1 + // This file is auto-generated by @hey-api/openapi-ts 2 + 3 + export type ClientOptions = { 4 + baseUrl: `${string}://${string}` | (string & {}); 5 + }; 6 + 7 + export type Foo = { 8 + $type: string; 9 + foo?: string; 10 + }; 11 + 12 + export type Bar = Omit<Foo, '$type'> & { 13 + $type: 'FooBar'; 14 + bar?: string; 15 + }; 16 + 17 + export type Baz = Omit<Foo, '$type'> & { 18 + baz?: string; 19 + $type: 'FooBaz'; 20 + }; 21 + 22 + export type Qux = Omit<Bar, '$type'> & { 23 + qux?: string; 24 + $type: 'BarQux'; 25 + }; 26 + 27 + export type GetFoosData = { 28 + body?: never; 29 + path?: never; 30 + query?: never; 31 + url: '/foos'; 32 + }; 33 + 34 + export type GetFoosResponses = { 35 + /** 36 + * OK 37 + */ 38 + 200: Bar | Baz | Qux; 39 + }; 40 + 41 + export type GetFoosResponse = GetFoosResponses[keyof GetFoosResponses];
+41 -27
packages/shared/src/openApi/3.0.x/parser/schema.ts
··· 436 436 // Collect discriminator information to add after all compositions are processed 437 437 type DiscriminatorInfo = { 438 438 discriminator: NonNullable<SchemaObject['discriminator']>; 439 + isExplicitMapping: boolean; 439 440 isRequired: boolean; 440 441 values: ReadonlyArray<string>; 441 442 }; 442 443 const discriminatorsToAdd: Array<DiscriminatorInfo> = []; 443 - const addedDiscriminators = new Set<string>(); 444 444 445 445 for (const compositionSchema of compositionSchemas) { 446 446 const originalInAllOf = state.inAllOf; ··· 478 478 schema: ref, 479 479 }); 480 480 481 - // Process each discriminator found 482 481 for (const { discriminator, oneOf } of discriminators) { 483 - // Skip if we've already collected this discriminator property 484 - if (addedDiscriminators.has(discriminator.propertyName)) { 485 - continue; 486 - } 487 - 488 482 const values = discriminatorValues( 489 483 state.$ref, 490 484 discriminator.mapping, ··· 494 488 oneOf ? () => oneOf.some((o) => '$ref' in o && o.$ref === state.$ref) : undefined, 495 489 ); 496 490 497 - if (values.length > 0) { 498 - // Check if the discriminator property is required in any of the discriminator schemas 499 - const isRequired = discriminators.some( 500 - (d) => 501 - d.discriminator.propertyName === discriminator.propertyName && 502 - // Check in the ref's required array or in the allOf components 503 - (ref.required?.includes(d.discriminator.propertyName) || 504 - (ref.allOf && 505 - ref.allOf.some((item) => { 506 - const resolvedItem = 507 - '$ref' in item ? context.resolveRef<SchemaObject>(item.$ref) : item; 508 - return resolvedItem.required?.includes(d.discriminator.propertyName); 509 - }))), 510 - ); 491 + if (values.length === 0) { 492 + continue; 493 + } 511 494 512 - discriminatorsToAdd.push({ 513 - discriminator, 514 - isRequired, 515 - values, 516 - }); 517 - addedDiscriminators.add(discriminator.propertyName); 495 + // True when state.$ref appears directly in the mapping; false when the 496 + // value fell back to the schema name because no mapping entry matched. 497 + const isExplicitMapping = 498 + discriminator.mapping !== undefined && 499 + Object.values(discriminator.mapping).includes(state.$ref); 500 + 501 + // An explicit mapping always beats a same-property fallback collected 502 + // earlier (e.g. from a grandparent discriminator that doesn't list this 503 + // schema). Replace it; otherwise skip the duplicate. 504 + const existingIndex = discriminatorsToAdd.findIndex( 505 + (d) => d.discriminator.propertyName === discriminator.propertyName, 506 + ); 507 + if (existingIndex !== -1) { 508 + if (isExplicitMapping && !discriminatorsToAdd[existingIndex]!.isExplicitMapping) { 509 + discriminatorsToAdd.splice(existingIndex, 1); 510 + } else { 511 + continue; 512 + } 518 513 } 514 + 515 + const isRequired = discriminators.some( 516 + (d) => 517 + d.discriminator.propertyName === discriminator.propertyName && 518 + (ref.required?.includes(d.discriminator.propertyName) || 519 + (ref.allOf && 520 + ref.allOf.some((item) => { 521 + const resolvedItem = 522 + '$ref' in item ? context.resolveRef<SchemaObject>(item.$ref) : item; 523 + return resolvedItem.required?.includes(d.discriminator.propertyName); 524 + }))), 525 + ); 526 + 527 + discriminatorsToAdd.push({ 528 + discriminator, 529 + isExplicitMapping, 530 + isRequired, 531 + values, 532 + }); 519 533 } 520 534 } 521 535 }
+42 -28
packages/shared/src/openApi/3.1.x/parser/schema.ts
··· 531 531 // Collect discriminator information to add after all compositions are processed 532 532 type DiscriminatorInfo = { 533 533 discriminator: NonNullable<SchemaObject['discriminator']>; 534 + isExplicitMapping: boolean; 534 535 isRequired: boolean; 535 536 values: ReadonlyArray<string>; 536 537 }; 537 538 const discriminatorsToAdd: Array<DiscriminatorInfo> = []; 538 - const addedDiscriminators = new Set<string>(); 539 539 540 540 for (const compositionSchema of compositionSchemas) { 541 541 const originalInAllOf = state.inAllOf; ··· 573 573 schema: ref, 574 574 }); 575 575 576 - // Process each discriminator found 577 576 for (const { discriminator, oneOf } of discriminators) { 578 - // Skip if we've already collected this discriminator property 579 - if (addedDiscriminators.has(discriminator.propertyName)) { 580 - continue; 581 - } 582 - 583 577 const values = discriminatorValues( 584 578 state.$ref, 585 579 discriminator.mapping, ··· 589 583 oneOf ? () => oneOf.some((o) => '$ref' in o && o.$ref === state.$ref) : undefined, 590 584 ); 591 585 592 - if (values.length > 0) { 593 - // Check if the discriminator property is required in any of the discriminator schemas 594 - const isRequired = discriminators.some( 595 - (d) => 596 - d.discriminator.propertyName === discriminator.propertyName && 597 - // Check in the ref's required array or in the allOf components 598 - (ref.required?.includes(d.discriminator.propertyName) || 599 - (ref.allOf && 600 - ref.allOf.some((item) => { 601 - const resolvedItem = item.$ref 602 - ? context.resolveRef<SchemaObject>(item.$ref) 603 - : item; 604 - return resolvedItem.required?.includes(d.discriminator.propertyName); 605 - }))), 606 - ); 586 + if (values.length === 0) { 587 + continue; 588 + } 607 589 608 - discriminatorsToAdd.push({ 609 - discriminator, 610 - isRequired, 611 - values, 612 - }); 613 - addedDiscriminators.add(discriminator.propertyName); 590 + // True when state.$ref appears directly in the mapping; false when the 591 + // value fell back to the schema name because no mapping entry matched. 592 + const isExplicitMapping = 593 + discriminator.mapping !== undefined && 594 + Object.values(discriminator.mapping).includes(state.$ref); 595 + 596 + // An explicit mapping always beats a same-property fallback collected 597 + // earlier (e.g. from a grandparent discriminator that doesn't list this 598 + // schema). Replace it; otherwise skip the duplicate. 599 + const existingIndex = discriminatorsToAdd.findIndex( 600 + (d) => d.discriminator.propertyName === discriminator.propertyName, 601 + ); 602 + if (existingIndex !== -1) { 603 + if (isExplicitMapping && !discriminatorsToAdd[existingIndex]!.isExplicitMapping) { 604 + discriminatorsToAdd.splice(existingIndex, 1); 605 + } else { 606 + continue; 607 + } 614 608 } 609 + 610 + const isRequired = discriminators.some( 611 + (d) => 612 + d.discriminator.propertyName === discriminator.propertyName && 613 + (ref.required?.includes(d.discriminator.propertyName) || 614 + (ref.allOf && 615 + ref.allOf.some((item) => { 616 + const resolvedItem = item.$ref 617 + ? context.resolveRef<SchemaObject>(item.$ref) 618 + : item; 619 + return resolvedItem.required?.includes(d.discriminator.propertyName); 620 + }))), 621 + ); 622 + 623 + discriminatorsToAdd.push({ 624 + discriminator, 625 + isExplicitMapping, 626 + isRequired, 627 + values, 628 + }); 615 629 } 616 630 } 617 631 }
+90
specs/3.0.x/discriminator-allof-inline.json
··· 1 + { 2 + "openapi": "3.0.3", 3 + "info": { 4 + "title": "Discriminator allOf inline schema mapping", 5 + "version": "1.0.0", 6 + "description": "Reproduces a bug where a schema extending a parent whose allOf contains both a $ref (with a discriminator that doesn't map the child) and an inline schema (with a discriminator that does map the child) gets the wrong discriminator value. The inline discriminator mapping should win, not the $ref discriminator fallback." 7 + }, 8 + "paths": { 9 + "/foos": { 10 + "get": { 11 + "responses": { 12 + "200": { 13 + "description": "OK", 14 + "content": { 15 + "application/json": { 16 + "schema": { 17 + "oneOf": [ 18 + { "$ref": "#/components/schemas/Bar" }, 19 + { "$ref": "#/components/schemas/Baz" }, 20 + { "$ref": "#/components/schemas/Qux" } 21 + ] 22 + } 23 + } 24 + } 25 + } 26 + } 27 + } 28 + } 29 + }, 30 + "components": { 31 + "schemas": { 32 + "Foo": { 33 + "required": ["$type"], 34 + "type": "object", 35 + "properties": { 36 + "$type": { "type": "string" }, 37 + "foo": { "type": "string" } 38 + }, 39 + "discriminator": { 40 + "propertyName": "$type", 41 + "mapping": { 42 + "FooBar": "#/components/schemas/Bar", 43 + "FooBaz": "#/components/schemas/Baz" 44 + } 45 + } 46 + }, 47 + "Bar": { 48 + "allOf": [ 49 + { "$ref": "#/components/schemas/Foo" }, 50 + { 51 + "required": ["$type"], 52 + "type": "object", 53 + "properties": { 54 + "$type": { "type": "string" }, 55 + "bar": { "type": "string" } 56 + }, 57 + "discriminator": { 58 + "propertyName": "$type", 59 + "mapping": { 60 + "BarQux": "#/components/schemas/Qux" 61 + } 62 + } 63 + } 64 + ] 65 + }, 66 + "Baz": { 67 + "allOf": [ 68 + { "$ref": "#/components/schemas/Foo" }, 69 + { 70 + "type": "object", 71 + "properties": { 72 + "baz": { "type": "string" } 73 + } 74 + } 75 + ] 76 + }, 77 + "Qux": { 78 + "allOf": [ 79 + { "$ref": "#/components/schemas/Bar" }, 80 + { 81 + "type": "object", 82 + "properties": { 83 + "qux": { "type": "string" } 84 + } 85 + } 86 + ] 87 + } 88 + } 89 + } 90 + }
+90
specs/3.1.x/discriminator-allof-inline.json
··· 1 + { 2 + "openapi": "3.1.0", 3 + "info": { 4 + "title": "Discriminator allOf inline schema mapping", 5 + "version": "1.0.0", 6 + "description": "Reproduces a bug where a schema extending a parent whose allOf contains both a $ref (with a discriminator that doesn't map the child) and an inline schema (with a discriminator that does map the child) gets the wrong discriminator value. The inline discriminator mapping should win, not the $ref discriminator fallback." 7 + }, 8 + "paths": { 9 + "/foos": { 10 + "get": { 11 + "responses": { 12 + "200": { 13 + "description": "OK", 14 + "content": { 15 + "application/json": { 16 + "schema": { 17 + "oneOf": [ 18 + { "$ref": "#/components/schemas/Bar" }, 19 + { "$ref": "#/components/schemas/Baz" }, 20 + { "$ref": "#/components/schemas/Qux" } 21 + ] 22 + } 23 + } 24 + } 25 + } 26 + } 27 + } 28 + } 29 + }, 30 + "components": { 31 + "schemas": { 32 + "Foo": { 33 + "required": ["$type"], 34 + "type": "object", 35 + "properties": { 36 + "$type": { "type": "string" }, 37 + "foo": { "type": "string" } 38 + }, 39 + "discriminator": { 40 + "propertyName": "$type", 41 + "mapping": { 42 + "FooBar": "#/components/schemas/Bar", 43 + "FooBaz": "#/components/schemas/Baz" 44 + } 45 + } 46 + }, 47 + "Bar": { 48 + "allOf": [ 49 + { "$ref": "#/components/schemas/Foo" }, 50 + { 51 + "required": ["$type"], 52 + "type": "object", 53 + "properties": { 54 + "$type": { "type": "string" }, 55 + "bar": { "type": "string" } 56 + }, 57 + "discriminator": { 58 + "propertyName": "$type", 59 + "mapping": { 60 + "BarQux": "#/components/schemas/Qux" 61 + } 62 + } 63 + } 64 + ] 65 + }, 66 + "Baz": { 67 + "allOf": [ 68 + { "$ref": "#/components/schemas/Foo" }, 69 + { 70 + "type": "object", 71 + "properties": { 72 + "baz": { "type": "string" } 73 + } 74 + } 75 + ] 76 + }, 77 + "Qux": { 78 + "allOf": [ 79 + { "$ref": "#/components/schemas/Bar" }, 80 + { 81 + "type": "object", 82 + "properties": { 83 + "qux": { "type": "string" } 84 + } 85 + } 86 + ] 87 + } 88 + } 89 + } 90 + }