Skip to content

Commit

Permalink
feat: first pass of adding json schema generation
Browse files Browse the repository at this point in the history
  • Loading branch information
moodysalem committed Sep 2, 2022
1 parent 1852e7a commit aefe97c
Show file tree
Hide file tree
Showing 19 changed files with 279 additions and 41 deletions.
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"devDependencies": {
"@types/chai": "^4.3.3",
"@types/mocha": "^9.1.1",
"ajv": "^8.11.0",
"chai": "^4.3.6",
"codecov": "^3.8.3",
"conditional-type-checks": "^1.0.5",
Expand All @@ -74,5 +75,8 @@
"ts-node": "^10.9.1",
"typedoc": "^0.23.10",
"typescript": "^4.7.3"
},
"dependencies": {
"@types/json-schema": "^7.0.11"
}
}
7 changes: 7 additions & 0 deletions src/interfaces.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import { expect } from "chai";
import { describe, it } from "mocha";
import { ValidationError, Validator, FailedValidationError } from "./index";
import { JSONSchema7 } from "json-schema";

class CustomValidator extends Validator<string> {
validate(value: any, path: Array<string> = []): ValidationError[] {
return typeof value === "string"
? []
: [{ message: "not a string", path, value }];
}

_toJsonSchema(): JSONSchema7 {
return {
type: "string",
};
}
}

