From b3f105408b732633dc79bcf09718fcf59c5a232d Mon Sep 17 00:00:00 2001 From: Mario DeSousa Date: Sat, 18 Feb 2023 12:54:54 -0500 Subject: [PATCH 1/2] feat: add `discriminatorOpenApi` tag to generate `discriminator` schemas (#1551) - https://swagger.io/docs/specification/data-models/inheritance-and-polymorphism/ - https://ajv.js.org/json-schema.html#discriminator --- .../BasicAnnotationsReader.ts | 5 + src/Type/UnionType.ts | 10 +- src/TypeFormatter/AnnotatedTypeFormatter.ts | 7 +- src/TypeFormatter/UnionTypeFormatter.ts | 107 ++++++++++-------- test/utils.ts | 2 +- test/valid-data-annotations.test.ts | 2 + test/valid-data/discriminator/main.ts | 14 +++ test/valid-data/discriminator/schema.json | 61 ++++++++++ .../schema.json | 14 +-- 9 files changed, 166 insertions(+), 56 deletions(-) create mode 100644 test/valid-data/discriminator/main.ts create mode 100644 test/valid-data/discriminator/schema.json diff --git a/src/AnnotationsReader/BasicAnnotationsReader.ts b/src/AnnotationsReader/BasicAnnotationsReader.ts index ce921d02a..7683446ed 100644 --- a/src/AnnotationsReader/BasicAnnotationsReader.ts +++ b/src/AnnotationsReader/BasicAnnotationsReader.ts @@ -22,6 +22,11 @@ export class BasicAnnotationsReader implements AnnotationsReader { // Custom tag for if-then-else support. "discriminator", + + // OpenAPI Discriminator + // https://swagger.io/docs/specification/data-models/inheritance-and-polymorphism/ + // https://ajv.js.org/json-schema.html#discriminator + "discriminatorOpenApi" ]); private static jsonTags = new Set([ "minimum", diff --git a/src/Type/UnionType.ts b/src/Type/UnionType.ts index 99426e4e0..01976e43f 100644 --- a/src/Type/UnionType.ts +++ b/src/Type/UnionType.ts @@ -3,9 +3,12 @@ import { uniqueTypeArray } from "../Utils/uniqueTypeArray"; import { NeverType } from "./NeverType"; import { derefType } from "../Utils/derefType"; +type DiscriminatorType = "json-schema" | "open-api"; + export class UnionType extends BaseType { private readonly types: BaseType[]; private discriminator?: string = undefined; + private discriminatorType?: DiscriminatorType = undefined; public constructor(types: readonly BaseType[]) { super(); @@ -21,14 +24,19 @@ export class UnionType extends BaseType { ); } - public setDiscriminator(discriminator: string) { + public setDiscriminator(discriminator: string, type: DiscriminatorType) { this.discriminator = discriminator; + this.discriminatorType = type; } public getDiscriminator() { return this.discriminator; } + public getDiscriminatorType() { + return this.discriminatorType; + } + public getId(): string { return `(${this.types.map((type) => type.getId()).join("|")})`; } diff --git a/src/TypeFormatter/AnnotatedTypeFormatter.ts b/src/TypeFormatter/AnnotatedTypeFormatter.ts index e197e051c..ce3fbe510 100644 --- a/src/TypeFormatter/AnnotatedTypeFormatter.ts +++ b/src/TypeFormatter/AnnotatedTypeFormatter.ts @@ -54,11 +54,14 @@ export class AnnotatedTypeFormatter implements SubTypeFormatter { public getDefinition(type: AnnotatedType): Definition { const annotations = type.getAnnotations(); - if ("discriminator" in annotations) { + if ("discriminator" in annotations || "discriminatorOpenApi" in annotations) { const derefed = derefType(type.getType()); if (derefed instanceof UnionType) { - derefed.setDiscriminator(annotations.discriminator); + const discriminator = annotations.discriminator ?? annotations.discriminatorOpenApi; + const discriminatorType = annotations.discriminator ? "json-schema" : "open-api"; + derefed.setDiscriminator(discriminator, discriminatorType); delete annotations.discriminator; + delete annotations.discriminatorOpenApi; } else { throw new Error( `Cannot assign discriminator tag to type: ${JSON.stringify( diff --git a/src/TypeFormatter/UnionTypeFormatter.ts b/src/TypeFormatter/UnionTypeFormatter.ts index 5f8cd792d..8134f87a7 100644 --- a/src/TypeFormatter/UnionTypeFormatter.ts +++ b/src/TypeFormatter/UnionTypeFormatter.ts @@ -16,64 +16,81 @@ export class UnionTypeFormatter implements SubTypeFormatter { public supportsType(type: UnionType): boolean { return type instanceof UnionType; } - public getDefinition(type: UnionType): Definition { - const definitions = type + private getTypeDefinitions(type: UnionType) { + return type .getTypes() .filter((item) => !(derefType(item) instanceof NeverType)) .map((item) => this.childTypeFormatter.getDefinition(item)); - + } + private getJsonSchemaDiscriminatorDefinition(type: UnionType): Definition { + const definitions = this.getTypeDefinitions(type); const discriminator = type.getDiscriminator(); - if (discriminator !== undefined) { - const kindTypes = type - .getTypes() - .filter((item) => !(derefType(item) instanceof NeverType)) - .map((item) => getTypeByKey(item, new LiteralType(discriminator))); - - const undefinedIndex = kindTypes.findIndex((item) => item === undefined); - - if (undefinedIndex != -1) { - throw new Error( - `Cannot find discriminator keyword "${discriminator}" in type ${JSON.stringify( - type.getTypes()[undefinedIndex] - )}.` - ); - } + if (!discriminator) throw new Error("discriminator is undefined"); + const kindTypes = type + .getTypes() + .filter((item) => !(derefType(item) instanceof NeverType)) + .map((item) => getTypeByKey(item, new LiteralType(discriminator))); - const kindDefinitions = kindTypes.map((item) => this.childTypeFormatter.getDefinition(item as BaseType)); + const undefinedIndex = kindTypes.findIndex((item) => item === undefined); - const allOf = []; + if (undefinedIndex != -1) { + throw new Error( + `Cannot find discriminator keyword "${discriminator}" in type ${JSON.stringify( + type.getTypes()[undefinedIndex] + )}.` + ); + } - for (let i = 0; i < definitions.length; i++) { - allOf.push({ - if: { - properties: { [discriminator]: kindDefinitions[i] }, - }, - then: definitions[i], - }); - } + const kindDefinitions = kindTypes.map((item) => this.childTypeFormatter.getDefinition(item as BaseType)); - const kindValues = kindDefinitions - .map((item) => item.const) - .filter((item): item is string | number | boolean | null => item !== undefined); - - const duplicates = kindValues.filter((item, index) => kindValues.indexOf(item) !== index); - if (duplicates.length > 0) { - throw new Error( - `Duplicate discriminator values: ${duplicates.join(", ")} in type ${JSON.stringify( - type.getName() - )}.` - ); - } + const allOf = []; - const properties = { - [discriminator]: { - enum: kindValues, + for (let i = 0; i < definitions.length; i++) { + allOf.push({ + if: { + properties: { [discriminator]: kindDefinitions[i] }, }, - }; + then: definitions[i], + }); + } - return { type: "object", properties, required: [discriminator], allOf }; + const kindValues = kindDefinitions + .map((item) => item.const) + .filter((item): item is string | number | boolean | null => item !== undefined); + + const duplicates = kindValues.filter((item, index) => kindValues.indexOf(item) !== index); + if (duplicates.length > 0) { + throw new Error( + `Duplicate discriminator values: ${duplicates.join(", ")} in type ${JSON.stringify(type.getName())}.` + ); } + const properties = { + [discriminator]: { + enum: kindValues, + }, + }; + + return { type: "object", properties, required: [discriminator], allOf }; + } + private getOpenApiDiscriminatorDefinition(type: UnionType): Definition { + const oneOf = this.getTypeDefinitions(type); + const discriminator = type.getDiscriminator(); + if (!discriminator) throw new Error("discriminator is undefined"); + return { + type: "object", + discriminator: { propertyName: discriminator }, + required: [discriminator], + oneOf, + } as JSONSchema7; + } + public getDefinition(type: UnionType): Definition { + const discriminatorType = type.getDiscriminatorType(); + if (discriminatorType === "json-schema") return this.getJsonSchemaDiscriminatorDefinition(type); + if (discriminatorType === "open-api") return this.getOpenApiDiscriminatorDefinition(type); + + const definitions = this.getTypeDefinitions(type); + // TODO: why is this not covered by LiteralUnionTypeFormatter? // special case for string literals | string -> string let stringType = true; diff --git a/test/utils.ts b/test/utils.ts index 04200688f..9b68f0841 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -12,7 +12,7 @@ import { UnknownTypeError } from "../src/Error/UnknownTypeError"; import { SchemaGenerator } from "../src/SchemaGenerator"; import { BaseType } from "../src/Type/BaseType"; -const validator = new Ajv(); +const validator = new Ajv({ discriminator: true }); addFormats(validator); const basePath = "test/valid-data"; diff --git a/test/valid-data-annotations.test.ts b/test/valid-data-annotations.test.ts index ffa9e1ba1..9d69e6600 100644 --- a/test/valid-data-annotations.test.ts +++ b/test/valid-data-annotations.test.ts @@ -75,4 +75,6 @@ describe("valid-data-annotations", () => { it("annotation-union-if-then", assertValidSchema("annotation-union-if-then", "Animal", "basic")); it("annotation-nullable-definition", assertValidSchema("annotation-nullable-definition", "MyObject", "extended")); + + it("discriminator", assertValidSchema("discriminator", "Animal", "basic")); }); diff --git a/test/valid-data/discriminator/main.ts b/test/valid-data/discriminator/main.ts new file mode 100644 index 000000000..df5f74b86 --- /dev/null +++ b/test/valid-data/discriminator/main.ts @@ -0,0 +1,14 @@ +export type Fish = { + animal_type: "fish"; + found_in: "ocean" | "river"; +}; + +export type Bird = { + animal_type: "bird"; + can_fly: boolean; +}; + +/** + * @discriminatorOpenApi animal_type + */ +export type Animal = Bird | Fish; diff --git a/test/valid-data/discriminator/schema.json b/test/valid-data/discriminator/schema.json new file mode 100644 index 000000000..723f58dbf --- /dev/null +++ b/test/valid-data/discriminator/schema.json @@ -0,0 +1,61 @@ +{ + "$ref": "#/definitions/Animal", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "Animal": { + "discriminator": { + "propertyName": "animal_type" + }, + "oneOf": [ + { + "$ref": "#/definitions/Bird" + }, + { + "$ref": "#/definitions/Fish" + } + ], + "required": [ + "animal_type" + ], + "type": "object" + }, + "Bird": { + "additionalProperties": false, + "properties": { + "animal_type": { + "const": "bird", + "type": "string" + }, + "can_fly": { + "type": "boolean" + } + }, + "required": [ + "animal_type", + "can_fly" + ], + "type": "object" + }, + "Fish": { + "additionalProperties": false, + "properties": { + "animal_type": { + "const": "fish", + "type": "string" + }, + "found_in": { + "enum": [ + "ocean", + "river" + ], + "type": "string" + } + }, + "required": [ + "animal_type", + "found_in" + ], + "type": "object" + } + } +} diff --git a/test/valid-data/literal-object-type-with-computed-props/schema.json b/test/valid-data/literal-object-type-with-computed-props/schema.json index b835841d4..f84123e10 100644 --- a/test/valid-data/literal-object-type-with-computed-props/schema.json +++ b/test/valid-data/literal-object-type-with-computed-props/schema.json @@ -1,20 +1,20 @@ { - "$schema": "http://json-schema.org/draft-07/schema#", "$ref": "#/definitions/MyType", + "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { "MyType": { - "type": "object", + "additionalProperties": false, "properties": { - "localKey": { + "exportedEnumKey": { "type": "string" }, - "localEnumKey": { + "exportedKey": { "type": "string" }, - "exportedKey": { + "localEnumKey": { "type": "string" }, - "exportedEnumKey": { + "localKey": { "type": "string" } }, @@ -24,7 +24,7 @@ "exportedKey", "exportedEnumKey" ], - "additionalProperties": false + "type": "object" } } } From d69a68cb684926f19edbeba75e2e3990259f68e5 Mon Sep 17 00:00:00 2001 From: Mario DeSousa Date: Tue, 21 Feb 2023 19:13:57 -0500 Subject: [PATCH 2/2] feat: add discriminatorType config to choose between if-then-else and open api discriminator --- factory/formatter.ts | 2 +- src/AnnotationsReader/BasicAnnotationsReader.ts | 5 ----- src/Config.ts | 2 ++ src/Type/UnionType.ts | 10 +--------- src/TypeFormatter/AnnotatedTypeFormatter.ts | 7 ++----- src/TypeFormatter/UnionTypeFormatter.ts | 12 ++++++++---- test/utils.ts | 4 +++- test/valid-data-annotations.test.ts | 5 ++++- test/valid-data/discriminator/main.ts | 2 +- 9 files changed, 22 insertions(+), 27 deletions(-) diff --git a/factory/formatter.ts b/factory/formatter.ts index b030885ee..1d095c0b2 100644 --- a/factory/formatter.ts +++ b/factory/formatter.ts @@ -75,7 +75,7 @@ export function createFormatter(config: Config, augmentor?: FormatterAugmentor): .addTypeFormatter(new ArrayTypeFormatter(circularReferenceTypeFormatter)) .addTypeFormatter(new TupleTypeFormatter(circularReferenceTypeFormatter)) - .addTypeFormatter(new UnionTypeFormatter(circularReferenceTypeFormatter)) + .addTypeFormatter(new UnionTypeFormatter(circularReferenceTypeFormatter, config.discriminatorType)) .addTypeFormatter(new IntersectionTypeFormatter(circularReferenceTypeFormatter)); return circularReferenceTypeFormatter; diff --git a/src/AnnotationsReader/BasicAnnotationsReader.ts b/src/AnnotationsReader/BasicAnnotationsReader.ts index 7683446ed..ce921d02a 100644 --- a/src/AnnotationsReader/BasicAnnotationsReader.ts +++ b/src/AnnotationsReader/BasicAnnotationsReader.ts @@ -22,11 +22,6 @@ export class BasicAnnotationsReader implements AnnotationsReader { // Custom tag for if-then-else support. "discriminator", - - // OpenAPI Discriminator - // https://swagger.io/docs/specification/data-models/inheritance-and-polymorphism/ - // https://ajv.js.org/json-schema.html#discriminator - "discriminatorOpenApi" ]); private static jsonTags = new Set([ "minimum", diff --git a/src/Config.ts b/src/Config.ts index a4c449822..29114d1f8 100644 --- a/src/Config.ts +++ b/src/Config.ts @@ -13,6 +13,7 @@ export interface Config { encodeRefs?: boolean; extraTags?: string[]; additionalProperties?: boolean; + discriminatorType?: "json-schema" | "open-api"; } export const DEFAULT_CONFIG: Omit, "path" | "type" | "schemaId" | "tsconfig"> = { @@ -26,4 +27,5 @@ export const DEFAULT_CONFIG: Omit, "path" | "type" | "schemaId" minify: false, extraTags: [], additionalProperties: false, + discriminatorType: "json-schema", }; diff --git a/src/Type/UnionType.ts b/src/Type/UnionType.ts index 01976e43f..99426e4e0 100644 --- a/src/Type/UnionType.ts +++ b/src/Type/UnionType.ts @@ -3,12 +3,9 @@ import { uniqueTypeArray } from "../Utils/uniqueTypeArray"; import { NeverType } from "./NeverType"; import { derefType } from "../Utils/derefType"; -type DiscriminatorType = "json-schema" | "open-api"; - export class UnionType extends BaseType { private readonly types: BaseType[]; private discriminator?: string = undefined; - private discriminatorType?: DiscriminatorType = undefined; public constructor(types: readonly BaseType[]) { super(); @@ -24,19 +21,14 @@ export class UnionType extends BaseType { ); } - public setDiscriminator(discriminator: string, type: DiscriminatorType) { + public setDiscriminator(discriminator: string) { this.discriminator = discriminator; - this.discriminatorType = type; } public getDiscriminator() { return this.discriminator; } - public getDiscriminatorType() { - return this.discriminatorType; - } - public getId(): string { return `(${this.types.map((type) => type.getId()).join("|")})`; } diff --git a/src/TypeFormatter/AnnotatedTypeFormatter.ts b/src/TypeFormatter/AnnotatedTypeFormatter.ts index ce3fbe510..e197e051c 100644 --- a/src/TypeFormatter/AnnotatedTypeFormatter.ts +++ b/src/TypeFormatter/AnnotatedTypeFormatter.ts @@ -54,14 +54,11 @@ export class AnnotatedTypeFormatter implements SubTypeFormatter { public getDefinition(type: AnnotatedType): Definition { const annotations = type.getAnnotations(); - if ("discriminator" in annotations || "discriminatorOpenApi" in annotations) { + if ("discriminator" in annotations) { const derefed = derefType(type.getType()); if (derefed instanceof UnionType) { - const discriminator = annotations.discriminator ?? annotations.discriminatorOpenApi; - const discriminatorType = annotations.discriminator ? "json-schema" : "open-api"; - derefed.setDiscriminator(discriminator, discriminatorType); + derefed.setDiscriminator(annotations.discriminator); delete annotations.discriminator; - delete annotations.discriminatorOpenApi; } else { throw new Error( `Cannot assign discriminator tag to type: ${JSON.stringify( diff --git a/src/TypeFormatter/UnionTypeFormatter.ts b/src/TypeFormatter/UnionTypeFormatter.ts index 8134f87a7..ac932e0ff 100644 --- a/src/TypeFormatter/UnionTypeFormatter.ts +++ b/src/TypeFormatter/UnionTypeFormatter.ts @@ -10,8 +10,10 @@ import { derefType } from "../Utils/derefType"; import { getTypeByKey } from "../Utils/typeKeys"; import { uniqueArray } from "../Utils/uniqueArray"; +type DiscriminatorType = "json-schema" | "open-api"; + export class UnionTypeFormatter implements SubTypeFormatter { - public constructor(protected childTypeFormatter: TypeFormatter) {} + public constructor(protected childTypeFormatter: TypeFormatter, private discriminatorType?: DiscriminatorType) {} public supportsType(type: UnionType): boolean { return type instanceof UnionType; @@ -85,9 +87,11 @@ export class UnionTypeFormatter implements SubTypeFormatter { } as JSONSchema7; } public getDefinition(type: UnionType): Definition { - const discriminatorType = type.getDiscriminatorType(); - if (discriminatorType === "json-schema") return this.getJsonSchemaDiscriminatorDefinition(type); - if (discriminatorType === "open-api") return this.getOpenApiDiscriminatorDefinition(type); + const discriminator = type.getDiscriminator(); + if (discriminator !== undefined) { + if (this.discriminatorType === "open-api") return this.getOpenApiDiscriminatorDefinition(type); + return this.getJsonSchemaDiscriminatorDefinition(type); + } const definitions = this.getTypeDefinitions(type); diff --git a/test/utils.ts b/test/utils.ts index 9b68f0841..b54c3e4de 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -49,7 +49,8 @@ export function assertValidSchema( * @default {strict:false} */ ajvOptions?: AjvOptions; - } + }, + discriminatorType?: Config["discriminatorType"] ) { return (): void => { const config: Config = { @@ -57,6 +58,7 @@ export function assertValidSchema( type, jsDoc, extraTags, + discriminatorType, skipTypeCheck: !!process.env.FAST_TEST, }; diff --git a/test/valid-data-annotations.test.ts b/test/valid-data-annotations.test.ts index 9d69e6600..f52e823bd 100644 --- a/test/valid-data-annotations.test.ts +++ b/test/valid-data-annotations.test.ts @@ -76,5 +76,8 @@ describe("valid-data-annotations", () => { it("annotation-nullable-definition", assertValidSchema("annotation-nullable-definition", "MyObject", "extended")); - it("discriminator", assertValidSchema("discriminator", "Animal", "basic")); + it( + "discriminator", + assertValidSchema("discriminator", "Animal", "basic", undefined, undefined, undefined, "open-api") + ); }); diff --git a/test/valid-data/discriminator/main.ts b/test/valid-data/discriminator/main.ts index df5f74b86..e46709edd 100644 --- a/test/valid-data/discriminator/main.ts +++ b/test/valid-data/discriminator/main.ts @@ -9,6 +9,6 @@ export type Bird = { }; /** - * @discriminatorOpenApi animal_type + * @discriminator animal_type */ export type Animal = Bird | Fish;