Skip to content

Discriminator Mapping Support for oneOf composition #1574

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Mar 4, 2024
8 changes: 7 additions & 1 deletion packages/openapi-typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,17 @@ export default async function openapiTS(
silent: options.silent ?? false,
});

const { discriminators, discriminatorRefsHandled } = scanDiscriminators(
schema,
options,
);

const ctx: GlobalContext = {
additionalProperties: options.additionalProperties ?? false,
alphabetize: options.alphabetize ?? false,
defaultNonNullable: options.defaultNonNullable ?? true,
discriminators: scanDiscriminators(schema),
discriminators,
discriminatorRefsHandled,
emptyObjectsUnknown: options.emptyObjectsUnknown ?? false,
enum: options.enum ?? false,
excludeDeprecated: options.excludeDeprecated ?? false,
Expand Down
119 changes: 112 additions & 7 deletions packages/openapi-typescript/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -165,15 +171,113 @@ export function resolveRef<T>(
return node;
}

function createDiscriminatorEnum(value: string): SchemaObject {
return {
type: "string",
enum: [value],
description: `discriminator enum property added by openapi-typescript`,
};
}

/** Return a key–value map of discriminator objects found in a schema */
export function scanDiscriminators(schema: OpenAPI3) {
export function scanDiscriminators(
schema: OpenAPI3,
options: OpenAPITSOptions,
) {
const discriminators: Record<string, DiscriminatorObject> = {};
// discriminator objects which we have successfully handled to infer the discriminator enum value
const discriminatorRefsHandled: 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;
}

// add to discriminators object for later usage
const ref = createRef(path);

discriminators[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: Record<string, string> = {};

// 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 infered discriminator enum value
const value = item.$ref.split("/").pop();

if (value) {
mapping[item.$ref] = 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];

mapping[mappedRef] = mappedValue;
}
}

for (const [mappedRef, mappedValue] of Object.entries(mapping)) {
if (discriminatorRefsHandled.includes(mappedRef)) {
continue;
}

const resolvedSchema = resolveRef<SchemaObject>(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",
required: [discriminator.propertyName],
properties: {
[discriminator.propertyName]: createDiscriminatorEnum(mappedValue),
},
});

discriminatorRefsHandled.push(mappedRef);
} else if (
typeof resolvedSchema === "object" &&
"type" in resolvedSchema &&
resolvedSchema.type === "object"
) {
// if the schema is an object, we can add/replace the discriminator enum to it
if (!resolvedSchema.properties) {
resolvedSchema.properties = {};
}

if (!resolvedSchema.required) {
resolvedSchema.required = [discriminator.propertyName];
} else if (
!resolvedSchema.required.includes(discriminator.propertyName)
) {
resolvedSchema.required.push(discriminator.propertyName);
}

resolvedSchema.properties[discriminator.propertyName] =
createDiscriminatorEnum(mappedValue);

discriminatorRefsHandled.push(mappedRef);
} else {
warn(
`Discriminator mapping has an invalid schema (neither an object schema nor an allOf array): ${mappedRef} => ${mappedValue} (Discriminator: ${ref})`,
options.silent,
);
continue;
}
}
});

Expand All @@ -197,7 +301,8 @@ export function scanDiscriminators(schema: OpenAPI3) {
}
}
});
return discriminators;

return { discriminators, discriminatorRefsHandled };
}

/** Walk through any JSON-serializable (i.e. non-circular) object */
Expand Down
21 changes: 12 additions & 9 deletions packages/openapi-typescript/src/transform/schema-object.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.discriminatorRefsHandled.includes(item.$ref)) {
const validRequired = (required ?? []).filter(
(key) => !!resolved.properties![key],
);
if (validRequired.length) {
itemType = tsWithRequired(
itemType,
validRequired,
options.ctx.injectFooter,
);
}
}
}
}
Expand Down
1 change: 1 addition & 0 deletions packages/openapi-typescript/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,7 @@ export interface GlobalContext {
alphabetize: boolean;
defaultNonNullable: boolean;
discriminators: Record<string, DiscriminatorObject>;
discriminatorRefsHandled: string[];
emptyObjectsUnknown: boolean;
enum: boolean;
excludeDeprecated: boolean;
Expand Down
Loading