Created
April 2, 2020 13:47
-
-
Save sawyerh/fdb367466012b5d2345067735b3d10e1 to your computer and use it in GitHub Desktop.
Ajv wrapper for validating JSON Schemas
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const Ajv = require("ajv"); | |
const ajvKeywords = require("ajv-keywords"); | |
// This is an export of all of your JSON Schema files | |
const schemas = require("../schemas"); | |
// EXAMPLE: | |
// This could live in a ApplicationError.js file and be shared with other files | |
const ApplicationError = class ApplicationError { | |
/** | |
* @param {object} attributes | |
* @param {object} [attributes._ajvError] - If this error resulted from a JSON Schema validation error, this is | |
* the original error object that Ajv returned, and which this error was generated from | |
* @param {string} [attributes.dataPath] - Optional. If this error is associated with an input field, this should be | |
* the field's property name, in object path notation (i.e `foo.bar`, or `firstName`) | |
* @param {string} [attributes.messagePath] - Optional. Object path of the localized error message, which can be used for | |
* localizing the error message. Fallback error message is used if this isn't set, or isn't found. | |
* @param {string} [attributes.rule] - Optional. Rule name, in object path notation (i.e `format.date`) | |
*/ | |
constructor(attributes) { | |
this._ajvError = attributes._ajvError; | |
this.dataPath = attributes.dataPath; | |
this.messagePath = attributes.messagePath; | |
this.rule = attributes.rule; | |
} | |
} | |
// EXAMPLE: | |
// This could live in a collectionEnums.js file and be shared with other files | |
const collectionEnums = { | |
expenses: "expenses", | |
income: "income", | |
people: "people", | |
relationships: "relationships", | |
resources: "resources" | |
} | |
// Create capture group that matches our collectionEnums | |
const collectionNamesRegex = `(${Object.values(collectionEnums).join("|")})`; // => (foo|bar) | |
/** | |
* Error returned by the Ajv library | |
* @typedef AjvError | |
* @property {string} dataPath - not set for "required" validation rule errors | |
* @property {string} keyword - type of validation rule | |
* @property {object} params | |
* @property {string} [params.format] - only present for "format" validation rule errors | |
* @property {string} [params.missingProperty] - only present for "required" validation rule errors | |
* @see https://github.com/epoberezkin/ajv#validation-errors | |
*/ | |
/** | |
* JSON Schema validation service. Validates data and returns | |
* errors in a format that the UI can use for rendering | |
* human-friendly error messages. | |
*/ | |
class Validator { | |
/** | |
* @param {object[]} [refSchemas] - Array of additional JSON schemas to be used | |
* if the target schema includes them as a $ref. By default, all schemas exported | |
* from `schemas/index.js` are included, and you should NOT manually pass those | |
* into the constructor, since this will most definitely break things. | |
*/ | |
constructor(refSchemas = []) { | |
this.ajv = new Ajv({ | |
// return all validation errors, not just the first one: | |
allErrors: true, | |
// Include all schemas so each schema can include a $ref to another schema | |
schemas: schemas.concat(refSchemas) | |
}); | |
ajvKeywords(this.ajv, "formatMaximum"); | |
} | |
/** | |
* A descriptive name of the specific validation rule that failed | |
* @private | |
* @param {AjvError} error | |
* @returns {string} Object path in dot notation | |
*/ | |
_errorRule(error) { | |
switch (error.keyword) { | |
case "format": | |
// What was the specific format that's expected? | |
return `format.${error.params.format}`; | |
default: | |
// Fallback to the JSON schema keyword (i.e "required") | |
return error.keyword; | |
} | |
} | |
/** | |
* The path to the specific field where the error occurred, relative to the | |
* object that was validated. | |
* @private | |
* @param {AjvError} error | |
* @returns {string} Object path in dot notation | |
*/ | |
_errorDataPath(error) { | |
// Remove leading "." from paths | |
let dataPath = error.dataPath.replace(/^./, ""); | |
if (error.keyword === "required") { | |
// Include the name of the missing field: | |
const { missingProperty } = error.params; | |
dataPath = dataPath ? `${dataPath}.${missingProperty}` : missingProperty; | |
} | |
// Convert bracket notation into dot notation so we can have one consistent format | |
dataPath = dataPath | |
.replace(/\['/, ".") // foo['bar']['cuz'] => foo.bar'].cuz'] | |
.replace(/']/, ""); // foo.bar'].cuz'] => foo.bar.cuz | |
return dataPath; | |
} | |
/** | |
* A generic locale string key for the given error | |
* @private | |
* @param {string} dataPath - Formatted data path | |
* @param {string} rule - Formatted rule | |
* @param {AjvError} error | |
* @returns {string} Object path in dot notation | |
*/ | |
_errorMessagePath(dataPath, rule, error) { | |
// Remove the fields uuid so that our message path can be generic for the given field name | |
const regex = new RegExp(`^${collectionNamesRegex}\\.([\\S]+)\\.`); | |
dataPath = dataPath.replace(regex, "$1."); // people.abc-123.ssn => people.ssn | |
if (rule === "minItems" && error.params.limit === 1) { | |
/** | |
* When an array expects at least 1 item and the array was empty, | |
* we use the same message path for the error to make it easier | |
* for us to manage the content. | |
*/ | |
rule = "required"; | |
} | |
return `${dataPath}.${rule}`; | |
} | |
/** | |
* Some errors the Ajv validator returns aren't necessary for us in order to | |
* display an error message to a user. For example, Ajv returns an error object | |
* for an `if` block if it fails, which is expected (hence why there's an if statement). | |
* @param {AjvError} error | |
* @returns {boolean} returns `true` if the error should be kept, `false` if it should be removed | |
*/ | |
_filterError(error) { | |
const keywordsWeDontCareAbout = ["if"]; | |
return !keywordsWeDontCareAbout.includes(error.keyword); | |
} | |
/** | |
* The errors we get back from Ajv are different shapes | |
* depending on the validation rule that failed. This method | |
* normalizes those errors so it's easier to render proper | |
* error styling in the UI. | |
* @private | |
* @param {AjvError[]} errors | |
* @returns {ApplicationError[]} | |
*/ | |
_formatErrors(errors) { | |
if (!errors) return []; | |
return errors.filter(this._filterError).map(error => { | |
const rule = this._errorRule(error); | |
const dataPath = this._errorDataPath(error); | |
const messagePath = this._errorMessagePath(dataPath, rule, error); | |
return new ApplicationError({ | |
// Preserving the original error helps with development, | |
// but we shouldn't reference `_ajvError` in our code: | |
_ajvError: error, | |
dataPath, | |
rule, | |
messagePath | |
}); | |
}); | |
} | |
/** | |
* Run JSON Schema validations | |
* @param {object} [schema] | |
* @param {object} [data] | |
* @returns {{ valid: boolean, errors: ApplicationError[] }} | |
*/ | |
validate(schema, data) { | |
const validate = this.ajv.compile(schema); | |
const valid = validate(data); | |
const errors = this._formatErrors(validate.errors); | |
if (errors.length && process.env.NODE_ENV === "development") { | |
// Output the errors to the console during local development | |
// so it's easier to see what name and path should be used | |
// for the locale key | |
// eslint-disable-next-line no-console | |
console.table(errors); | |
} | |
return { errors, valid }; | |
} | |
} | |
module.exports = Validator; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const Validator = require("./Validator"); | |
describe("Validator", () => { | |
describe("#validate", () => { | |
describe("given a required fields validation", () => { | |
it("passes validation when required data is present", () => { | |
const schema = { | |
$schema: "http://json-schema.org/draft-07/schema", | |
$id: "test.json", | |
type: "object", | |
required: ["bar", "foo"], | |
properties: { | |
foo: { | |
type: "string" | |
}, | |
bar: { | |
type: "string" | |
} | |
} | |
}; | |
const data = { | |
foo: "2019-12-30", | |
bar: "Bud" | |
}; | |
const validator = new Validator(); | |
const results = validator.validate(schema, data); | |
expect(results.valid).toBe(true); | |
expect(results.errors).toHaveLength(0); | |
}); | |
it("fails validation when required data is not included", () => { | |
const schema = { | |
$schema: "http://json-schema.org/draft-07/schema", | |
$id: "test.json", | |
type: "object", | |
required: ["foo", "bar"], | |
properties: { | |
foo: { | |
type: "string" | |
}, | |
bar: { | |
type: "string" | |
} | |
} | |
}; | |
const data = {}; | |
const validator = new Validator(); | |
const results = validator.validate(schema, data); | |
expect(results.valid).toBe(false); | |
expect(results.errors).toHaveLength(2); | |
expect(results.errors[0].dataPath).toBe("foo"); | |
expect(results.errors[1].dataPath).toBe("bar"); | |
expect(results.errors[0].rule).toBe("required"); | |
expect(results.errors[1].rule).toBe("required"); | |
expect(results.errors[0].messagePath).toBe("foo.required"); | |
expect(results.errors[1].messagePath).toBe("bar.required"); | |
}); | |
}); | |
describe("given a schema with an if/else condition", () => { | |
const schema = { | |
$schema: "http://json-schema.org/draft-07/schema", | |
$id: "test/ssn.json", | |
type: "object", | |
properties: { | |
ssn: { | |
type: "string" | |
}, | |
attestedNoSSN: { | |
type: "boolean" | |
} | |
}, | |
if: { | |
required: ["attestedNoSSN"], | |
properties: { | |
attestedNoSSN: { | |
const: true | |
} | |
} | |
}, | |
then: {}, | |
else: { | |
required: ["ssn"] | |
} | |
}; | |
it("passes validation when 'if' condition is met", () => { | |
const data = { attestedNoSSN: true }; | |
const validator = new Validator(); | |
const results = validator.validate(schema, data); | |
expect(results.valid).toBe(true); | |
}); | |
it("passes validation when 'else' condition is met", () => { | |
const data = { ssn: "123456789" }; | |
const validator = new Validator(); | |
const results = validator.validate(schema, data); | |
expect(results.valid).toBe(true); | |
}); | |
it("returns error about 'else' condition if neither conditions are met", () => { | |
const data = {}; | |
const validator = new Validator(); | |
const results = validator.validate(schema, data); | |
expect(results.valid).toBe(false); | |
expect(results.errors).toHaveLength(1); | |
expect(results.errors[0].messagePath).toBe("ssn.required"); | |
}); | |
}); | |
describe("given a date format validation", () => { | |
it("requires dates to be ISO 8601 format", () => { | |
const schema = { | |
$schema: "http://json-schema.org/draft-07/schema", | |
$id: "test.json", | |
type: "object", | |
required: ["foo", "bar"], | |
properties: { | |
foo: { | |
type: "string", | |
format: "date" | |
}, | |
bar: { | |
type: "string", | |
format: "date" | |
} | |
} | |
}; | |
const data = { | |
foo: "2019-10-02", | |
bar: "20199999999-10-02" | |
}; | |
const validator = new Validator(); | |
const results = validator.validate(schema, data); | |
expect(results.valid).toBe(false); | |
expect(results.errors).toHaveLength(1); | |
expect(results.errors[0].dataPath).toBe("bar"); | |
expect(results.errors[0].rule).toBe("format.date"); | |
expect(results.errors[0].messagePath).toBe("bar.format.date"); | |
}); | |
}); | |
describe("given an array type with a minLength of 1", () => { | |
it("outputs a {name}.required messagePath, rather than {name}.minLength", () => { | |
const schema = { | |
$schema: "http://json-schema.org/draft-07/schema", | |
$id: "test.json", | |
type: "object", | |
properties: { | |
foo: { | |
type: "array", | |
minItems: 1 | |
} | |
} | |
}; | |
const data = { foo: [] }; | |
const validator = new Validator(); | |
const results = validator.validate(schema, data); | |
expect(results.valid).toBe(false); | |
expect(results.errors[0].messagePath).toBe("foo.required"); | |
}); | |
}); | |
describe("given a schema that references subschemas", () => { | |
it("validates data with the combined schemas", () => { | |
const birthdateSchema = { | |
$schema: "http://json-schema.org/draft-07/schema", | |
$id: "birthdate.json", | |
type: "object", | |
required: ["birthdate"], | |
properties: { | |
birthdate: { | |
type: "string", | |
format: "date" | |
} | |
} | |
}; | |
const nameSchema = { | |
$schema: "http://json-schema.org/draft-07/schema", | |
$id: "name.json", | |
type: "object", | |
required: ["name"], | |
properties: { | |
name: { | |
type: "string" | |
} | |
} | |
}; | |
const fullSchema = { | |
$schema: "http://json-schema.org/draft-07/schema", | |
$id: "full.json", | |
type: "object", | |
properties: { | |
person: { | |
allOf: [{ $ref: "birthdate.json" }, { $ref: "name.json" }] | |
} | |
} | |
}; | |
// Create a Validator, passing in the subschemas | |
const validator = new Validator([birthdateSchema, nameSchema]); | |
// Run a validation with invalid date, and another validation with valid data: | |
const badDataResults = validator.validate(fullSchema, { person: {} }); | |
const goodDataResults = validator.validate(fullSchema, { | |
person: { birthdate: "2019-12-09", name: "Bud" } | |
}); | |
expect(badDataResults.valid).toBe(false); | |
expect(badDataResults.errors).toHaveLength(2); | |
expect(goodDataResults.valid).toBe(true); | |
}); | |
it("throws error if a subschema isn't found", () => { | |
const fullSchema = { | |
$schema: "http://json-schema.org/draft-07/schema", | |
$id: "full.json", | |
type: "object", | |
properties: { | |
person: { | |
// These point to schemas that the validator won't be aware of | |
allOf: [{ $ref: "birthdate.json" }, { $ref: "name.json" }] | |
} | |
} | |
}; | |
const validator = new Validator(); | |
expect(() => { | |
validator.validate(fullSchema, {}); | |
}).toThrowError(/can't resolve reference/); | |
}); | |
}); | |
describe("given invalid data with varying levels of nested data", () => { | |
const schema = { | |
$schema: "http://json-schema.org/draft-07/schema", | |
$id: "nested.json", | |
type: "object", | |
required: ["people", "resources"], | |
properties: { | |
people: { | |
type: "object" | |
}, | |
resources: { | |
type: "object", | |
patternProperties: { | |
// Each resource is an object, with any alphanumeric string as a valid key: | |
"^[0-9a-zA-Z-]+": { | |
type: "object", | |
required: ["name"], | |
properties: { | |
name: { | |
type: "string" | |
} | |
} | |
} | |
} | |
} | |
} | |
}; | |
it("error.dataPath includes the full path name, in dot notation", () => { | |
// Data is missing the required `people` top-level property, | |
// and is missing the required `resources[...].name nested property | |
const data = { | |
resources: { | |
foo: {} | |
} | |
}; | |
const validator = new Validator(); | |
const results = validator.validate(schema, data); | |
expect(results.valid).toBe(false); | |
expect(results.errors[0].dataPath).toBe("people"); | |
expect(results.errors[1].dataPath).toBe("resources.foo.name"); | |
}); | |
it("error.messagePath includes the rule name appended to the path name, excluding the UUID", () => { | |
// Data is missing the required `people` top-level property, | |
// and is missing the required `resources[...].name nested property | |
const data = { | |
resources: { | |
foo: {} | |
} | |
}; | |
const validator = new Validator(); | |
const results = validator.validate(schema, data); | |
expect(results.valid).toBe(false); | |
expect(results.errors[0].messagePath).toBe("people.required"); | |
// ".foo." is not included in this path: | |
expect(results.errors[1].messagePath).toBe("resources.name.required"); | |
}); | |
}); | |
}); | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment