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

Fix discriminator resolution for nested allOf compositions

Add helper function to recursively find discriminators in allOf schemas and update parseAllOf to use it for both OpenAPI 3.0.x and 3.1.x

Co-authored-by: mrlubos <12529395+mrlubos@users.noreply.github.com>

+336 -64
+121 -32
packages/openapi-ts/src/openApi/3.0.x/parser/schema.ts
··· 28 28 return; 29 29 }; 30 30 31 + /** 32 + * Recursively finds discriminators in a schema, including nested allOf compositions. 33 + * This is needed when a schema extends another schema via allOf, and that parent 34 + * schema is itself an allOf composition with discriminators in inline schemas. 35 + */ 36 + const findDiscriminatorsInSchema = ({ 37 + context, 38 + discriminators = [], 39 + schema, 40 + }: { 41 + context: Context; 42 + discriminators?: Array<{ 43 + discriminator: NonNullable<SchemaObject['discriminator']>; 44 + oneOf?: SchemaObject['oneOf']; 45 + }>; 46 + schema: SchemaObject; 47 + }): Array<{ 48 + discriminator: NonNullable<SchemaObject['discriminator']>; 49 + oneOf?: SchemaObject['oneOf']; 50 + }> => { 51 + // Check if this schema has a discriminator 52 + if (schema.discriminator) { 53 + discriminators.push({ 54 + discriminator: schema.discriminator, 55 + oneOf: schema.oneOf, 56 + }); 57 + } 58 + 59 + // If this schema is an allOf composition, recursively search in its components 60 + if (schema.allOf) { 61 + for (const compositionSchema of schema.allOf) { 62 + let resolvedSchema: SchemaObject; 63 + if ('$ref' in compositionSchema) { 64 + resolvedSchema = context.resolveRef<SchemaObject>( 65 + compositionSchema.$ref, 66 + ); 67 + } else { 68 + resolvedSchema = compositionSchema; 69 + } 70 + 71 + findDiscriminatorsInSchema({ 72 + context, 73 + discriminators, 74 + schema: resolvedSchema, 75 + }); 76 + } 77 + } 78 + 79 + return discriminators; 80 + }; 81 + 31 82 const parseSchemaJsDoc = ({ 32 83 irSchema, 33 84 schema, ··· 339 390 if ('$ref' in compositionSchema) { 340 391 const ref = context.resolveRef<SchemaObject>(compositionSchema.$ref); 341 392 // `$ref` should be passed from the root `parseSchema()` call 342 - if (ref.discriminator && state.$ref) { 343 - const values = discriminatorValues( 344 - state.$ref, 345 - ref.discriminator.mapping, 346 - // If the ref has oneOf, we only use the schema name as the value 347 - // only if current schema is part of the oneOf. Else it is extending 348 - // the ref schema 349 - ref.oneOf 350 - ? () => ref.oneOf!.some((o) => '$ref' in o && o.$ref === state.$ref) 351 - : undefined, 352 - ); 393 + if (state.$ref) { 394 + // Find all discriminators in the referenced schema, including nested allOf compositions 395 + const discriminators = findDiscriminatorsInSchema({ 396 + context, 397 + schema: ref, 398 + }); 399 + 400 + // Track discriminator properties we've already added to avoid duplicates 401 + const addedDiscriminators = new Set<string>(); 402 + 403 + // Process each discriminator found 404 + for (const { discriminator, oneOf } of discriminators) { 405 + // Skip if we've already added this discriminator property 406 + if (addedDiscriminators.has(discriminator.propertyName)) { 407 + continue; 408 + } 353 409 354 - if (values.length > 0) { 355 - const valueSchemas: ReadonlyArray<IR.SchemaObject> = values.map( 356 - (value) => ({ 357 - const: value, 358 - type: 'string', 359 - }), 410 + const values = discriminatorValues( 411 + state.$ref, 412 + discriminator.mapping, 413 + // If the ref has oneOf, we only use the schema name as the value 414 + // only if current schema is part of the oneOf. Else it is extending 415 + // the ref schema 416 + oneOf 417 + ? () => oneOf.some((o) => '$ref' in o && o.$ref === state.$ref) 418 + : undefined, 360 419 ); 361 - const irDiscriminatorSchema: IR.SchemaObject = { 362 - properties: { 363 - [ref.discriminator.propertyName]: 364 - valueSchemas.length > 1 365 - ? { 366 - items: valueSchemas, 367 - logicalOperator: 'or', 368 - } 369 - : valueSchemas[0]!, 370 - }, 371 - type: 'object', 372 - }; 373 - if (ref.required?.includes(ref.discriminator.propertyName)) { 374 - irDiscriminatorSchema.required = [ref.discriminator.propertyName]; 420 + 421 + if (values.length > 0) { 422 + const valueSchemas: ReadonlyArray<IR.SchemaObject> = values.map( 423 + (value) => ({ 424 + const: value, 425 + type: 'string', 426 + }), 427 + ); 428 + const irDiscriminatorSchema: IR.SchemaObject = { 429 + properties: { 430 + [discriminator.propertyName]: 431 + valueSchemas.length > 1 432 + ? { 433 + items: valueSchemas, 434 + logicalOperator: 'or', 435 + } 436 + : valueSchemas[0]!, 437 + }, 438 + type: 'object', 439 + }; 440 + 441 + // Check if the discriminator property is required in any of the discriminator schemas 442 + // by looking at all discriminators with the same property name 443 + const isRequired = discriminators.some( 444 + (d) => 445 + d.discriminator.propertyName === discriminator.propertyName && 446 + // Check in the ref's required array or in the allOf components 447 + (ref.required?.includes(d.discriminator.propertyName) || 448 + (ref.allOf && 449 + ref.allOf.some((item) => { 450 + const resolvedItem = 451 + '$ref' in item 452 + ? context.resolveRef<SchemaObject>(item.$ref) 453 + : item; 454 + return resolvedItem.required?.includes( 455 + d.discriminator.propertyName, 456 + ); 457 + }))), 458 + ); 459 + 460 + if (isRequired) { 461 + irDiscriminatorSchema.required = [discriminator.propertyName]; 462 + } 463 + schemaItems.push(irDiscriminatorSchema); 464 + addedDiscriminators.add(discriminator.propertyName); 375 465 } 376 - schemaItems.push(irDiscriminatorSchema); 377 466 } 378 467 } 379 468 }
+121 -32
packages/openapi-ts/src/openApi/3.1.x/parser/schema.ts
··· 32 32 return []; 33 33 }; 34 34 35 + /** 36 + * Recursively finds discriminators in a schema, including nested allOf compositions. 37 + * This is needed when a schema extends another schema via allOf, and that parent 38 + * schema is itself an allOf composition with discriminators in inline schemas. 39 + */ 40 + const findDiscriminatorsInSchema = ({ 41 + context, 42 + discriminators = [], 43 + schema, 44 + }: { 45 + context: Context; 46 + discriminators?: Array<{ 47 + discriminator: NonNullable<SchemaObject['discriminator']>; 48 + oneOf?: SchemaObject['oneOf']; 49 + }>; 50 + schema: SchemaObject; 51 + }): Array<{ 52 + discriminator: NonNullable<SchemaObject['discriminator']>; 53 + oneOf?: SchemaObject['oneOf']; 54 + }> => { 55 + // Check if this schema has a discriminator 56 + if (schema.discriminator) { 57 + discriminators.push({ 58 + discriminator: schema.discriminator, 59 + oneOf: schema.oneOf, 60 + }); 61 + } 62 + 63 + // If this schema is an allOf composition, recursively search in its components 64 + if (schema.allOf) { 65 + for (const compositionSchema of schema.allOf) { 66 + let resolvedSchema: SchemaObject; 67 + if (compositionSchema.$ref) { 68 + resolvedSchema = context.resolveRef<SchemaObject>( 69 + compositionSchema.$ref, 70 + ); 71 + } else { 72 + resolvedSchema = compositionSchema; 73 + } 74 + 75 + findDiscriminatorsInSchema({ 76 + context, 77 + discriminators, 78 + schema: resolvedSchema, 79 + }); 80 + } 81 + } 82 + 83 + return discriminators; 84 + }; 85 + 35 86 const parseSchemaJsDoc = ({ 36 87 irSchema, 37 88 schema, ··· 421 472 if (compositionSchema.$ref) { 422 473 const ref = context.resolveRef<SchemaObject>(compositionSchema.$ref); 423 474 // `$ref` should be passed from the root `parseSchema()` call 424 - if (ref.discriminator && state.$ref) { 425 - const values = discriminatorValues( 426 - state.$ref, 427 - ref.discriminator.mapping, 428 - // If the ref has oneOf, we only use the schema name as the value 429 - // only if current schema is part of the oneOf. Else it is extending 430 - // the ref schema 431 - ref.oneOf 432 - ? () => ref.oneOf!.some((o) => '$ref' in o && o.$ref === state.$ref) 433 - : undefined, 434 - ); 435 - if (values.length > 0) { 436 - const valueSchemas: ReadonlyArray<IR.SchemaObject> = values.map( 437 - (value) => ({ 438 - const: value, 439 - type: 'string', 440 - }), 475 + if (state.$ref) { 476 + // Find all discriminators in the referenced schema, including nested allOf compositions 477 + const discriminators = findDiscriminatorsInSchema({ 478 + context, 479 + schema: ref, 480 + }); 481 + 482 + // Track discriminator properties we've already added to avoid duplicates 483 + const addedDiscriminators = new Set<string>(); 484 + 485 + // Process each discriminator found 486 + for (const { discriminator, oneOf } of discriminators) { 487 + // Skip if we've already added this discriminator property 488 + if (addedDiscriminators.has(discriminator.propertyName)) { 489 + continue; 490 + } 491 + 492 + const values = discriminatorValues( 493 + state.$ref, 494 + discriminator.mapping, 495 + // If the ref has oneOf, we only use the schema name as the value 496 + // only if current schema is part of the oneOf. Else it is extending 497 + // the ref schema 498 + oneOf 499 + ? () => oneOf.some((o) => '$ref' in o && o.$ref === state.$ref) 500 + : undefined, 441 501 ); 442 - const irDiscriminatorSchema: IR.SchemaObject = { 443 - properties: { 444 - [ref.discriminator.propertyName]: 445 - valueSchemas.length > 1 446 - ? { 447 - items: valueSchemas, 448 - logicalOperator: 'or', 449 - } 450 - : valueSchemas[0]!, 451 - }, 452 - type: 'object', 453 - }; 454 - if (ref.required?.includes(ref.discriminator.propertyName)) { 455 - irDiscriminatorSchema.required = [ref.discriminator.propertyName]; 502 + 503 + if (values.length > 0) { 504 + const valueSchemas: ReadonlyArray<IR.SchemaObject> = values.map( 505 + (value) => ({ 506 + const: value, 507 + type: 'string', 508 + }), 509 + ); 510 + const irDiscriminatorSchema: IR.SchemaObject = { 511 + properties: { 512 + [discriminator.propertyName]: 513 + valueSchemas.length > 1 514 + ? { 515 + items: valueSchemas, 516 + logicalOperator: 'or', 517 + } 518 + : valueSchemas[0]!, 519 + }, 520 + type: 'object', 521 + }; 522 + 523 + // Check if the discriminator property is required in any of the discriminator schemas 524 + // by looking at all discriminators with the same property name 525 + const isRequired = discriminators.some( 526 + (d) => 527 + d.discriminator.propertyName === discriminator.propertyName && 528 + // Check in the ref's required array or in the allOf components 529 + (ref.required?.includes(d.discriminator.propertyName) || 530 + (ref.allOf && 531 + ref.allOf.some((item) => { 532 + const resolvedItem = item.$ref 533 + ? context.resolveRef<SchemaObject>(item.$ref) 534 + : item; 535 + return resolvedItem.required?.includes( 536 + d.discriminator.propertyName, 537 + ); 538 + }))), 539 + ); 540 + 541 + if (isRequired) { 542 + irDiscriminatorSchema.required = [discriminator.propertyName]; 543 + } 544 + schemaItems.push(irDiscriminatorSchema); 545 + addedDiscriminators.add(discriminator.propertyName); 456 546 } 457 - schemaItems.push(irDiscriminatorSchema); 458 547 } 459 548 } 460 549 }
+94
specs/3.1.x/discriminator-allof-nested.json
··· 1 + { 2 + "openapi": "3.1.0", 3 + "info": { 4 + "title": "Minimal Polymorphic Discriminator Reproduction", 5 + "version": "1.0.0", 6 + "description": "Demonstrates an issue where TypeScript type generation results in wrong discriminator for nested allOf inheritance with discriminators at multiple levels." 7 + }, 8 + "paths": { 9 + "/cars": { 10 + "get": { 11 + "summary": "Get cars", 12 + "responses": { 13 + "200": { 14 + "description": "List of cars", 15 + "content": { 16 + "application/json": { 17 + "schema": { 18 + "type": "array", 19 + "items": { 20 + "oneOf": [ 21 + { "$ref": "#/components/schemas/CarDto" }, 22 + { "$ref": "#/components/schemas/VolvoDto" } 23 + ] 24 + } 25 + } 26 + } 27 + } 28 + } 29 + } 30 + } 31 + } 32 + }, 33 + "components": { 34 + "schemas": { 35 + "VehicleDto": { 36 + "type": "object", 37 + "required": ["$type", "id"], 38 + "properties": { 39 + "$type": { 40 + "type": "string" 41 + }, 42 + "id": { 43 + "type": "integer" 44 + } 45 + }, 46 + "discriminator": { 47 + "propertyName": "$type", 48 + "mapping": { 49 + "Car": "#/components/schemas/CarDto", 50 + "Volvo": "#/components/schemas/VolvoDto" 51 + } 52 + } 53 + }, 54 + "CarDto": { 55 + "allOf": [ 56 + { "$ref": "#/components/schemas/VehicleDto" }, 57 + { 58 + "type": "object", 59 + "required": ["$type", "modelName"], 60 + "properties": { 61 + "$type": { 62 + "type": "string" 63 + }, 64 + "modelName": { 65 + "type": "string" 66 + } 67 + }, 68 + "discriminator": { 69 + "propertyName": "$type", 70 + "mapping": { 71 + "Car": "#/components/schemas/CarDto", 72 + "Volvo": "#/components/schemas/VolvoDto" 73 + } 74 + } 75 + } 76 + ] 77 + }, 78 + "VolvoDto": { 79 + "allOf": [ 80 + { "$ref": "#/components/schemas/CarDto" }, 81 + { 82 + "type": "object", 83 + "required": ["$type", "seatbeltCount"], 84 + "properties": { 85 + "seatbeltCount": { 86 + "type": "integer" 87 + } 88 + } 89 + } 90 + ] 91 + } 92 + } 93 + } 94 + }