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.

Merge pull request #3301 from hey-api/copilot/fix-type-discriminator-values

Fix discriminator values in writable type variants

authored by

Lubos and committed by
GitHub
a8d0bf79 5ff53705

+443 -7
+5
.changeset/quiet-hats-switch.md
··· 1 + --- 2 + "@hey-api/shared": patch 3 + --- 4 + 5 + **transform(read-write)**: improve discriminated schemas split
+7
packages/openapi-ts-tests/main/test/3.1.x.test.ts
··· 229 229 }, 230 230 { 231 231 config: createConfig({ 232 + input: 'discriminator-one-of-read-write.yaml', 233 + output: 'discriminator-one-of-read-write', 234 + }), 235 + description: 'handles discriminator with oneOf and read/write transforms', 236 + }, 237 + { 238 + config: createConfig({ 232 239 input: 'duplicate-null.json', 233 240 output: 'duplicate-null', 234 241 }),
+3
packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/discriminator-one-of-read-write/index.ts
··· 1 + // This file is auto-generated by @hey-api/openapi-ts 2 + 3 + export type { AnimalPayload, AnimalPayloadWritable, CatPayload, CatPayloadWritable, ClientOptions, CreatePetRequest, CreatePetRequestWritable, CreatePetResponse, CreatePetResponseWritable, DogPayload, DogPayloadWritable, PostPetsData, PostPetsResponse, PostPetsResponses } from './types.gen';
+93
packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/discriminator-one-of-read-write/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 AnimalPayload = { 8 + typeDiscriminator: string; 9 + }; 10 + 11 + export type DogPayload = Omit<AnimalPayload, 'typeDiscriminator'> & { 12 + breed: string; 13 + canFetch: boolean; 14 + readonly displayBreed: string; 15 + typeDiscriminator: 'dog'; 16 + }; 17 + 18 + export type CatPayload = Omit<AnimalPayload, 'typeDiscriminator'> & { 19 + breed: string; 20 + livesRemaining: number; 21 + readonly displayBreed: string; 22 + typeDiscriminator: 'cat'; 23 + }; 24 + 25 + export type CreatePetRequest = { 26 + name: string; 27 + animal: ({ 28 + typeDiscriminator: 'dog'; 29 + } & DogPayload) | ({ 30 + typeDiscriminator: 'cat'; 31 + } & CatPayload); 32 + }; 33 + 34 + export type CreatePetResponse = { 35 + id: string; 36 + name: string; 37 + animal: ({ 38 + typeDiscriminator: 'dog'; 39 + } & DogPayload) | ({ 40 + typeDiscriminator: 'cat'; 41 + } & CatPayload); 42 + }; 43 + 44 + export type DogPayloadWritable = Omit<AnimalPayloadWritable, 'typeDiscriminator'> & { 45 + breed: string; 46 + canFetch: boolean; 47 + typeDiscriminator: 'dog'; 48 + }; 49 + 50 + export type CatPayloadWritable = Omit<AnimalPayloadWritable, 'typeDiscriminator'> & { 51 + breed: string; 52 + livesRemaining: number; 53 + typeDiscriminator: 'cat'; 54 + }; 55 + 56 + export type CreatePetRequestWritable = { 57 + name: string; 58 + animal: ({ 59 + typeDiscriminator: 'dog'; 60 + } & DogPayloadWritable) | ({ 61 + typeDiscriminator: 'cat'; 62 + } & CatPayloadWritable); 63 + }; 64 + 65 + export type CreatePetResponseWritable = { 66 + id: string; 67 + name: string; 68 + animal: ({ 69 + typeDiscriminator: 'dog'; 70 + } & DogPayloadWritable) | ({ 71 + typeDiscriminator: 'cat'; 72 + } & CatPayloadWritable); 73 + }; 74 + 75 + export type AnimalPayloadWritable = { 76 + typeDiscriminator: string; 77 + }; 78 + 79 + export type PostPetsData = { 80 + body: CreatePetRequestWritable; 81 + path?: never; 82 + query?: never; 83 + url: '/pets'; 84 + }; 85 + 86 + export type PostPetsResponses = { 87 + /** 88 + * OK 89 + */ 90 + 200: CreatePetResponse; 91 + }; 92 + 93 + export type PostPetsResponse = PostPetsResponses[keyof PostPetsResponses];
+228 -7
packages/shared/src/openApi/shared/transforms/readWrite.ts
··· 17 17 18 18 type OriginalSchemas = Record<string, unknown>; 19 19 20 + type SplitMapping = Record< 21 + string, 22 + { 23 + read?: string; 24 + write?: string; 25 + } 26 + >; 27 + 20 28 type SplitSchemas = { 21 29 /** Key is the original schema pointer. */ 22 - mapping: Record< 23 - string, 24 - { 25 - read?: string; 26 - write?: string; 27 - } 28 - >; 30 + mapping: SplitMapping; 29 31 /** splitPointer -> originalPointer */ 30 32 reverseMapping: Record<string, string>; 31 33 /** name -> schema object */ ··· 313 315 }; 314 316 315 317 /** 318 + * Create writable variants of parent schemas that have discriminators 319 + * and are referenced by split schemas. 320 + */ 321 + function splitDiscriminatorSchemas({ 322 + config, 323 + existingNames, 324 + schemasPointerNamespace, 325 + spec, 326 + split, 327 + }: { 328 + config: ReadWriteConfig; 329 + existingNames: Set<string>; 330 + schemasPointerNamespace: string; 331 + spec: unknown; 332 + split: SplitSchemas; 333 + }) { 334 + const schemasObj = getSchemasObject(spec); 335 + if (!schemasObj) return; 336 + 337 + const parentSchemasToSplit = new Map<string, Set<Scope>>(); 338 + 339 + // First pass: identify parent schemas that need writable variants 340 + for (const [name, schema] of Object.entries(split.schemas)) { 341 + const pointer = `${schemasPointerNamespace}${name}`; 342 + const originalPointer = split.reverseMapping[pointer]; 343 + 344 + if (originalPointer) { 345 + const mapping = split.mapping[originalPointer]; 346 + if (mapping) { 347 + const contextVariant: Scope | null = 348 + mapping.read === pointer ? 'read' : mapping.write === pointer ? 'write' : null; 349 + 350 + // Check allOf for $refs to schemas with discriminators 351 + if ( 352 + contextVariant && 353 + schema && 354 + typeof schema === 'object' && 355 + 'allOf' in schema && 356 + schema.allOf instanceof Array 357 + ) { 358 + for (const comp of schema.allOf) { 359 + if ( 360 + comp && 361 + typeof comp === 'object' && 362 + '$ref' in comp && 363 + typeof comp.$ref === 'string' 364 + ) { 365 + const refPath = jsonPointerToPath(comp.$ref); 366 + const schemaName = refPath[refPath.length - 1]; 367 + 368 + if (typeof schemaName === 'string' && schemaName in schemasObj) { 369 + const resolvedSchema = schemasObj[schemaName]; 370 + 371 + // Check if this schema has a discriminator with mapping 372 + if ( 373 + resolvedSchema && 374 + typeof resolvedSchema === 'object' && 375 + 'discriminator' in resolvedSchema && 376 + resolvedSchema.discriminator && 377 + typeof resolvedSchema.discriminator === 'object' && 378 + 'mapping' in resolvedSchema.discriminator && 379 + resolvedSchema.discriminator.mapping && 380 + typeof resolvedSchema.discriminator.mapping === 'object' 381 + ) { 382 + // This parent schema needs a variant for this context 383 + if (!parentSchemasToSplit.has(comp.$ref)) { 384 + parentSchemasToSplit.set(comp.$ref, new Set()); 385 + } 386 + parentSchemasToSplit.get(comp.$ref)!.add(contextVariant); 387 + } 388 + } 389 + } 390 + } 391 + } 392 + } 393 + } 394 + } 395 + 396 + // Second pass: create writable variants of parent schemas and update their discriminator mappings 397 + const parentSchemaVariants = new Map<string, SplitMapping[keyof SplitMapping]>(); 398 + 399 + for (const [parentRef, contexts] of parentSchemasToSplit) { 400 + const refPath = jsonPointerToPath(parentRef); 401 + const parentName = refPath[refPath.length - 1]; 402 + 403 + if (typeof parentName !== 'string' || !(parentName in schemasObj)) continue; 404 + 405 + const parentSchema = schemasObj[parentName]; 406 + if (!parentSchema || typeof parentSchema !== 'object') continue; 407 + 408 + const variants: SplitMapping[keyof SplitMapping] = {}; 409 + 410 + // Create variants for each context 411 + for (const context of contexts) { 412 + const variantSchema = deepClone(parentSchema); 413 + 414 + // Update discriminator mapping in the variant 415 + if ( 416 + 'discriminator' in variantSchema && 417 + variantSchema.discriminator && 418 + typeof variantSchema.discriminator === 'object' && 419 + 'mapping' in variantSchema.discriminator && 420 + variantSchema.discriminator.mapping && 421 + typeof variantSchema.discriminator.mapping === 'object' 422 + ) { 423 + const mapping = variantSchema.discriminator.mapping; 424 + const updatedMapping: Record<string, string> = {}; 425 + 426 + for (const [discriminatorValue, originalRef] of Object.entries(mapping)) { 427 + const map = split.mapping[originalRef]; 428 + if (map) { 429 + if (context === 'read' && map.read) { 430 + updatedMapping[discriminatorValue] = map.read; 431 + } else if (context === 'write' && map.write) { 432 + updatedMapping[discriminatorValue] = map.write; 433 + } else { 434 + updatedMapping[discriminatorValue] = originalRef; 435 + } 436 + } else { 437 + updatedMapping[discriminatorValue] = originalRef; 438 + } 439 + } 440 + 441 + variantSchema.discriminator.mapping = updatedMapping; 442 + } 443 + 444 + // Add the variant to split.schemas with an appropriate name 445 + if (context === 'write') { 446 + const writeBase = applyNaming(parentName, config.requests); 447 + const writeName = getUniqueComponentName({ 448 + base: writeBase, 449 + components: existingNames, 450 + }); 451 + existingNames.add(writeName); 452 + split.schemas[writeName] = variantSchema; 453 + variants.write = `${schemasPointerNamespace}${writeName}`; 454 + } 455 + // We could create read variants too, but typically they're not needed 456 + // since the original schema serves as the read variant 457 + } 458 + 459 + parentSchemaVariants.set(parentRef, variants); 460 + } 461 + 462 + // Third pass: update $refs in split schemas to point to the parent variants 463 + for (const [name, schema] of Object.entries(split.schemas)) { 464 + const pointer = `${schemasPointerNamespace}${name}`; 465 + const originalPointer = split.reverseMapping[pointer]; 466 + if (!originalPointer) continue; 467 + 468 + const mapping = split.mapping[originalPointer]; 469 + if (!mapping) continue; 470 + 471 + const contextVariant: Scope | null = 472 + mapping.read === pointer ? 'read' : mapping.write === pointer ? 'write' : null; 473 + 474 + if (contextVariant && schema && typeof schema === 'object') { 475 + // Update $refs in allOf 476 + if ('allOf' in schema && schema.allOf instanceof Array) { 477 + for (let i = 0; i < schema.allOf.length; i++) { 478 + const comp = schema.allOf[i]; 479 + 480 + if (comp && typeof comp === 'object' && '$ref' in comp && typeof comp.$ref === 'string') { 481 + const variants = parentSchemaVariants.get(comp.$ref); 482 + 483 + if (variants) { 484 + if (contextVariant === 'write' && variants.write) { 485 + comp.$ref = variants.write; 486 + } else if (contextVariant === 'read' && variants.read) { 487 + comp.$ref = variants.read; 488 + } 489 + } 490 + } 491 + } 492 + } 493 + } 494 + } 495 + } 496 + 497 + /** 316 498 * Splits schemas with both 'read' and 'write' scopes into read/write variants. 317 499 * Returns the new schemas and a mapping from original pointer to new variant pointers. 318 500 * ··· 436 618 split.reverseMapping[readPointer] = pointer; 437 619 split.reverseMapping[writePointer] = pointer; 438 620 } 621 + 622 + splitDiscriminatorSchemas({ 623 + config, 624 + existingNames, 625 + schemasPointerNamespace, 626 + spec, 627 + split, 628 + }); 439 629 440 630 event.timeEnd(); 441 631 return split; ··· 630 820 (node as Record<string, unknown>)[key] = map.read; 631 821 } 632 822 } 823 + } else if (key === 'discriminator' && typeof value === 'object' && value !== null) { 824 + // Update discriminator mappings to point to the correct read/write variants 825 + if ('mapping' in value && value.mapping && typeof value.mapping === 'object') { 826 + const updatedMapping: Record<string, string> = {}; 827 + for (const [discriminatorValue, originalRef] of Object.entries(value.mapping)) { 828 + const map = split.mapping[originalRef]; 829 + if (map) { 830 + if (nextContext === 'read' && map.read) { 831 + updatedMapping[discriminatorValue] = map.read; 832 + } else if (nextContext === 'write' && map.write) { 833 + updatedMapping[discriminatorValue] = map.write; 834 + } else { 835 + // For schemas with no context, don't update the mapping. 836 + // This preserves the original mapping for base schemas. 837 + updatedMapping[discriminatorValue] = originalRef; 838 + } 839 + } else { 840 + updatedMapping[discriminatorValue] = originalRef; 841 + } 842 + } 843 + value.mapping = updatedMapping; 844 + } 845 + // Continue walking the discriminator object for other properties 846 + walk({ 847 + context: nextContext, 848 + currentPointer: nextPointer, 849 + inSchema, 850 + node: value, 851 + path: [...path, key], 852 + visited, 853 + }); 633 854 } else { 634 855 walk({ 635 856 context: nextContext,
+107
specs/3.1.x/discriminator-one-of-read-write.yaml
··· 1 + openapi: 3.1.0 2 + info: 3 + title: OpenAPI 3.1.0 discriminator one of with read/write example 4 + version: 1 5 + paths: 6 + /pets: 7 + post: 8 + requestBody: 9 + content: 10 + application/json: 11 + schema: 12 + $ref: '#/components/schemas/CreatePetRequest' 13 + required: true 14 + responses: 15 + '200': 16 + content: 17 + application/json: 18 + schema: 19 + $ref: '#/components/schemas/CreatePetResponse' 20 + description: OK 21 + components: 22 + schemas: 23 + AnimalPayload: 24 + type: object 25 + required: 26 + - typeDiscriminator 27 + properties: 28 + typeDiscriminator: 29 + type: string 30 + discriminator: 31 + propertyName: typeDiscriminator 32 + mapping: 33 + dog: '#/components/schemas/DogPayload' 34 + cat: '#/components/schemas/CatPayload' 35 + 36 + DogPayload: 37 + allOf: 38 + - $ref: '#/components/schemas/AnimalPayload' 39 + - type: object 40 + required: 41 + - breed 42 + - canFetch 43 + - displayBreed 44 + properties: 45 + breed: 46 + type: string 47 + canFetch: 48 + type: boolean 49 + displayBreed: 50 + type: string 51 + readOnly: true 52 + 53 + CatPayload: 54 + allOf: 55 + - $ref: '#/components/schemas/AnimalPayload' 56 + - type: object 57 + required: 58 + - breed 59 + - livesRemaining 60 + - displayBreed 61 + properties: 62 + breed: 63 + type: string 64 + livesRemaining: 65 + type: integer 66 + displayBreed: 67 + type: string 68 + readOnly: true 69 + 70 + CreatePetRequest: 71 + type: object 72 + required: 73 + - name 74 + - animal 75 + properties: 76 + name: 77 + type: string 78 + animal: 79 + oneOf: 80 + - $ref: '#/components/schemas/DogPayload' 81 + - $ref: '#/components/schemas/CatPayload' 82 + discriminator: 83 + propertyName: typeDiscriminator 84 + mapping: 85 + dog: '#/components/schemas/DogPayload' 86 + cat: '#/components/schemas/CatPayload' 87 + 88 + CreatePetResponse: 89 + type: object 90 + required: 91 + - id 92 + - name 93 + - animal 94 + properties: 95 + id: 96 + type: string 97 + name: 98 + type: string 99 + animal: 100 + oneOf: 101 + - $ref: '#/components/schemas/DogPayload' 102 + - $ref: '#/components/schemas/CatPayload' 103 + discriminator: 104 + propertyName: typeDiscriminator 105 + mapping: 106 + dog: '#/components/schemas/DogPayload' 107 + cat: '#/components/schemas/CatPayload'