diff --git a/packages/openapi-typescript/examples/digital-ocean-api.ts b/packages/openapi-typescript/examples/digital-ocean-api.ts index d8d61bdf3..5bbc0be90 100644 --- a/packages/openapi-typescript/examples/digital-ocean-api.ts +++ b/packages/openapi-typescript/examples/digital-ocean-api.ts @@ -9565,11 +9565,10 @@ export interface components { /** @description Specifies the action that will be taken on the Droplet. */ droplet_action: { /** - * @description The type of action to initiate for the Droplet. - * @example reboot + * @description The type of action to initiate for the Droplet. (enum property replaced by openapi-typescript) * @enum {string} */ - type: "enable_backups" | "disable_backups" | "reboot" | "power_cycle" | "shutdown" | "power_off" | "power_on" | "restore" | "password_reset" | "resize" | "rebuild" | "rename" | "change_kernel" | "enable_ipv6" | "snapshot"; + type: "enable_backups" | "disable_backups" | "power_cycle" | "shutdown" | "power_off" | "power_on" | "enable_ipv6"; }; droplet_action_restore: components["schemas"]["droplet_action"] & { /** @@ -9617,6 +9616,12 @@ export interface components { * @example Nifty New Snapshot */ name?: string; + } & { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "snapshot"; }; firewall_rule_base: { /** diff --git a/packages/openapi-typescript/src/index.ts b/packages/openapi-typescript/src/index.ts index e45b8b173..e0589514e 100644 --- a/packages/openapi-typescript/src/index.ts +++ b/packages/openapi-typescript/src/index.ts @@ -71,7 +71,7 @@ export default async function openapiTS( additionalProperties: options.additionalProperties ?? false, alphabetize: options.alphabetize ?? false, defaultNonNullable: options.defaultNonNullable ?? true, - discriminators: scanDiscriminators(schema), + discriminators: scanDiscriminators(schema, options), emptyObjectsUnknown: options.emptyObjectsUnknown ?? false, enum: options.enum ?? false, excludeDeprecated: options.excludeDeprecated ?? false, diff --git a/packages/openapi-typescript/src/lib/utils.ts b/packages/openapi-typescript/src/lib/utils.ts index e018cc31b..05c434c03 100644 --- a/packages/openapi-typescript/src/lib/utils.ts +++ b/packages/openapi-typescript/src/lib/utils.ts @@ -5,7 +5,13 @@ import { import c from "ansi-colors"; import supportsColor from "supports-color"; import ts from "typescript"; -import type { DiscriminatorObject, OpenAPI3 } from "../types.js"; +import type { + DiscriminatorObject, + OpenAPI3, + OpenAPITSOptions, + ReferenceObject, + SchemaObject, +} from "../types.js"; import { tsLiteral, tsModifiers, tsPropertyIndex } from "./ts.js"; if (!supportsColor.stdout || supportsColor.stdout.hasBasic === false) { @@ -165,15 +171,154 @@ export function resolveRef( return node; } +function createDiscriminatorEnum( + values: string[], + prevSchema?: SchemaObject, +): SchemaObject { + return { + type: "string", + enum: values, + description: prevSchema?.description + ? `${prevSchema.description} (enum property replaced by openapi-typescript)` + : `discriminator enum property added by openapi-typescript`, + }; +} + +type InternalDiscriminatorMapping = Record< + string, + { inferred?: string; defined?: string[] } +>; + /** Return a key–value map of discriminator objects found in a schema */ -export function scanDiscriminators(schema: OpenAPI3) { - const discriminators: Record = {}; +export function scanDiscriminators( + schema: OpenAPI3, + options: OpenAPITSOptions, +) { + // all discriminator objects found in the schema + const objects: Record = {}; + + // refs of all mapped schema objects we have successfully handled to infer the discriminator enum value + const refsHandled: string[] = []; - // perform 2 passes: first, collect all discriminator definitions + // perform 2 passes: first, collect all discriminator definitions and handle oneOf and mappings walk(schema, (obj, path) => { - if ((obj?.discriminator as DiscriminatorObject)?.propertyName) { - discriminators[createRef(path)] = - obj.discriminator as DiscriminatorObject; + const discriminator = obj?.discriminator as DiscriminatorObject | undefined; + if (!discriminator?.propertyName) { + return; + } + + // collect discriminator object for later usage + const ref = createRef(path); + + objects[ref] = discriminator; + + // if a mapping is available we will help Typescript to infer properties by adding the discriminator enum with its single mapped value to each schema + // we only handle the mapping in advance for discriminator + oneOf compositions right now + if (!obj?.oneOf || !Array.isArray(obj.oneOf)) { + return; + } + + const oneOf: (SchemaObject | ReferenceObject)[] = obj.oneOf; + const mapping: InternalDiscriminatorMapping = {}; + + // the mapping can be inferred from the oneOf refs next to the discriminator object + for (const item of oneOf) { + if ("$ref" in item) { + // the name of the schema is the inferred discriminator enum value + const value = item.$ref.split("/").pop(); + + if (value) { + if (!mapping[item.$ref]) { + mapping[item.$ref] = { inferred: value }; + } else { + mapping[item.$ref].inferred = value; + } + } + } + } + + // the mapping can be defined in the discriminator object itself + if (discriminator.mapping) { + for (const mappedValue in discriminator.mapping) { + const mappedRef = discriminator.mapping[mappedValue]; + if (!mappedRef) { + continue; + } + + if (!mapping[mappedRef]?.defined) { + // this overrides inferred values, but we don't need them anymore as soon as we have a defined value + mapping[mappedRef] = { defined: [] }; + } + + mapping[mappedRef].defined?.push(mappedValue); + } + } + + for (const [mappedRef, { inferred, defined }] of Object.entries(mapping)) { + if (refsHandled.includes(mappedRef)) { + continue; + } + + if (!inferred && !defined) { + continue; + } + + // prefer defined values over automatically inferred ones + // 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 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; + } } }); @@ -185,19 +330,20 @@ export function scanDiscriminators(schema: OpenAPI3) { if (obj && Array.isArray(obj[key])) { for (const item of (obj as any)[key]) { if ("$ref" in item) { - if (discriminators[item.$ref]) { - discriminators[createRef(path)] = { - ...discriminators[item.$ref], + if (objects[item.$ref]) { + objects[createRef(path)] = { + ...objects[item.$ref], }; } } else if (item.discriminator?.propertyName) { - discriminators[createRef(path)] = { ...item.discriminator }; + objects[createRef(path)] = { ...item.discriminator }; } } } } }); - return discriminators; + + return { objects, refsHandled }; } /** Walk through any JSON-serializable (i.e. non-circular) object */ diff --git a/packages/openapi-typescript/src/transform/schema-object.ts b/packages/openapi-typescript/src/transform/schema-object.ts index 011dc6197..0b6892249 100644 --- a/packages/openapi-typescript/src/transform/schema-object.ts +++ b/packages/openapi-typescript/src/transform/schema-object.ts @@ -157,16 +157,19 @@ export function transformSchemaObjectWithComposition( typeof resolved === "object" && "properties" in resolved ) { - // don’t try and make keys required if the $ref doesn’t have them - const validRequired = (required ?? []).filter( - (key) => !!resolved.properties![key], - ); - if (validRequired.length) { - itemType = tsWithRequired( - itemType, - validRequired, - options.ctx.injectFooter, + // 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], ); + if (validRequired.length) { + itemType = tsWithRequired( + itemType, + validRequired, + options.ctx.injectFooter, + ); + } } } } @@ -182,7 +185,7 @@ export function transformSchemaObjectWithComposition( ); } const discriminator = - ("$ref" in item && options.ctx.discriminators[item.$ref]) || + ("$ref" in item && options.ctx.discriminators.objects[item.$ref]) || (item as any).discriminator; // eslint-disable-line @typescript-eslint/no-explicit-any if (discriminator) { output.push(tsOmit(itemType, [discriminator.propertyName])); @@ -414,7 +417,8 @@ function transformSchemaObjectCore( // ctx.discriminators. But stop objects from referencing their own // discriminator meant for children (!schemaObject.discriminator) const discriminator = - !schemaObject.discriminator && options.ctx.discriminators[options.path!]; + !schemaObject.discriminator && + options.ctx.discriminators.objects[options.path!]; if (discriminator) { coreObjectType.unshift( createDiscriminatorProperty(discriminator, { diff --git a/packages/openapi-typescript/src/types.ts b/packages/openapi-typescript/src/types.ts index 8212e9226..b371d687c 100644 --- a/packages/openapi-typescript/src/types.ts +++ b/packages/openapi-typescript/src/types.ts @@ -686,7 +686,10 @@ export interface GlobalContext { additionalProperties: boolean; alphabetize: boolean; defaultNonNullable: boolean; - discriminators: Record; + discriminators: { + objects: Record; + refsHandled: string[]; + }; emptyObjectsUnknown: boolean; enum: boolean; excludeDeprecated: boolean; diff --git a/packages/openapi-typescript/test/discriminators.test.ts b/packages/openapi-typescript/test/discriminators.test.ts index f0a518f3c..5b01f0ac1 100644 --- a/packages/openapi-typescript/test/discriminators.test.ts +++ b/packages/openapi-typescript/test/discriminators.test.ts @@ -195,7 +195,7 @@ export type operations = Record;`, }, ], [ - "oneOf", + "oneOf > implicit mapping", { given: { openapi: "3.1", @@ -246,12 +246,27 @@ export interface components { Pet: components["schemas"]["Cat"] | components["schemas"]["Dog"] | components["schemas"]["Lizard"]; Cat: { name?: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + petType: "Cat"; }; Dog: { bark?: string; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + petType: "dog"; }; Lizard: { lovesRocks?: boolean; + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + petType: "Lizard"; }; }; responses: never; @@ -265,6 +280,314 @@ export type operations = Record;`, // options: DEFAULT_OPTIONS, }, ], + [ + "oneOf > explicit mapping > replace discriminator enum", + { + given: { + openapi: "3.1.0", + info: { + title: "test", + version: 1, + }, + paths: { + "/endpoint": { + get: { + responses: { + "200": { + description: "OK", + content: { + "application/json": { + schema: { + oneOf: [ + { + $ref: "#/components/schemas/simpleObject", + }, + { + $ref: "#/components/schemas/complexObject", + }, + ], + discriminator: { + propertyName: "type", + mapping: { + simple: "#/components/schemas/simpleObject", + complex: "#/components/schemas/complexObject", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + simpleObject: { + type: "object", + required: ["type"], + properties: { + type: { + $ref: "#/components/schemas/type", + }, + simple: { + type: "boolean", + }, + }, + }, + complexObject: { + type: "object", + required: ["type"], + properties: { + type: { + $ref: "#/components/schemas/type", + }, + complex: { + type: "boolean", + }, + }, + }, + type: { + type: "string", + enum: ["simple", "complex"], + }, + }, + }, + }, + want: `export interface paths { + "/endpoint": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["simpleObject"] | components["schemas"]["complexObject"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + simpleObject: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "simple"; + simple?: boolean; + }; + complexObject: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "complex"; + complex?: boolean; + }; + /** @enum {string} */ + type: "simple" | "complex"; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export type operations = Record;`, + }, + ], + [ + "oneOf > explicit mapping > append enum in allOf", + { + given: { + openapi: "3.1.0", + info: { + title: "test", + version: 1, + }, + paths: { + "/endpoint": { + get: { + responses: { + "200": { + description: "OK", + content: { + "application/json": { + schema: { + oneOf: [ + { + $ref: "#/components/schemas/simpleObject", + }, + { + $ref: "#/components/schemas/complexObject", + }, + ], + discriminator: { + propertyName: "type", + mapping: { + simple: "#/components/schemas/simpleObject", + complex: "#/components/schemas/complexObject", + // special case for different enum value but using the same schema as 'complex' + tooComplex: "#/components/schemas/complexObject", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + baseObject: { + type: "object", + required: ["type"], + properties: { + type: { + $ref: "#/components/schemas/type", + }, + }, + }, + simpleObject: { + allOf: [ + { + $ref: "#/components/schemas/baseObject", + }, + { + type: "object", + properties: { + simple: { + type: "boolean", + }, + }, + }, + ], + }, + complexObject: { + allOf: [ + { + $ref: "#/components/schemas/baseObject", + }, + { + type: "object", + properties: { + complex: { + type: "boolean", + }, + }, + }, + ], + }, + type: { + type: "string", + enum: ["simple", "complex", "tooComplex"], + }, + }, + }, + }, + want: `export interface paths { + "/endpoint": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["simpleObject"] | components["schemas"]["complexObject"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + baseObject: { + type: components["schemas"]["type"]; + }; + simpleObject: components["schemas"]["baseObject"] & { + simple?: boolean; + } & { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "simple"; + }; + complexObject: components["schemas"]["baseObject"] & { + complex?: boolean; + } & { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "complex" | "tooComplex"; + }; + /** @enum {string} */ + type: "simple" | "complex" | "tooComplex"; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export type operations = Record;`, + }, + ], ]; for (const [testName, { given, want, options, ci }] of tests) { diff --git a/packages/openapi-typescript/test/test-helpers.ts b/packages/openapi-typescript/test/test-helpers.ts index a296076d7..22e3328aa 100644 --- a/packages/openapi-typescript/test/test-helpers.ts +++ b/packages/openapi-typescript/test/test-helpers.ts @@ -10,7 +10,10 @@ export const DEFAULT_CTX: GlobalContext = { alphabetize: false, arrayLength: false, defaultNonNullable: true, - discriminators: {}, + discriminators: { + objects: {}, + refsHandled: [], + }, emptyObjectsUnknown: false, enum: false, excludeDeprecated: false, 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 f11c73daf..a10ec2784 100644 --- a/packages/openapi-typescript/test/transform/schema-object/composition.test.ts +++ b/packages/openapi-typescript/test/transform/schema-object/composition.test.ts @@ -219,18 +219,21 @@ describe("composition", () => { ctx: { ...DEFAULT_OPTIONS.ctx, discriminators: { - [DEFAULT_OPTIONS.path]: { - propertyName: "operation", - mapping: { - test: DEFAULT_OPTIONS.path, + objects: { + [DEFAULT_OPTIONS.path]: { + propertyName: "operation", + mapping: { + test: DEFAULT_OPTIONS.path, + }, }, - }, - "#/components/schemas/parent": { - propertyName: "operation", - mapping: { - test: DEFAULT_OPTIONS.path, + "#/components/schemas/parent": { + propertyName: "operation", + mapping: { + test: DEFAULT_OPTIONS.path, + }, }, }, + refsHandled: [], }, resolve($ref) { switch ($ref) { @@ -274,15 +277,18 @@ describe("composition", () => { ctx: { ...DEFAULT_OPTIONS.ctx, discriminators: { - "#/components/schemas/Pet": { - propertyName: "petType", - }, - "#/components/schemas/Cat": { - propertyName: "petType", - }, - "#/components/schemas/Dog": { - propertyName: "petType", + objects: { + "#/components/schemas/Pet": { + propertyName: "petType", + }, + "#/components/schemas/Cat": { + propertyName: "petType", + }, + "#/components/schemas/Dog": { + propertyName: "petType", + }, }, + refsHandled: [], }, resolve($ref) { switch ($ref) { @@ -315,12 +321,15 @@ describe("composition", () => { ctx: { ...DEFAULT_OPTIONS.ctx, discriminators: { - [DEFAULT_OPTIONS.path]: { - propertyName: "operation", - }, - "#/components/schemas/parent": { - propertyName: "operation", + objects: { + [DEFAULT_OPTIONS.path]: { + propertyName: "operation", + }, + "#/components/schemas/parent": { + propertyName: "operation", + }, }, + refsHandled: [], }, resolve($ref) { switch ($ref) { @@ -356,18 +365,21 @@ describe("composition", () => { ctx: { ...DEFAULT_OPTIONS.ctx, discriminators: { - "#/components/schemas/schema-object": { - propertyName: "@type", - mapping: { - test: DEFAULT_OPTIONS.path, + objects: { + "#/components/schemas/schema-object": { + propertyName: "@type", + mapping: { + test: DEFAULT_OPTIONS.path, + }, }, - }, - "#/components/schemas/parent": { - propertyName: "@type", - mapping: { - test: DEFAULT_OPTIONS.path, + "#/components/schemas/parent": { + propertyName: "@type", + mapping: { + test: DEFAULT_OPTIONS.path, + }, }, }, + refsHandled: [], }, resolve($ref) { switch ($ref) { @@ -408,12 +420,15 @@ describe("composition", () => { ctx: { ...DEFAULT_OPTIONS.ctx, discriminators: { - "#/components/schemas/Pet": { - propertyName: "_petType", - }, - "#/components/schemas/Dog": { - propertyName: "_petType", + objects: { + "#/components/schemas/Pet": { + propertyName: "_petType", + }, + "#/components/schemas/Dog": { + propertyName: "_petType", + }, }, + refsHandled: [], }, resolve($ref) { switch ($ref) {