Skip to content
This repository has been archived by the owner on Nov 8, 2024. It is now read-only.

Commit

Permalink
feat: adds JSON Schema Draft 6/7 support
Browse files Browse the repository at this point in the history
  • Loading branch information
artem-zakharchenko committed Nov 26, 2019
1 parent 816b7ab commit fda7b4e
Show file tree
Hide file tree
Showing 16 changed files with 1,157 additions and 345 deletions.
4 changes: 3 additions & 1 deletion lib/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class UnknownValidatorError extends Error {}
class NotValidatableError extends Error {}
class NotEnoughDataError extends Error {}
class JsonSchemaNotValid extends Error {}
class JsonSchemaNotSupported extends Error {}

module.exports = {
DataNotJsonParsableError,
Expand All @@ -18,4 +19,5 @@ module.exports = {
NotValidatableError,
NotEnoughDataError,
JsonSchemaNotValid,
}
JsonSchemaNotSupported
};
366 changes: 366 additions & 0 deletions lib/validators/__json-schema.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,366 @@
const deepEqual = require('deep-equal');
const jsonPointer = require('json-pointer');
const Ajv = require('ajv');
const amanda = require('amanda');
const tv4 = require('tv4');

const parseJson = require('../utils/parseJson');
const metaSchemaV3 = require('../meta-schema-v3');
const metaSchemaV4 = require('../meta-schema-v4');
const errors = require('../errors');
const { ValidationErrors } = require('./validation-errors');

const SCHEMA_V3 = 'http://json-schema.org/draft-03/schema';
const SCHEMA_V4 = 'http://json-schema.org/draft-04/schema';

/**
* @param {Object} schema
* @returns {[Object, string] | null} Tuple of [schemaMeta, schemaVersion]
*/
const getSchemaMeta = (schema) => {
if (schema && schema.$schema && schema.$schema.includes(SCHEMA_V3)) {
return [metaSchemaV3, 'v3'];
}

if (schema && schema.$schema && schema.$schema.includes(SCHEMA_V4)) {
return [metaSchemaV4, 'v4'];
}

return null;
};

/**
* Returns a proper article for a given string.
* @param {string} str
* @returns {string}
*/
function getArticle(str) {
const vowels = ['a', 'e', 'i', 'o', 'u'];
return vowels.includes(str.toLowerCase()) ? 'an' : 'a';
}

const jsonSchemaOptions = {
singleError: false,
messages: {
minLength: (prop, val, validator) =>
`The ${prop} property must be at least ${validator} characters long (currently ${val.length} characters long).`,
maxLength: (prop, val, validator) =>
`The ${prop} property must not exceed ${validator} characters (currently${val.length} characters long).`,
length: (prop, val, validator) =>
`The ${prop} property must be exactly ${validator} characters long (currently ${val.length} characters long).`,
format: (prop, val, validator) =>
`The ${prop} property must be ${getArticle(
validator[0]
)} ${validator} (current value is ${JSON.stringify(val)}).`,
type: (prop, val, validator) =>
`The ${prop} property must be ${getArticle(
validator[0]
)} ${validator} (current value is ${JSON.stringify(val)})."`,
except: (prop, val) => `The ${prop} property must not be ${val}.`,
minimum: (prop, val, validator) =>
`The minimum value of the ${prop} must be ${validator} (current value is ${JSON.stringify(
val
)}).`,
maximum: (prop, val, validator) =>
`The maximum value of the ${prop} must be ${validator} (current value is ${JSON.stringify(
val
)}).`,
pattern: (prop, val, validator) =>
`The ${prop} value (${val}) does not match the ${validator} pattern.`,
maxItems: (prop, val, validator) =>
`The ${prop} property must not contain more than ${validator} items (currently contains ${val.length} items).`,
minItems: (prop, val, validator) =>
`The ${prop} property must contain at least ${validator} items (currently contains ${val.length} items).`,
divisibleBy: (prop, val, validator) =>
`The ${prop} property is not divisible by ${validator} (current value is ${JSON.stringify(
val
)}).`,
uniqueItems: (prop) => `All items in the ${prop} property must be unique.`
}
};

