Skip to content

Commit b084472

Browse files
committed
Refactor discriminator implementation to handle all oneOf+mapping combinations
1 parent 49d2ad0 commit b084472

File tree

6 files changed

+140
-46
lines changed

6 files changed

+140
-46
lines changed

packages/openapi-typescript/src/index.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,17 @@ export default async function openapiTS(
6767
silent: options.silent ?? false,
6868
});
6969

70+
const { discriminators, discriminatorRefsHandled } = scanDiscriminators(
71+
schema,
72+
options,
73+
);
74+
7075
const ctx: GlobalContext = {
7176
additionalProperties: options.additionalProperties ?? false,
7277
alphabetize: options.alphabetize ?? false,
7378
defaultNonNullable: options.defaultNonNullable ?? true,
74-
discriminators: scanDiscriminators(schema, options),
79+
discriminators,
80+
discriminatorRefsHandled,
7581
emptyObjectsUnknown: options.emptyObjectsUnknown ?? false,
7682
enum: options.enum ?? false,
7783
excludeDeprecated: options.excludeDeprecated ?? false,

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

+99-31
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, OpenAPITSOptions, SchemaObject } 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) {
@@ -174,42 +180,103 @@ function createDiscriminatorEnum(value: string): SchemaObject {
174180
}
175181

176182
/** Return a key–value map of discriminator objects found in a schema */
177-
export function scanDiscriminators(schema: OpenAPI3, options: OpenAPITSOptions) {
183+
export function scanDiscriminators(
184+
schema: OpenAPI3,
185+
options: OpenAPITSOptions,
186+
) {
178187
const discriminators: Record<string, DiscriminatorObject> = {};
179-
const enumsAppended: string[] = [];
188+
// discriminator objects which we have successfully handled to infer the discriminator enum value
189+
const discriminatorRefsHandled: string[] = [];
180190

181-
// perform 2 passes: first, collect all discriminator definitions
191+
// perform 2 passes: first, collect all discriminator definitions and handle oneOf and mappings
182192
walk(schema, (obj, path) => {
183193
const discriminator = obj?.discriminator as DiscriminatorObject | undefined;
184-
if (discriminator?.propertyName) {
185-
const ref = createRef(path);
186-
187-
discriminators[ref] = discriminator;
188-
189-
// if a mapping is available we will help Typescript by adding the discriminator enum with its mapped value to each schema
190-
if (discriminator.mapping) {
191-
for (const [mappedValue, mappedRef] of Object.entries(
192-
discriminator.mapping
193-
)) {
194-
if (enumsAppended.includes(mappedRef)) {
195-
continue;
196-
}
194+
if (!discriminator?.propertyName) {
195+
return;
196+
}
197197

198-
const resolved = resolveRef<SchemaObject>(schema, mappedRef, { silent: options.silent ?? false });
199-
if (resolved?.allOf) {
200-
resolved.allOf.push({ type: "object", properties: { [discriminator.propertyName]: createDiscriminatorEnum(mappedValue) } });
201-
enumsAppended.push(mappedRef);
202-
} else if (typeof resolved === 'object' && 'type' in resolved && resolved.type === 'object') {
203-
if (!resolved.properties) {
204-
resolved.properties = {};
205-
}
198+
// add to discriminators object for later usage
199+
const ref = createRef(path);
206200

207-
resolved.properties[discriminator.propertyName] = createDiscriminatorEnum(mappedValue);
208-
enumsAppended.push(mappedRef);
209-
} else {
210-
continue;
211-
}
201+
discriminators[ref] = discriminator;
202+
203+
// 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
204+
// we only handle the mapping in advance for discriminator + oneOf compositions right now
205+
if (!obj?.oneOf || !Array.isArray(obj.oneOf)) {
206+
return;
207+
}
208+
209+
const oneOf: (SchemaObject | ReferenceObject)[] = obj.oneOf;
210+
const mapping: Record<string, string> = {};
211+
212+
// the mapping can be inferred from the oneOf refs next to the discriminator object
213+
for (const item of oneOf) {
214+
if ("$ref" in item) {
215+
// the name of the schema is the infered discriminator enum value
216+
const value = item.$ref.split("/").pop();
217+
218+
if (value) {
219+
mapping[item.$ref] = value;
220+
}
221+
}
222+
}
223+
224+
// the mapping can be defined in the discriminator object itself
225+
if (discriminator.mapping) {
226+
for (const mappedValue in discriminator.mapping) {
227+
const mappedRef = discriminator.mapping[mappedValue];
228+
229+
mapping[mappedRef] = mappedValue;
230+
}
231+
}
232+
233+
for (const [mappedRef, mappedValue] of Object.entries(mapping)) {
234+
if (discriminatorRefsHandled.includes(mappedRef)) {
235+
continue;
236+
}
237+
238+
const resolvedSchema = resolveRef<SchemaObject>(schema, mappedRef, {
239+
silent: options.silent ?? false,
240+
});
241+
if (resolvedSchema?.allOf) {
242+
// if the schema is an allOf, we can append a new schema object to the allOf array
243+
resolvedSchema.allOf.push({
244+
type: "object",
245+
required: [discriminator.propertyName],
246+
properties: {
247+
[discriminator.propertyName]: createDiscriminatorEnum(mappedValue),
248+
},
249+
});
250+
251+
discriminatorRefsHandled.push(mappedRef);
252+
} else if (
253+
typeof resolvedSchema === "object" &&
254+
"type" in resolvedSchema &&
255+
resolvedSchema.type === "object"
256+
) {
257+
// if the schema is an object, we can add/replace the discriminator enum to it
258+
if (!resolvedSchema.properties) {
259+
resolvedSchema.properties = {};
260+
}
261+
262+
if (!resolvedSchema.required) {
263+
resolvedSchema.required = [discriminator.propertyName];
264+
} else if (
265+
!resolvedSchema.required.includes(discriminator.propertyName)
266+
) {
267+
resolvedSchema.required.push(discriminator.propertyName);
212268
}
269+
270+
resolvedSchema.properties[discriminator.propertyName] =
271+
createDiscriminatorEnum(mappedValue);
272+
273+
discriminatorRefsHandled.push(mappedRef);
274+
} else {
275+
warn(
276+
`Discriminator mapping has an invalid schema (neither an object schema nor an allOf array): ${mappedRef} => ${mappedValue} (Discriminator: ${ref})`,
277+
options.silent,
278+
);
279+
continue;
213280
}
214281
}
215282
});
@@ -234,7 +301,8 @@ export function scanDiscriminators(schema: OpenAPI3, options: OpenAPITSOptions)
234301
}
235302
}
236303
});
237-
return discriminators;
304+
305+
return { discriminators, discriminatorRefsHandled };
238306
}
239307

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

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

+12-9
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.discriminatorRefsHandled.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
}

