diff --git a/packages/openapi-typescript/examples/digital-ocean-api.ts b/packages/openapi-typescript/examples/digital-ocean-api.ts index 5bbc0be90..667967907 100644 --- a/packages/openapi-typescript/examples/digital-ocean-api.ts +++ b/packages/openapi-typescript/examples/digital-ocean-api.ts @@ -9881,18 +9881,26 @@ export interface components { */ type: "assign" | "unassign"; }; - floating_ip_action_assign: { - type: "assign"; - } & (Omit & { + floating_ip_action_assign: Omit & { /** * @description The ID of the Droplet that the floating IP will be assigned to. * @example 758604968 */ droplet_id: number; - }); - floating_ip_action_unassign: { + } & { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "assign"; + }; + floating_ip_action_unassign: Omit & Record & { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ type: "unassign"; - } & (Omit & Record); + }; namespace_info: { /** * @description The namespace's API hostname. Each function in a namespace is provided an endpoint at the namespace's hostname. @@ -11555,18 +11563,26 @@ export interface components { */ type: "assign" | "unassign"; }; - reserved_ip_action_assign: { - type: "assign"; - } & (Omit & { + reserved_ip_action_assign: Omit & { /** * @description The ID of the Droplet that the reserved IP will be assigned to. * @example 758604968 */ droplet_id: number; - }); - reserved_ip_action_unassign: { + } & { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "assign"; + }; + reserved_ip_action_unassign: Omit & Record & { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ type: "unassign"; - } & (Omit & Record); + }; snapshots: { /** * @description The unique identifier for the snapshot. @@ -19323,7 +19339,7 @@ export interface operations { * */ requestBody?: { content: { - "application/json": Omit | Omit; + "application/json": components["schemas"]["floating_ip_action_unassign"] | components["schemas"]["floating_ip_action_assign"]; }; }; responses: { @@ -22367,7 +22383,7 @@ export interface operations { * */ requestBody?: { content: { - "application/json": Omit | Omit; + "application/json": components["schemas"]["reserved_ip_action_unassign"] | components["schemas"]["reserved_ip_action_assign"]; }; }; responses: { diff --git a/packages/openapi-typescript/scripts/update-examples.ts b/packages/openapi-typescript/scripts/update-examples.ts index b5d8f9349..81a8e8f46 100644 --- a/packages/openapi-typescript/scripts/update-examples.ts +++ b/packages/openapi-typescript/scripts/update-examples.ts @@ -15,18 +15,31 @@ async function generateSchemas() { const updateSchema = async (name: string, ext: string) => { const start = performance.now(); - await execa( - "./bin/cli.js", - [`./examples/${name}${ext}`, "-o", `./examples/${name}.ts`], - { cwd }, - ); - - schemasDoneCount++; - const timeMs = Math.round(performance.now() - start); - - console.log( - `✔︎ [${schemasDoneCount}/${schemaTotalCount}] Updated ${name} (${timeMs}ms)`, - ); + try { + await execa( + "./bin/cli.js", + [`./examples/${name}${ext}`, "-o", `./examples/${name}.ts`], + { + cwd: + process.platform === "win32" + ? // execa/cross-spawn can not handle URL objects on Windows, so convert it to string and cut away the protocol + cwd.toString().slice("file:///".length) + : cwd, + }, + ); + + schemasDoneCount++; + const timeMs = Math.round(performance.now() - start); + + console.log( + `✔︎ [${schemasDoneCount}/${schemaTotalCount}] Updated ${name} (${timeMs}ms)`, + ); + } catch (error) { + console.error( + `✘ [${schemasDoneCount}/${schemaTotalCount}] Failed to update ${name}`, + { error: error instanceof Error ? error.message : error }, + ); + } }; console.log("Updating examples..."); diff --git a/packages/openapi-typescript/src/lib/utils.ts b/packages/openapi-typescript/src/lib/utils.ts index 05c434c03..01f4e9af2 100644 --- a/packages/openapi-typescript/src/lib/utils.ts +++ b/packages/openapi-typescript/src/lib/utils.ts @@ -184,6 +184,68 @@ function createDiscriminatorEnum( }; } +/** Adds or replaces the discriminator enum with the passed `values` in a schema defined by `ref` */ +function patchDiscriminatorEnum( + schema: SchemaObject, + ref: string, + values: string[], + discriminator: DiscriminatorObject, + discriminatorRef: string, + options: OpenAPITSOptions, +): boolean { + const resolvedSchema = resolveRef(schema, ref, { + silent: options.silent ?? false, + }); + + if (resolvedSchema?.allOf) { + // if the schema is an allOf, we can append a new schema object to the allOf array + resolvedSchema.allOf.push({ + type: "object", + // discriminator enum properties always need to be required + required: [discriminator.propertyName], + properties: { + [discriminator.propertyName]: createDiscriminatorEnum(values), + }, + }); + + return true; + } else if ( + typeof resolvedSchema === "object" && + "type" in resolvedSchema && + resolvedSchema.type === "object" + ) { + // if the schema is an object, we can apply the discriminator enums to its properties + if (!resolvedSchema.properties) { + resolvedSchema.properties = {}; + } + + // discriminator enum properties always need to be required + if (!resolvedSchema.required) { + resolvedSchema.required = [discriminator.propertyName]; + } else if (!resolvedSchema.required.includes(discriminator.propertyName)) { + resolvedSchema.required.push(discriminator.propertyName); + } + + // add/replace the discriminator enum property + resolvedSchema.properties[discriminator.propertyName] = + createDiscriminatorEnum( + values, + resolvedSchema.properties[discriminator.propertyName], + ); + + return true; + } + + warn( + `Discriminator mapping has an invalid schema (neither an object schema nor an allOf array): ${ref} => ${values.join( + ", ", + )} (Discriminator: ${discriminatorRef})`, + options.silent, + ); + + return false; +} + type InternalDiscriminatorMapping = Record< string, { inferred?: string; defined?: string[] } @@ -267,57 +329,18 @@ export function scanDiscriminators( // the inferred enum values from the schema might not represent the actual enum values of the discriminator, // so if we have defined values, use them instead const mappedValues = defined ?? [inferred!]; - const resolvedSchema = resolveRef(schema, mappedRef, { - silent: options.silent ?? false, - }); - - if (resolvedSchema?.allOf) { - // if the schema is an allOf, we can append a new schema object to the allOf array - resolvedSchema.allOf.push({ - type: "object", - // discriminator enum properties always need to be required - required: [discriminator.propertyName], - properties: { - [discriminator.propertyName]: createDiscriminatorEnum(mappedValues), - }, - }); - refsHandled.push(mappedRef); - } else if ( - typeof resolvedSchema === "object" && - "type" in resolvedSchema && - resolvedSchema.type === "object" + if ( + patchDiscriminatorEnum( + schema, + mappedRef, + mappedValues, + discriminator, + ref, + options, + ) ) { - // if the schema is an object, we can apply the discriminator enums to its properties - if (!resolvedSchema.properties) { - resolvedSchema.properties = {}; - } - - // discriminator enum properties always need to be required - if (!resolvedSchema.required) { - resolvedSchema.required = [discriminator.propertyName]; - } else if ( - !resolvedSchema.required.includes(discriminator.propertyName) - ) { - resolvedSchema.required.push(discriminator.propertyName); - } - - // add/replace the discriminator enum property - resolvedSchema.properties[discriminator.propertyName] = - createDiscriminatorEnum( - mappedValues, - resolvedSchema.properties[discriminator.propertyName], - ); - refsHandled.push(mappedRef); - } else { - warn( - `Discriminator mapping has an invalid schema (neither an object schema nor an allOf array): ${mappedRef} => ${mappedValues.join( - ", ", - )} (Discriminator: ${ref})`, - options.silent, - ); - continue; } } }); @@ -326,19 +349,48 @@ export function scanDiscriminators( // (sometimes this mapping is implicit, so it can’t be done until we know // about every discriminator in the document) walk(schema, (obj, path) => { - for (const key of ["oneOf", "anyOf", "allOf"] as const) { - if (obj && Array.isArray(obj[key])) { - for (const item of (obj as any)[key]) { - if ("$ref" in item) { - if (objects[item.$ref]) { - objects[createRef(path)] = { - ...objects[item.$ref], - }; + if (!obj || !Array.isArray(obj.allOf)) { + return; + } + + for (const item of (obj as any).allOf) { + if ("$ref" in item) { + if (!objects[item.$ref]) { + return; + } + + const ref = createRef(path); + const discriminator = objects[item.$ref]; + const mappedValues: string[] = []; + + if (discriminator.mapping) { + for (const mappedValue in discriminator.mapping) { + if (discriminator.mapping[mappedValue] === ref) { + mappedValues.push(mappedValue); + } + } + + if (mappedValues.length > 0) { + if ( + patchDiscriminatorEnum( + schema, + ref, + mappedValues, + discriminator, + item.$ref, + options, + ) + ) { + refsHandled.push(ref); } - } else if (item.discriminator?.propertyName) { - objects[createRef(path)] = { ...item.discriminator }; } } + + objects[ref] = { + ...objects[item.$ref], + }; + } else if (item.discriminator?.propertyName) { + objects[createRef(path)] = { ...item.discriminator }; } } }); diff --git a/packages/openapi-typescript/src/transform/schema-object.ts b/packages/openapi-typescript/src/transform/schema-object.ts index 0b6892249..4bdf036ac 100644 --- a/packages/openapi-typescript/src/transform/schema-object.ts +++ b/packages/openapi-typescript/src/transform/schema-object.ts @@ -138,8 +138,18 @@ export function transformSchemaObjectWithComposition( * Object + composition (anyOf/allOf/oneOf) types */ - /** Collect oneOf/allOf/anyOf with Omit<> for discriminators */ - function collectCompositions( + /** Collect oneOf/anyOf */ + function collectUnionCompositions(items: (SchemaObject | ReferenceObject)[]) { + const output: ts.TypeNode[] = []; + for (const item of items) { + output.push(transformSchemaObject(item, options)); + } + + return output; + } + + /** Collect allOf with Omit<> for discriminators */ + function collectAllOfCompositions( items: (SchemaObject | ReferenceObject)[], required?: string[], ): ts.TypeNode[] { @@ -152,24 +162,25 @@ export function transformSchemaObjectWithComposition( itemType = transformSchemaObject(item, options); const resolved = options.ctx.resolve(item.$ref); + + // make keys required, if necessary if ( resolved && typeof resolved === "object" && - "properties" in resolved + "properties" in resolved && + // we have already handled this item (discriminator property was already added as required) + !options.ctx.discriminators.refsHandled.includes(item.$ref) ) { - // don’t try and make keys required if we have already handled the item (discriminator property was already added as required) - // or the $ref doesn’t have them - if (!options.ctx.discriminators.refsHandled.includes(item.$ref)) { - const validRequired = (required ?? []).filter( - (key) => !!resolved.properties![key], + // add WithRequired if necessary + const validRequired = (required ?? []).filter( + (key) => !!resolved.properties![key], + ); + if (validRequired.length) { + itemType = tsWithRequired( + itemType, + validRequired, + options.ctx.injectFooter, ); - if (validRequired.length) { - itemType = tsWithRequired( - itemType, - validRequired, - options.ctx.injectFooter, - ); - } } } } @@ -184,6 +195,7 @@ export function transformSchemaObjectWithComposition( options, ); } + const discriminator = ("$ref" in item && options.ctx.discriminators.objects[item.$ref]) || (item as any).discriminator; // eslint-disable-line @typescript-eslint/no-explicit-any @@ -201,7 +213,7 @@ export function transformSchemaObjectWithComposition( // core + allOf: intersect const coreObjectType = transformSchemaObjectCore(schemaObject, options); - const allOfType = collectCompositions( + const allOfType = collectAllOfCompositions( schemaObject.allOf ?? [], schemaObject.required, ); @@ -216,21 +228,17 @@ export function transformSchemaObjectWithComposition( } // anyOf: union // (note: this may seem counterintuitive, but as TypeScript’s unions are not true XORs, they mimic behavior closer to anyOf than oneOf) - const anyOfType = collectCompositions( - schemaObject.anyOf ?? [], - schemaObject.required, - ); + const anyOfType = collectUnionCompositions(schemaObject.anyOf ?? []); if (anyOfType.length) { finalType = tsUnion([...(finalType ? [finalType] : []), ...anyOfType]); } // oneOf: union (within intersection with other types, if any) - const oneOfType = collectCompositions( + const oneOfType = collectUnionCompositions( schemaObject.oneOf || ("type" in schemaObject && schemaObject.type === "object" && (schemaObject.enum as (SchemaObject | ReferenceObject)[])) || [], - schemaObject.required, ); if (oneOfType.length) { // note: oneOf is the only type that may include primitives @@ -408,16 +416,19 @@ function transformSchemaObjectCore( // type: object const coreObjectType: ts.TypeElement[] = []; - // discriminatorss: explicit mapping on schema object - for (const k of ["oneOf", "allOf", "anyOf"] as const) { + // discriminators: explicit mapping on schema object + for (const k of ["allOf", "anyOf"] as const) { if (!schemaObject[k]) { continue; } // for all magic inheritance, we will have already gathered it into // ctx.discriminators. But stop objects from referencing their own // discriminator meant for children (!schemaObject.discriminator) + // and don't add discriminator properties if we already added/patched + // them (options.ctx.discriminators.refsHandled.includes(options.path!). const discriminator = !schemaObject.discriminator && + !options.ctx.discriminators.refsHandled.includes(options.path!) && options.ctx.discriminators.objects[options.path!]; if (discriminator) { coreObjectType.unshift( diff --git a/packages/openapi-typescript/test/discriminators.test.ts b/packages/openapi-typescript/test/discriminators.test.ts index 5b01f0ac1..193c6bdbb 100644 --- a/packages/openapi-typescript/test/discriminators.test.ts +++ b/packages/openapi-typescript/test/discriminators.test.ts @@ -20,6 +20,7 @@ describe("3.1 discriminators", () => { propertyName: "petType", mapping: { dog: "#/components/schemas/Dog", + poodle: "#/components/schemas/Dog", }, }, }, @@ -43,6 +44,38 @@ describe("3.1 discriminators", () => { }, allOf: [{ $ref: "#/components/schemas/Pet" }], }, + LizardDog: { + allOf: [ + { $ref: "#/components/schemas/Dog" }, + { $ref: "#/components/schemas/Lizard" }, + ], + }, + AnimalSighting: { + oneOf: [ + { + $ref: "#/components/schemas/Cat", + }, + { + $ref: "#/components/schemas/Dog", + }, + { + $ref: "#/components/schemas/Lizard", + }, + ], + }, + Beast: { + anyOf: [ + { + $ref: "#/components/schemas/Cat", + }, + { + $ref: "#/components/schemas/Dog", + }, + { + $ref: "#/components/schemas/Lizard", + }, + ], + }, }, }, }, @@ -56,15 +89,24 @@ export interface components { Cat: { petType: "Cat"; } & Omit; - Dog: { - petType: "dog"; - } & (Omit & { + Dog: Omit & { bark?: string; - }); + } & { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + petType: "dog" | "poodle"; + }; Lizard: { petType: "Lizard"; lovesRocks?: boolean; } & Omit; + LizardDog: { + petType: "LizardDog"; + } & (Omit & Omit); + AnimalSighting: components["schemas"]["Cat"] | components["schemas"]["Dog"] | components["schemas"]["Lizard"]; + Beast: components["schemas"]["Cat"] | components["schemas"]["Dog"] | components["schemas"]["Lizard"]; }; responses: never; parameters: never; diff --git a/packages/openapi-typescript/test/transform/schema-object/composition.test.ts b/packages/openapi-typescript/test/transform/schema-object/composition.test.ts index a10ec2784..af8c45077 100644 --- a/packages/openapi-typescript/test/transform/schema-object/composition.test.ts +++ b/packages/openapi-typescript/test/transform/schema-object/composition.test.ts @@ -256,6 +256,52 @@ describe("composition", () => { ], [ "discriminator > oneOf", + { + given: { + oneOf: [ + { $ref: "#/components/schemas/Cat" }, + { $ref: "#/components/schemas/Dog" }, + ], + }, + want: `components["schemas"]["Cat"] | components["schemas"]["Dog"]`, + options: { + path: "#/components/schemas/Pet", + ctx: { + ...DEFAULT_OPTIONS.ctx, + discriminators: { + objects: { + "#/components/schemas/Pet": { + propertyName: "petType", + }, + "#/components/schemas/Cat": { + propertyName: "petType", + }, + "#/components/schemas/Dog": { + propertyName: "petType", + }, + }, + refsHandled: [], + }, + resolve($ref) { + switch ($ref) { + case "#/components/schemas/Pet": { + return { + propertyName: "petType", + oneOf: ["#/components/schemas/Cat"], + }; + } + default: { + return undefined as any; + } + } + }, + }, + }, + }, + ], + [ + // this is actually invalid syntax for oneOfs, but we support it anyways for better compatibility with bad schemas + "discriminator > oneOf inside object", { given: { type: "object", @@ -269,9 +315,8 @@ describe("composition", () => { ], }, want: `{ - petType: "Pet"; name: string; -} & (Omit | Omit)`, +} & (components["schemas"]["Cat"] | components["schemas"]["Dog"])`, options: { path: "#/components/schemas/Pet", ctx: { @@ -313,9 +358,7 @@ describe("composition", () => { given: { oneOf: [{ $ref: "#/components/schemas/parent" }, { type: "null" }], }, - want: `{ - operation: "schema-object"; -} & (Omit | null)`, + want: `components["schemas"]["parent"] | null`, options: { ...DEFAULT_OPTIONS, ctx: {