Skip to content

Commit ede4797

Browse files
authored
Discriminator Mapping Support for oneOf composition (#1574)
* Added discriminator mapping support for oneOf composition * Discriminator support proposal cleanup * Refactor discriminator implementation to handle all oneOf+mapping combinations * Refactor global context to a single property for all discriminator values * Update examples with new discriminator output * Support discriminator enum mappings using the same ref multiple times * Updated examples with improved discriminator support --------- Co-authored-by: mat <[email protected]>
1 parent ba1aca5 commit ede4797

File tree

8 files changed

+565
-66
lines changed

8 files changed

+565
-66
lines changed

packages/openapi-typescript/examples/digital-ocean-api.ts

+8-3
Original file line numberDiff line numberDiff line change
@@ -9565,11 +9565,10 @@ export interface components {
95659565
/** @description Specifies the action that will be taken on the Droplet. */
95669566
droplet_action: {
95679567
/**
9568-
* @description The type of action to initiate for the Droplet.
9569-
* @example reboot
9568+
* @description The type of action to initiate for the Droplet. (enum property replaced by openapi-typescript)
95709569
* @enum {string}
95719570
*/
9572-
type: "enable_backups" | "disable_backups" | "reboot" | "power_cycle" | "shutdown" | "power_off" | "power_on" | "restore" | "password_reset" | "resize" | "rebuild" | "rename" | "change_kernel" | "enable_ipv6" | "snapshot";
9571+
type: "enable_backups" | "disable_backups" | "power_cycle" | "shutdown" | "power_off" | "power_on" | "enable_ipv6";
95739572
};
95749573
droplet_action_restore: components["schemas"]["droplet_action"] & {
95759574
/**
@@ -9617,6 +9616,12 @@ export interface components {
96179616
* @example Nifty New Snapshot
96189617
*/
96199618
name?: string;
9619+
} & {
9620+
/**
9621+
* @description discriminator enum property added by openapi-typescript
9622+
* @enum {string}
9623+
*/
9624+
type: "snapshot";
96209625
};
96219626
firewall_rule_base: {
96229627
/**

packages/openapi-typescript/src/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export default async function openapiTS(
7171
additionalProperties: options.additionalProperties ?? false,
7272
alphabetize: options.alphabetize ?? false,
7373
defaultNonNullable: options.defaultNonNullable ?? true,
74-
discriminators: scanDiscriminators(schema),
74+
discriminators: scanDiscriminators(schema, options),
7575
emptyObjectsUnknown: options.emptyObjectsUnknown ?? false,
7676
enum: options.enum ?? false,
7777
excludeDeprecated: options.excludeDeprecated ?? false,

packages/openapi-typescript/src/lib/utils.ts

+158-12
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@ import {
55
import c from "ansi-colors";
66
import supportsColor from "supports-color";
77
import ts from "typescript";
8-
import type { DiscriminatorObject, OpenAPI3 } from "../types.js";
8+
import type {
9+
DiscriminatorObject,
10+
OpenAPI3,
11+
OpenAPITSOptions,
12+
ReferenceObject,
13+
SchemaObject,
14+
} from "../types.js";
915
import { tsLiteral, tsModifiers, tsPropertyIndex } from "./ts.js";
1016

1117
if (!supportsColor.stdout || supportsColor.stdout.hasBasic === false) {
@@ -165,15 +171,154 @@ export function resolveRef<T>(
165171
return node;
166172
}
167173

174+
function createDiscriminatorEnum(
175+
values: string[],
176+
prevSchema?: SchemaObject,
177+
): SchemaObject {
178+
return {
179+
type: "string",
180+
enum: values,
181+
description: prevSchema?.description
182+
? `${prevSchema.description} (enum property replaced by openapi-typescript)`
183+
: `discriminator enum property added by openapi-typescript`,
184+
};
185+
}
186+
187+
type InternalDiscriminatorMapping = Record<
188+
string,
189+
{ inferred?: string; defined?: string[] }
190+
>;
191+
168192
/** Return a key–value map of discriminator objects found in a schema */
169-
export function scanDiscriminators(schema: OpenAPI3) {
170-
const discriminators: Record<string, DiscriminatorObject> = {};
193+
export function scanDiscriminators(
194+
schema: OpenAPI3,
195+
options: OpenAPITSOptions,
196+
) {
197+
// all discriminator objects found in the schema
198+
const objects: Record<string, DiscriminatorObject> = {};
199+
200+
// refs of all mapped schema objects we have successfully handled to infer the discriminator enum value
201+
const refsHandled: string[] = [];
171202

172-
// perform 2 passes: first, collect all discriminator definitions
203+
// perform 2 passes: first, collect all discriminator definitions and handle oneOf and mappings
173204
walk(schema, (obj, path) => {
174-
if ((obj?.discriminator as DiscriminatorObject)?.propertyName) {
175-
discriminators[createRef(path)] =
176-
obj.discriminator as DiscriminatorObject;
205+
const discriminator = obj?.discriminator as DiscriminatorObject | undefined;
206+
if (!discriminator?.propertyName) {
207+
return;
208+
}
209+
210+
// collect discriminator object for later usage
211+
const ref = createRef(path);
212+
213+
objects[ref] = discriminator;
214+
215+
// 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
216+
// we only handle the mapping in advance for discriminator + oneOf compositions right now
217+
if (!obj?.oneOf || !Array.isArray(obj.oneOf)) {
218+
return;
219+
}
220+
221+
const oneOf: (SchemaObject | ReferenceObject)[] = obj.oneOf;
222+
const mapping: InternalDiscriminatorMapping = {};
223+
224+
// the mapping can be inferred from the oneOf refs next to the discriminator object
225+
for (const item of oneOf) {
226+
if ("$ref" in item) {
227+
// the name of the schema is the inferred discriminator enum value
228+
const value = item.$ref.split("/").pop();
229+
230+
if (value) {
231+
if (!mapping[item.$ref]) {
232+
mapping[item.$ref] = { inferred: value };
233+
} else {
234+
mapping[item.$ref].inferred = value;
235+
}
236+
}
237+
}
238+
}
239+
240+
// the mapping can be defined in the discriminator object itself
241+
if (discriminator.mapping) {
242+
for (const mappedValue in discriminator.mapping) {
243+
const mappedRef = discriminator.mapping[mappedValue];
244+
if (!mappedRef) {
245+
continue;
246+
}
247+
248+
if (!mapping[mappedRef]?.defined) {
249+
// this overrides inferred values, but we don't need them anymore as soon as we have a defined value
250+
mapping[mappedRef] = { defined: [] };
251+
}
252+
253+
mapping[mappedRef].defined?.push(mappedValue);
254+
}
255+
}
256+
257+
for (const [mappedRef, { inferred, defined }] of Object.entries(mapping)) {
258+
if (refsHandled.includes(mappedRef)) {
259+
continue;
260+
}
261+
262+
if (!inferred && !defined) {
263+
continue;
264+
}
265+
266+
// prefer defined values over automatically inferred ones
267+
// the inferred enum values from the schema might not represent the actual enum values of the discriminator,
268+
// so if we have defined values, use them instead
269+
const mappedValues = defined ?? [inferred!];
270+
const resolvedSchema = resolveRef<SchemaObject>(schema, mappedRef, {
271+
silent: options.silent ?? false,
272+
});
273+
274+
if (resolvedSchema?.allOf) {
275+
// if the schema is an allOf, we can append a new schema object to the allOf array
276+
resolvedSchema.allOf.push({
277+
type: "object",
278+
// discriminator enum properties always need to be required
279+
required: [discriminator.propertyName],
280+
properties: {
281+
[discriminator.propertyName]: createDiscriminatorEnum(mappedValues),
282+
},
283+
});
284+
285+
refsHandled.push(mappedRef);
286+
} else if (
287+
typeof resolvedSchema === "object" &&
288+
"type" in resolvedSchema &&
289+
resolvedSchema.type === "object"
290+
) {
291+
// if the schema is an object, we can apply the discriminator enums to its properties
292+
if (!resolvedSchema.properties) {
293+
resolvedSchema.properties = {};
294+
}
295+
296+
// discriminator enum properties always need to be required
297+
if (!resolvedSchema.required) {
298+
resolvedSchema.required = [discriminator.propertyName];
299+
} else if (
300+
!resolvedSchema.required.includes(discriminator.propertyName)
301+
) {
302+
resolvedSchema.required.push(discriminator.propertyName);
303+
}
304+
305+
// add/replace the discriminator enum property
306+
resolvedSchema.properties[discriminator.propertyName] =
307+
createDiscriminatorEnum(
308+
mappedValues,
309+
resolvedSchema.properties[discriminator.propertyName],
310+
);
311+
312+
refsHandled.push(mappedRef);
313+
} else {
314+
warn(
315+
`Discriminator mapping has an invalid schema (neither an object schema nor an allOf array): ${mappedRef} => ${mappedValues.join(
316+
", ",
317+
)} (Discriminator: ${ref})`,
318+
options.silent,
319+
);
320+
continue;
321+
}
177322
}
178323
});
179324

@@ -185,19 +330,20 @@ export function scanDiscriminators(schema: OpenAPI3) {
185330
if (obj && Array.isArray(obj[key])) {
186331
for (const item of (obj as any)[key]) {
187332
if ("$ref" in item) {
188-
if (discriminators[item.$ref]) {
189-
discriminators[createRef(path)] = {
190-
...discriminators[item.$ref],
333+
if (objects[item.$ref]) {
334+
objects[createRef(path)] = {
335+
...objects[item.$ref],
191336
};
192337
}
193338
} else if (item.discriminator?.propertyName) {
194-
discriminators[createRef(path)] = { ...item.discriminator };
339+
objects[createRef(path)] = { ...item.discriminator };
195340
}
196341
}
197342
}
198343
}
199344
});
200-
return discriminators;
345+
346+
return { objects, refsHandled };
201347
}
202348

203349
/** Walk through any JSON-serializable (i.e. non-circular) object */

packages/openapi-typescript/src/transform/schema-object.ts

+15-11
Original file line numberDiff line numberDiff line change
@@ -157,16 +157,19 @@ export function transformSchemaObjectWithComposition(
157157
typeof resolved === "object" &&
158158
"properties" in resolved
159159
) {
160-
// don’t try and make keys required if the $ref doesn’t have them
161-
const validRequired = (required ?? []).filter(
162-
(key) => !!resolved.properties![key],
163-
);
164-
if (validRequired.length) {
165-
itemType = tsWithRequired(
166-
itemType,
167-
validRequired,
168-
options.ctx.injectFooter,
160+
// don’t try and make keys required if we have already handled the item (discriminator property was already added as required)
161+
// or the $ref doesn’t have them
162+
if (!options.ctx.discriminators.refsHandled.includes(item.$ref)) {
163+
const validRequired = (required ?? []).filter(
164+
(key) => !!resolved.properties![key],
169165
);
166+
if (validRequired.length) {
167+
itemType = tsWithRequired(
168+
itemType,
169+
validRequired,
170+
options.ctx.injectFooter,
171+
);
172+
}
170173
}
171174
}
172175
}
@@ -182,7 +185,7 @@ export function transformSchemaObjectWithComposition(
182185
);
183186
}
184187
const discriminator =
185-
("$ref" in item && options.ctx.discriminators[item.$ref]) ||
188+
("$ref" in item && options.ctx.discriminators.objects[item.$ref]) ||
186189
(item as any).discriminator; // eslint-disable-line @typescript-eslint/no-explicit-any
187190
if (discriminator) {
188191
output.push(tsOmit(itemType, [discriminator.propertyName]));
@@ -414,7 +417,8 @@ function transformSchemaObjectCore(
414417
// ctx.discriminators. But stop objects from referencing their own
415418
// discriminator meant for children (!schemaObject.discriminator)
416419
const discriminator =
417-
!schemaObject.discriminator && options.ctx.discriminators[options.path!];
420+
!schemaObject.discriminator &&
421+
options.ctx.discriminators.objects[options.path!];
418422
if (discriminator) {
419423
coreObjectType.unshift(
420424
createDiscriminatorProperty(discriminator, {

packages/openapi-typescript/src/types.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -686,7 +686,10 @@ export interface GlobalContext {
686686
additionalProperties: boolean;
687687
alphabetize: boolean;
688688
defaultNonNullable: boolean;
689-
discriminators: Record<string, DiscriminatorObject>;
689+
discriminators: {
690+
objects: Record<string, DiscriminatorObject>;
691+
refsHandled: string[];
692+
};
690693
emptyObjectsUnknown: boolean;
691694
enum: boolean;
692695
excludeDeprecated: boolean;

0 commit comments

Comments
 (0)