packages/openapi-typescript/src/types.ts

+1
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,7 @@ export interface GlobalContext {
687687
alphabetize: boolean;
688688
defaultNonNullable: boolean;
689689
discriminators: Record<string, DiscriminatorObject>;
690+
discriminatorRefsHandled: string[];
690691
emptyObjectsUnknown: boolean;
691692
enum: boolean;
692693
excludeDeprecated: boolean;

packages/openapi-typescript/test/discriminators.test.ts

+20-5
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ export type operations = Record<string, never>;`,
195195
},
196196
],
197197
[
198-
"oneOf",
198+
"oneOf > implicit mapping",
199199
{
200200
given: {
201201
openapi: "3.1",
@@ -246,12 +246,27 @@ export interface components {
246246
Pet: components["schemas"]["Cat"] | components["schemas"]["Dog"] | components["schemas"]["Lizard"];
247247
Cat: {
248248
name?: string;
249+
/**
250+
* @description discriminator enum property added by openapi-typescript
251+
* @enum {string}
252+
*/
253+
petType: "Cat";
249254
};
250255
Dog: {
251256
bark?: string;
257+
/**
258+
* @description discriminator enum property added by openapi-typescript
259+
* @enum {string}
260+
*/
261+
petType: "dog";
252262
};
253263
Lizard: {
254264
lovesRocks?: boolean;
265+
/**
266+
* @description discriminator enum property added by openapi-typescript
267+
* @enum {string}
268+
*/
269+
petType: "Lizard";
255270
};
256271
};
257272
responses: never;
@@ -266,7 +281,7 @@ export type operations = Record<string, never>;`,
266281
},
267282
],
268283
[
269-
"oneOf > mapping support > replace discriminator enum",
284+
"oneOf > explicit mapping > replace discriminator enum",
270285
{
271286
given: {
272287
openapi: "3.1.0",
@@ -409,7 +424,7 @@ export type operations = Record<string, never>;`,
409424
},
410425
],
411426
[
412-
"oneOf > mapping support > append enum in allOf",
427+
"oneOf > explicit mapping > append enum in allOf",
413428
{
414429
given: {
415430
openapi: "3.1.0",
@@ -547,7 +562,7 @@ export interface components {
547562
* @description discriminator enum property added by openapi-typescript
548563
* @enum {string}
549564
*/
550-
type?: "simple";
565+
type: "simple";
551566
};
552567
complexObject: components["schemas"]["baseObject"] & {
553568
complex?: boolean;
@@ -556,7 +571,7 @@ export interface components {
556571
* @description discriminator enum property added by openapi-typescript
557572
* @enum {string}
558573
*/
559-
type?: "complex";
574+
type: "complex";
560575
};
561576
/** @enum {string} */
562577
type: "simple" | "complex";

packages/openapi-typescript/test/test-helpers.ts

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const DEFAULT_CTX: GlobalContext = {
1111
arrayLength: false,
1212
defaultNonNullable: true,
1313
discriminators: {},
14+
discriminatorRefsHandled: [],
1415
emptyObjectsUnknown: false,
1516
enum: false,
1617
excludeDeprecated: false,

0 commit comments

Comments
 (0)