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/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/TypeFormatter/UnionTypeFormatter.ts b/src/TypeFormatter/UnionTypeFormatter.ts index 5f8cd792d..ac932e0ff 100644 --- a/src/TypeFormatter/UnionTypeFormatter.ts +++ b/src/TypeFormatter/UnionTypeFormatter.ts @@ -10,70 +10,91 @@ 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; } - 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))); + 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 undefinedIndex = kindTypes.findIndex((item) => item === undefined); + 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 (undefinedIndex != -1) { + throw new Error( + `Cannot find discriminator keyword "${discriminator}" in type ${JSON.stringify( + type.getTypes()[undefinedIndex] + )}.` + ); + } - const kindDefinitions = kindTypes.map((item) => this.childTypeFormatter.getDefinition(item as BaseType)); + const kindDefinitions = kindTypes.map((item) => this.childTypeFormatter.getDefinition(item as BaseType)); - const allOf = []; + const allOf = []; - for (let i = 0; i < definitions.length; i++) { - allOf.push({ - if: { - properties: { [discriminator]: kindDefinitions[i] }, - }, - then: definitions[i], - }); - } + for (let i = 0; i < definitions.length; i++) { + allOf.push({ + if: { + properties: { [discriminator]: kindDefinitions[i] }, + }, + then: definitions[i], + }); + } - 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 kindValues = kindDefinitions + .map((item) => item.const) + .filter((item): item is string | number | boolean | null => item !== undefined); - const properties = { - [discriminator]: { - enum: kindValues, - }, - }; + 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 }; + 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 discriminator = type.getDiscriminator(); + if (discriminator !== undefined) { + if (this.discriminatorType === "open-api") return this.getOpenApiDiscriminatorDefinition(type); + return this.getJsonSchemaDiscriminatorDefinition(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..b54c3e4de 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"; @@ -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 ffa9e1ba1..f52e823bd 100644 --- a/test/valid-data-annotations.test.ts +++ b/test/valid-data-annotations.test.ts @@ -75,4 +75,9 @@ 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", undefined, undefined, undefined, "open-api") + ); }); diff --git a/test/valid-data/discriminator/main.ts b/test/valid-data/discriminator/main.ts new file mode 100644 index 000000000..e46709edd --- /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; +}; + +/** + * @discriminator 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" } } }