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 all commits
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
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"]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should use an object for all these arguments now. This would remove the need for having so many undefined. Can you do that in a follow up pull request?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hi @domoritz yes, i was thinking the same thing... sure i'll submit one. thanks for merging

) {
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"
}
}
}