Skip to content

Commit

Permalink
feat: add discriminatorOpenApi tag to generate discriminator sche…
Browse files Browse the repository at this point in the history
…mas (#1572)

Fixes #1551
  • Loading branch information
mdesousa committed Feb 22, 2023
1 parent 68d7048 commit 8985251
Show file tree
Hide file tree
Showing 8 changed files with 160 additions and 55 deletions.
2 changes: 1 addition & 1 deletion factory/formatter.ts
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/Config.ts
Expand Up @@ -13,6 +13,7 @@ export interface Config {
encodeRefs?: boolean;
extraTags?: string[];
additionalProperties?: boolean;
discriminatorType?: "json-schema" | "open-api";
}

export const DEFAULT_CONFIG: Omit<Required<Config>, "path" | "type" | "schemaId" | "tsconfig"> = {
Expand All @@ -26,4 +27,5 @@ export const DEFAULT_CONFIG: Omit<Required<Config>, "path" | "type" | "schemaId"
minify: false,
extraTags: [],
additionalProperties: false,
discriminatorType: "json-schema",
};
111 changes: 66 additions & 45 deletions src/TypeFormatter/UnionTypeFormatter.ts
Expand Up @@ -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;
Expand Down
6 changes: 4 additions & 2 deletions test/utils.ts
Expand Up @@ -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";
Expand Down Expand Up @@ -49,14 +49,16 @@ export function assertValidSchema(
* @default {strict:false}
*/
ajvOptions?: AjvOptions;
}
},
discriminatorType?: Config["discriminatorType"]
) {
return (): void => {
const config: Config = {
path: `${basePath}/${relativePath}/*.ts`,
type,
jsDoc,
extraTags,
discriminatorType,
skipTypeCheck: !!process.env.FAST_TEST,
};

Expand Down
5 changes: 5 additions & 0 deletions test/valid-data-annotations.test.ts
Expand Up @@ -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")
);
});
14 changes: 14 additions & 0 deletions 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;
61 changes: 61 additions & 0 deletions 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"
}
}
}
@@ -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"
}
},
Expand All @@ -24,7 +24,7 @@
"exportedKey",
"exportedEnumKey"
],
"additionalProperties": false
"type": "object"
}
}
}

0 comments on commit 8985251

Please sign in to comment.