describe("Validator", () => {
Expand Down
35 changes: 35 additions & 0 deletions src/interfaces.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { JSONSchema4, JSONSchema7 } from "json-schema";

export type ValidationErrorPath = Readonly<Array<string | number>>;

/**
Expand Down Expand Up @@ -31,6 +33,22 @@ export class FailedValidationError extends Error {
}
}

function removeUndefinedProperties(x: JSONSchema7): JSONSchema7 {
if (Array.isArray(x)) {
x.forEach(removeUndefinedProperties);
return x;
} else if (typeof x === "object") {
for (const k in x) {
if ((x as any)[k] === undefined) {
delete (x as any)[k];
}
}
return x;
} else {
return x;
}
}

/**
* The base class of all the validators in this package. TValid is the type of any value that passes validation.
*/
Expand All @@ -45,6 +63,23 @@ export abstract class Validator<TValid> {
path?: ValidationErrorPath
): ValidationError[];

private cachedJsonSchema: JSONSchema7 | undefined;

/**
* Handles caching and removing undefined properties
*/
public toJsonSchema(): JSONSchema7 {
return (
this.cachedJsonSchema ??
(this.cachedJsonSchema = removeUndefinedProperties(this._toJsonSchema()))
);
}

/**
* Return the JSON schema for the given validator
*/
protected abstract _toJsonSchema(): JSONSchema7;

/**
* Return true if the value is valid. #isValid is unique in that validation errors are not surfaced, so the validator
* may optimize for performance.
Expand Down
21 changes: 17 additions & 4 deletions src/util/check-validates.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import { ValidationError, ValidationErrorPath, Validator } from "../interfaces";
import { expect } from "chai";

import Ajv from "ajv";
const ajv = new Ajv({});

/**
* Tests all the exposed functions of a validator, isValid, validate, and checkValid have consistent return values
* @param validator the validator to check
* @param value the value to validate
* @param expectedError if invalid, these are the errors it should throw
* @param path the path of the value being passed in
* @param looserJsonSchemaValidation if true, and validation fails of the validator, it is not also expected to fail for the json schema validator
*/
export default function checkValidates(
validator: Validator<unknown>,
value: unknown,
expectedError?: ValidationError[],
path?: ValidationErrorPath
path?: ValidationErrorPath,
looserJsonSchemaValidation: boolean = false
) {
expect(validator.validate(value, path)).to.deep.eq(
expectedError || [],
Expand All @@ -23,9 +28,17 @@ export default function checkValidates(
return;
}

expect(validator.isValid(value)).to.eq(
typeof expectedError === "undefined" || expectedError.length === 0
);
const isExpectedValid =
typeof expectedError === "undefined" || expectedError.length === 0;
expect(validator.isValid(value)).to.eq(isExpectedValid);

const schemaValidator = ajv.compile(validator.toJsonSchema());
schemaValidator(value);
if (looserJsonSchemaValidation) {
if (isExpectedValid) expect(!schemaValidator.errors?.length).to.eq(true);
} else {
expect(!schemaValidator.errors?.length).to.eq(isExpectedValid);
}

if (typeof expectedError === "undefined" || expectedError.length === 0) {
expect(() => validator.checkValid(value)).to.not.throw();
Expand Down
5 changes: 5 additions & 0 deletions src/validators/any-validator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ValidationError, Validator } from "../interfaces";
import { JSONSchema7 } from "json-schema";

export default class AnyValidator extends Validator<unknown> {
validate(
Expand All @@ -15,6 +16,10 @@ export default class AnyValidator extends Validator<unknown> {
checkValid(value: unknown): unknown {
return true;
}

_toJsonSchema(): JSONSchema7 {
return {};
}
}

export const ANY_VALIDATOR = new AnyValidator();
6 changes: 5 additions & 1 deletion src/validators/array-validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ describe("jointz#array", () => {
);
});

it("validates arrays properly", () => {
it("validates alphanumeric string array", () => {
checkValidates(
jointz
.array()
Expand All @@ -54,7 +54,9 @@ describe("jointz#array", () => {
.items(jointz.string().alphanum().minLength(3)),
["abc", "123"]
);
});

it("throws error for item in middle of array", () => {
checkValidates(
jointz.array().items(jointz.string().alphanum().minLength(3)),
["ab", "123"],
Expand All @@ -66,7 +68,9 @@ describe("jointz#array", () => {
},
]
);
});

it("can throw multiple errors from a single array", () => {
checkValidates(
jointz.array().items(jointz.string().alphanum().minLength(3)),
["a19-", "de"],
Expand Down
11 changes: 10 additions & 1 deletion src/validators/array-validator.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { JSONSchema7 } from "json-schema";
import { ValidationError, ValidationErrorPath, Validator } from "../interfaces";

interface ArrayValidatorOptions<TItem> {
Expand All @@ -11,7 +12,6 @@ interface ArrayValidatorOptions<TItem> {
*/
export default class ArrayValidator<TItem> extends Validator<TItem[]> {
private readonly options: ArrayValidatorOptions<TItem>;

public constructor(options: ArrayValidatorOptions<TItem>) {
super();
this.options = options;
Expand Down Expand Up @@ -105,4 +105,13 @@ export default class ArrayValidator<TItem> extends Validator<TItem[]> {
}
return false;
}

public _toJsonSchema(): JSONSchema7 {
return {
type: "array",
minItems: this.options.minLength,
maxItems: this.options.maxLength,
items: this.options.items?.toJsonSchema(),
};
}
}
7 changes: 7 additions & 0 deletions src/validators/boolean-validator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ValidationError, ValidationErrorPath, Validator } from "../interfaces";
import { JSONSchema7 } from "json-schema";

export default class BooleanValidator extends Validator<boolean> {
validate(value: any, path: ValidationErrorPath = []): ValidationError[] {
Expand All @@ -11,6 +12,12 @@ export default class BooleanValidator extends Validator<boolean> {
isValid(value: any): value is boolean {
return value === true || value === false;
}

_toJsonSchema(): JSONSchema7 {
return {
type: "boolean",
};
}
}

export const BOOLEAN_VALIDATOR = new BooleanValidator();
6 changes: 1 addition & 5 deletions src/validators/constant-validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,11 @@ describe("jointz#constant", () => {
expect(() => jointz.constant([] as any)).to.throw();
});

it("allows null and undefined", () => {
it("allows null", () => {
checkValidates(jointz.constant(null), null, []);
checkValidates(jointz.constant(undefined), undefined, []);
checkValidates(jointz.constant(null), undefined, [
{ message: "must be one of null", path: [], value: undefined },
]);
checkValidates(jointz.constant(undefined), null, [
{ message: "must be one of undefined", path: [], value: null },
]);
});

it("throws with empty list", () => {
Expand Down
13 changes: 11 additions & 2 deletions src/validators/constant-validator.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { ValidationError, ValidationErrorPath, Validator } from "../interfaces";
import { JSONSchema7 } from "json-schema";

export type AllowedValueTypes = string | number | boolean | null | undefined;
export type AllowedValueTypes = string | number | boolean | null;

const SUPPORTED_VALUE_TYPEOF: readonly string[] = [
"string",
"number",
"boolean",
"undefined",
];

/**
Expand Down Expand Up @@ -71,4 +71,13 @@ export default class ConstantValidator<
isValid(value: any): value is TValues[number] {
return this.options.allowedValues.indexOf(value) !== -1;
}

_toJsonSchema(): JSONSchema7 {
// todo: should we support enum here?
return {
anyOf: this.options.allowedValues.map((item) =>
item === null ? { type: "null" } : { const: item }
),
};
}
}
60 changes: 42 additions & 18 deletions src/validators/json-validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,27 +25,51 @@ describe("jointz#json", () => {
});

it("throws for invalid json", () => {
checkValidates(jointz.json(jointz.any()), '"ab', [
{ message: "invalid json", path: [], value: '"ab' },
]);
checkValidates(jointz.json(jointz.any()), "{", [
{ message: "invalid json", path: [], value: "{" },
]);
checkValidates(jointz.json(jointz.any()), "tru", [
{ message: "invalid json", path: [], value: "tru" },
]);
checkValidates(jointz.json(jointz.any()), "fals", [
{ message: "invalid json", path: [], value: "fals" },
]);
checkValidates(
jointz.json(jointz.any()),
'"ab',
[{ message: "invalid json", path: [], value: '"ab' }],
undefined,
true
);
checkValidates(
jointz.json(jointz.any()),
"{",
[{ message: "invalid json", path: [], value: "{" }],
undefined,
true
);
checkValidates(
jointz.json(jointz.any()),
"tru",
[{ message: "invalid json", path: [], value: "tru" }],
undefined,
true
);
checkValidates(
jointz.json(jointz.any()),
"fals",
[{ message: "invalid json", path: [], value: "fals" }],
undefined,
true
);
});

it("checks for a particular type in the json", () => {
checkValidates(jointz.json(jointz.boolean()), '""', [
{ path: [], message: "must be a boolean", value: "" },
]);
checkValidates(jointz.json(jointz.boolean()), "{}", [
{ path: [], message: "must be a boolean", value: {} },
]);
checkValidates(
jointz.json(jointz.boolean()),
'""',
[{ path: [], message: "must be a boolean", value: "" }],
undefined,
true
);
checkValidates(
jointz.json(jointz.boolean()),
"{}",
[{ path: [], message: "must be a boolean", value: {} }],
undefined,
true
);
checkValidates(jointz.json(jointz.boolean()), "true");
checkValidates(jointz.json(jointz.boolean()), "false");
});
Expand Down
10 changes: 10 additions & 0 deletions src/validators/json-validator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ValidationError, ValidationErrorPath, Validator } from "../interfaces";
import { JSONSchema7 } from "json-schema";

/**
* Validates that a string contains JSON that when parsed matches the given validator
Expand Down Expand Up @@ -37,4 +38,13 @@ export default class JsonValidator<TParsed> extends Validator<string> {
return false;
}
}

_toJsonSchema(): JSONSchema7 {
return {
type: "string",
contentMediaType: "application/json",
// next version of json schema
// contentSchema: this.parsedValidator.toJsonSchema(),
};
}
}
11 changes: 10 additions & 1 deletion src/validators/number-validator.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { JSONSchema7 } from "json-schema";
import { ValidationError, ValidationErrorPath, Validator } from "../interfaces";

interface NumberValidatorOptions {
Expand All @@ -8,7 +9,6 @@ interface NumberValidatorOptions {

export default class NumberValidator extends Validator<number> {
private readonly options: NumberValidatorOptions;

public constructor(options: NumberValidatorOptions) {
super();
this.options = options;
Expand Down Expand Up @@ -96,4 +96,13 @@ export default class NumberValidator extends Validator<number> {
(multipleOf === undefined || value % multipleOf === 0)
);
}

public _toJsonSchema(): JSONSchema7 {
return {
type: "number",
multipleOf: this.options.multipleOf,
minimum: this.options.min,
maximum: this.options.max,
};
}
}
Loading

0 comments on commit aefe97c

Please sign in to comment.