Skip to content
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

feat: add discriminatorOpenApi tag to generate discriminator schemas #1572

Merged
merged 2 commits into from Feb 22, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/AnnotationsReader/BasicAnnotationsReader.ts
Expand Up @@ -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<string>([
"minimum",
Expand Down
10 changes: 9 additions & 1 deletion src/Type/UnionType.ts
Expand Up @@ -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();
Expand All @@ -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("|")})`;
}
Expand Down
7 changes: 5 additions & 2 deletions src/TypeFormatter/AnnotatedTypeFormatter.ts
Expand Up @@ -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(
Expand Down
107 changes: 62 additions & 45 deletions src/TypeFormatter/UnionTypeFormatter.ts
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion 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
2 changes: 2 additions & 0 deletions test/valid-data-annotations.test.ts
Expand Up @@ -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"));
});
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;
};

/**
* @discriminatorOpenApi 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"
}
}
}