class JsonSchema {
/**
* Constructs a JsonValidator and validates given data.
* @param {Object | string} schema
* @param {Object | string} data
*/
constructor(schema, data) {
this.schema = schema;
this.data = data;

if (typeof this.data === 'string') {
try {
this.data = parseJson(this.data);
} catch (error) {
const outError = new errors.DataNotJsonParsableError(
`JSON validator: body: ${error.message}`
);
outError.data = this.data;
throw outError;
}
}

if (typeof this.schema === 'string') {
try {
this.schema = parseJson(this.schema);
} catch (error) {
const outError = new errors.SchemaNotJsonParsableError(
`JSON validator: schema: ${error.message}`
);
outError.schema = this.schema;
throw outError;
}
}

this.jsonSchemaVersion = null;
this.validateSchema();
}

/**
* Asserts that the given JSON Schema is a valid schema
* according with the supported JSON Schema Draft versions.
*/
validateSchema() {
const [metaSchema, schemaVersion] = getSchemaMeta(this.schema) || [];

if (metaSchema) {
this.jsonSchemaVersion = schemaVersion;

if (metaSchema.$schema) {
tv4.reset();
tv4.addSchema('', metaSchema);
tv4.addSchema(metaSchema.$schema, metaSchema);
const validationResult = tv4.validateResult(this.schema, metaSchema);
if (!validationResult.valid) {
throw new errors.JsonSchemaNotValid(
`JSON schema is not valid draft ${this.jsonSchemaVersion}! ${validationResult.error.message} at path "${validationResult.error.dataPath}"`
);
}
}
} else {
if (metaSchemaV3.$schema) {
tv4.reset();
tv4.addSchema('', metaSchemaV3);
tv4.addSchema(metaSchemaV3.$schema, metaSchemaV3);
const validationResult = tv4.validateResult(this.schema, metaSchemaV3);

if (validationResult && validationResult.valid) {
this.jsonSchemaVersion = 'v3';
return;
}
}

if (metaSchemaV4.$schema) {
tv4.reset();
tv4.addSchema('', metaSchemaV4);
tv4.addSchema(metaSchemaV4.$schema, metaSchemaV4);
const validationResult = tv4.validateResult(this.schema, metaSchemaV4);

if (validationResult && validationResult.valid) {
this.jsonSchemaVersion = 'v4';
return;
}
}

if (this.jsonSchemaVersion === null) {
throw new errors.JsonSchemaNotValid(
'JSON schema is not valid draft v3 or draft v4!'
);
}
}
}

validateSchemaNext() {
this.errors = [];

const ajv = new Ajv({
schemaId: 'id',
allErrors: true,
jsonPointers: true,
// Make AJV point to the property in "error.dataPath",
// so it could be used as a complete pointer.
errorDataPath: 'property'
});

ajv.addMetaSchema(metaSchemaV4);

try {
ajv.validate(this.schema, this.data);
} catch (error) {
// This throws in case:
// - schema is invalid
// - data property doesn't implement schema properly
this.errors.push({
message: error.message
});
}

// Emitting "this.errors" directory will make this method
// incompatible with ".evaluateOutputToResults()" usage.
ajv.errors.forEach((ajvError) => {
const property = ajvError.params.missingProperty;

this.errors.push({
message: `'${property}' ${ajvError.message}`,
location: {
pointer: ajvError.dataPath,
property
}
});
});

return this.errors;
}

validate() {
if (
this.data !== null &&
typeof this.data === 'object' &&
this.schema.empty
) {
this.output = {
length: 0,
errorMessages: {}
};
return new ValidationErrors(this.output);
}

const hasSameData = deepEqual(this.data, this.usedData, { strict: true });
const hasSameSchema = hasSameData
? deepEqual(this.schema, this.usedSchema, { strict: true })
: true;

if (!hasSameData || !hasSameSchema) {
this.output = this.validatePrivate();
}

return this.output;
}

validatePrivate() {
/**
* @TODO Would be nice to figure out why are we storing these.
*/
this.usedData = this.data;
this.usedSchema = this.schema;

switch (this.jsonSchemaVersion) {
case 'v3':
return this.validateSchemaV3();
case 'v4':
return this.validateSchemaV4();
case 'v6':
case 'v7':
return this.validateSchemaNext();
default:
throw new Error("JSON schema version not identified, can't validate!");
}
}

/**
* Converts TV4 output to Gavel results.
*/
evaluateOutputToResults(data) {
if (!data) {
data = this.output;
}

if (!data) {
return [];
}

const results = Array.from({ length: data.length }, (_, index) => {
const item = data[index];
const { message, property } = item;
const pathArray = [].concat(property).filter(Boolean);
const pointer = jsonPointer.compile(pathArray);

return {
message,
location: {
pointer,
property
}
};
});

return results;
}

validateSchemaV3() {
try {
return amanda.validate(
this.data,
this.schema,
jsonSchemaOptions,
(error) => {
if (error && error.length > 0) {
for (let i = 0; i < error.length; i++) {
if (error[i].property === '') {
error[i].property = [];
}
}
this.errors = new ValidationErrors(error);
return this.errors;
}
}
);
} catch (error) {
this.errors = new ValidationErrors({
'0': {
property: [],
attributeValue: true,
message: `Validator internal error: ${error.message}`,
validatorName: 'error'
},
length: 1,
errorMessages: {}
});

return this.errors;
}
}

validateSchemaV4() {
const result = tv4.validateMultiple(this.data, this.schema);
const validationErrors = result.errors.concat(result.missing);

const amandaCompatibleError = {
length: validationErrors.length,
errorMessages: {}
};

for (let index = 0; index < validationErrors.length; index++) {
const validationError = validationErrors[index];
let error;

if (validationError instanceof Error) {
error = validationError;
} else {
error = new Error('Missing schema');
error.params = { key: validationError };
error.dataPath = '';
}

const pathArray = jsonPointer
.parse(error.dataPath)
.concat(error.params.key || []);
const pointer = jsonPointer.compile(pathArray);

amandaCompatibleError[index] = {
message: `At '${pointer}' ${error.message}`,
property: pathArray,
attributeValue: true,
validatorName: 'error'
};
}

this.errors = new ValidationErrors(amandaCompatibleError);
return this.errors;
}
}

module.exports = {
JsonSchema
};
Loading

0 comments on commit fda7b4e

Please sign in to comment.