Skip to content

Commit

Permalink
feat: handle disjoint unions cleanly (#24)
Browse files Browse the repository at this point in the history
This gives a clear error message for disjoint unions, providing there is a single key that is a string, number of enum and has a unique, distinct value in all items within the union. If this is the case, instead of construcing an `anyOf` we use a series of `if`/`then`/`else` constructs to select the appropriate schema based on the value of that key, if the key doesn't match any schemas, we default to a schema that lists the valid values for that key.
  • Loading branch information
Forbes Lindesay authored Aug 15, 2019
1 parent cd3bec5 commit 1432be7
Show file tree
Hide file tree
Showing 10 changed files with 461 additions and 9 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"prebuild": "yarn clean",
"build": "tsc",
"build:watch": "yarn build -w",
"postbuild": "node lib/usage && node lib/cli src/Example.ts ExampleType && rimraf lib/__tests__",
"postbuild": "node lib/usage && node lib/cli src/Example.ts ExampleType && node lib/cli src/DisjointUnionExample.ts --collection && rimraf lib/__tests__",
"precommit": "pretty-quick --staged",
"prepush": "yarn prettier:diff && yarn test",
"prettier": "prettier --ignore-path .gitignore --write './**/*.{js,jsx,ts,tsx}'",
Expand Down
22 changes: 22 additions & 0 deletions src/DisjointUnionExample.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export enum EntityTypes {
TypeOne = 'TypeOne',
TypeTwo = 'TypeTwo',
TypeThree = 'TypeThree',
}
export interface EntityOne {
type: EntityTypes.TypeOne;
foo: string;
}
export interface EntityTwo {
type: EntityTypes.TypeTwo;
bar: string;
}
export type Entity =
| EntityOne
| EntityTwo
| {type: EntityTypes.TypeThree; baz: number};

export type Value =
| {number: 0; foo: string}
| {number: 1; bar: string}
| {number: 2; baz: string};
240 changes: 240 additions & 0 deletions src/DisjointUnionExample.validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
/* tslint:disable */
// generated by typescript-json-validator
import Ajv = require('ajv');
import {
EntityTypes,
EntityOne,
EntityTwo,
Entity,
Value,
} from './DisjointUnionExample';
export const ajv = new Ajv({
allErrors: true,
coerceTypes: false,
format: 'fast',
nullable: true,
unicode: true,
uniqueItems: true,
useDefaults: true,
});

ajv.addMetaSchema(require('ajv/lib/refs/json-schema-draft-06.json'));

export {EntityTypes, EntityOne, EntityTwo, Entity, Value};
export const Schema = {
$schema: 'http://json-schema.org/draft-07/schema#',
definitions: {
Entity: {
else: {
else: {
else: {
properties: {
type: {
enum: ['TypeOne', 'TypeTwo', 'TypeThree'],
type: 'string',
},
},
required: ['type'],
},
if: {
properties: {
type: {
enum: ['TypeThree'],
type: 'string',
},
},
required: ['type'],
},
then: {
defaultProperties: [],
properties: {
baz: {
type: 'number',
},
type: {
enum: ['TypeThree'],
type: 'string',
},
},
required: ['baz', 'type'],
type: 'object',
},
},
if: {
properties: {
type: {
enum: ['TypeTwo'],
type: 'string',
},
},
required: ['type'],
},
then: {
$ref: '#/definitions/EntityTwo',
},
},
if: {
properties: {
type: {
enum: ['TypeOne'],
type: 'string',
},
},
required: ['type'],
},
then: {
$ref: '#/definitions/EntityOne',
},
},
EntityOne: {
defaultProperties: [],
properties: {
foo: {
type: 'string',
},
type: {
enum: ['TypeOne'],
type: 'string',
},
},
required: ['foo', 'type'],
type: 'object',
},
EntityTwo: {
defaultProperties: [],
properties: {
bar: {
type: 'string',
},
type: {
enum: ['TypeTwo'],
type: 'string',
},
},
required: ['bar', 'type'],
type: 'object',
},
EntityTypes: {
enum: ['TypeOne', 'TypeThree', 'TypeTwo'],
type: 'string',
},
Value: {
else: {
else: {
else: {
properties: {
number: {
enum: [0, 1, 2],
type: 'number',
},
},
required: ['number'],
},
if: {
properties: {
number: {
enum: [2],
type: 'number',
},
},
required: ['number'],
},
then: {
defaultProperties: [],
properties: {
baz: {
type: 'string',
},
number: {
enum: [2],
type: 'number',
},
},
required: ['baz', 'number'],
type: 'object',
},
},
if: {
properties: {
number: {
enum: [1],
type: 'number',
},
},
required: ['number'],
},
then: {
defaultProperties: [],
properties: {
bar: {
type: 'string',
},
number: {
enum: [1],
type: 'number',
},
},
required: ['bar', 'number'],
type: 'object',
},
},
if: {
properties: {
number: {
enum: [0],
type: 'number',
},
},
required: ['number'],
},
then: {
defaultProperties: [],
properties: {
foo: {
type: 'string',
},
number: {
enum: [0],
type: 'number',
},
},
required: ['foo', 'number'],
type: 'object',
},
},
},
};
ajv.addSchema(Schema, 'Schema');
export function validate(
typeName: 'EntityTypes',
): (value: unknown) => EntityTypes;
export function validate(typeName: 'EntityOne'): (value: unknown) => EntityOne;
export function validate(typeName: 'EntityTwo'): (value: unknown) => EntityTwo;
export function validate(typeName: 'Entity'): (value: unknown) => Entity;
export function validate(typeName: 'Value'): (value: unknown) => Value;
export function validate(typeName: string): (value: unknown) => any {
const validator: any = ajv.getSchema(`Schema#/definitions/${typeName}`);
return (value: unknown): any => {
if (!validator) {
throw new Error(
`No validator defined for Schema#/definitions/${typeName}`,
);
}

const valid = validator(value);

if (!valid) {
throw new Error(
'Invalid ' +
typeName +
': ' +
ajv.errorsText(
validator.errors!.filter((e: any) => e.keyword !== 'if'),
{dataVar: typeName},
),
);
}

return value as any;
};
}
5 changes: 4 additions & 1 deletion src/Example.validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ export default function validate(value: unknown): ExampleType {
return value;
} else {
throw new Error(
ajv.errorsText(rawValidateExampleType.errors, {dataVar: 'ExampleType'}) +
ajv.errorsText(
rawValidateExampleType.errors!.filter((e: any) => e.keyword !== 'if'),
{dataVar: 'ExampleType'},
) +
'\n\n' +
inspect(value),
);
Expand Down
7 changes: 7 additions & 0 deletions src/__tests__/build-parameters.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
jest.setTimeout(30000);
import rimrafCB from 'rimraf';
import {exec as execCB, ExecOptions} from 'child_process';
import * as path from 'path';
Expand All @@ -24,6 +25,12 @@ const buildProject = async (project: string) => {
await exec(`node ../../../lib/cli ./src/Example.ts ExampleType`, {
cwd: testDir,
});
await exec(
`node ../../../lib/cli ./src/DisjointUnionExample.ts --collection`,
{
cwd: testDir,
},
);

await exec(`npx tsc --project ./tsconfig.json`, {
cwd: testDir,
Expand Down
49 changes: 49 additions & 0 deletions src/__tests__/disjointUnion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {validate} from '../DisjointUnionExample.validator';

// let validate: any;

test('Enum Keys', () => {
expect(() =>
validate('Entity')({type: 'TypeOne'}),
).toThrowErrorMatchingInlineSnapshot(
`"Invalid Entity: Entity should have required property 'foo'"`,
);
expect(() =>
validate('Entity')({type: 'TypeTwo'}),
).toThrowErrorMatchingInlineSnapshot(
`"Invalid Entity: Entity should have required property 'bar'"`,
);
expect(() =>
validate('Entity')({type: 'TypeThree'}),
).toThrowErrorMatchingInlineSnapshot(
`"Invalid Entity: Entity should have required property 'baz'"`,
);
expect(() =>
validate('Entity')({type: 'TypeFour'}),
).toThrowErrorMatchingInlineSnapshot(
`"Invalid Entity: Entity.type should be equal to one of the allowed values"`,
);
});

test('Number Keys', () => {
expect(() =>
validate('Value')({number: 0}),
).toThrowErrorMatchingInlineSnapshot(
`"Invalid Value: Value should have required property 'foo'"`,
);
expect(() =>
validate('Value')({number: 1}),
).toThrowErrorMatchingInlineSnapshot(
`"Invalid Value: Value should have required property 'bar'"`,
);
expect(() =>
validate('Value')({number: 2}),
).toThrowErrorMatchingInlineSnapshot(
`"Invalid Value: Value should have required property 'baz'"`,
);
expect(() =>
validate('Value')({type: 'TypeFour'}),
).toThrowErrorMatchingInlineSnapshot(
`"Invalid Value: Value should have required property 'number'"`,
);
});
10 changes: 8 additions & 2 deletions src/__tests__/output/ComplexExample.validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,10 @@ export function validateKoaRequest(
ctx.throw(
400,
'Invalid request: ' +
ajv.errorsText(validator.errors, {dataVar: prop}) +
ajv.errorsText(
validator.errors!.filter((e: any) => e.keyword !== 'if'),
{dataVar: prop},
) +
'\n\n' +
inspect({
params: ctx.params,
Expand Down Expand Up @@ -179,7 +182,10 @@ export function validate(typeName: string): (value: unknown) => any {
'Invalid ' +
typeName +
': ' +
ajv.errorsText(validator.errors, {dataVar: typeName}),
ajv.errorsText(
validator.errors!.filter((e: any) => e.keyword !== 'if'),
{dataVar: typeName},
),
);
}

Expand Down
Loading

0 comments on commit 1432be7

Please sign in to comment.