Skip to content

Instantly share code, notes, and snippets.

@sawyerh
Created April 2, 2020 13:47
Show Gist options
  • Save sawyerh/fdb367466012b5d2345067735b3d10e1 to your computer and use it in GitHub Desktop.
Save sawyerh/fdb367466012b5d2345067735b3d10e1 to your computer and use it in GitHub Desktop.
Ajv wrapper for validating JSON Schemas
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;
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