Skip to content

Commit

Permalink
Merge pull request #355 from apiaryio/90-ajv
Browse files Browse the repository at this point in the history
Adds JSON Schema Draft 6/7 support
  • Loading branch information
artem-zakharchenko authored Dec 6, 2019
2 parents 816b7ab + 593ea41 commit 3215a44
Show file tree
Hide file tree
Showing 29 changed files with 2,062 additions and 1,547 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
};
12 changes: 2 additions & 10 deletions lib/units/validateBody.js
Original file line number Diff line number Diff line change
Expand Up @@ -238,17 +238,9 @@ function validateBody(expected, actual) {
const usesJsonSchema = ValidatorClass && ValidatorClass.name === 'JsonSchema';
const validator =
ValidatorClass &&
new ValidatorClass(
usesJsonSchema ? expected.bodySchema : expected.body,
actual.body
);
new ValidatorClass(usesJsonSchema ? expected.bodySchema : expected.body);

// Calling "validate()" often updates an internal state of a validator.
// That state is later used to output the gavel-compliant results.
// Cannot remove until validators are refactored into simple functions.
// @see https://github.com/apiaryio/gavel.js/issues/150
validator && validator.validate();
const validationErrors = validator ? validator.evaluateOutputToResults() : [];
const validationErrors = validator ? validator.validate(actual.body) : [];
errors.push(...validationErrors);

return {
Expand Down
6 changes: 3 additions & 3 deletions lib/units/validateHeaders.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,14 @@ function validateHeaders(expected, actual) {
expectedType === APIARY_JSON_HEADER_TYPE;

const validator = hasJsonHeaders
? new HeadersJsonExample(values.expected, values.actual)
? new HeadersJsonExample(values.expected)
: null;

// if you don't call ".validate()", it never evaluates any results.
validator && validator.validate();
const validationErrors = validator && validator.validate(values.actual);

if (validator) {
errors.push(...validator.evaluateOutputToResults());
errors.push(...validationErrors);
} else {
errors.push({
message: `\
Expand Down
31 changes: 31 additions & 0 deletions lib/utils/to-gavel-result.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
const jsonPointer = require('json-pointer');

function splitProperty(property) {
return property.split(/\.|\[|\]/).filter(Boolean);
}

function reduceProperties(acc, property) {
return acc.concat(splitProperty(property));
}

/**
* Converts legacy (Amanda/TV4) error messages
* to the Gavel-compliant structure.
*/
function toGavelResult(legacyErrors) {
return Array.from({ length: legacyErrors.length }, (_, index) => {
const item = legacyErrors[index];
const propertyPath = item.property.reduce(reduceProperties, []);
const pointer = jsonPointer.compile(propertyPath);

return {
message: item.message,
location: {
pointer,
property: propertyPath
}
};
});
}

module.exports = toGavelResult;
60 changes: 24 additions & 36 deletions lib/validators/headers-json-example.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
const clone = require('clone');

const errors = require('../errors');
const { JsonSchema } = require('./json-schema');
const { JsonSchemaLegacy } = require('./json-schema-legacy');
const {
SchemaV4Generator,
SchemaV4Properties
} = require('../utils/schema-v4-generator');
const tv4ToHeadersMessage = require('../utils/tv4-to-headers-message');

const prepareHeaders = (headers) => {
const resolveHeaders = (headers) => {
if (typeof headers !== 'object') {
return headers;
}
Expand Down Expand Up @@ -37,53 +34,44 @@ const getSchema = (json) => {
return schemaGenerator.generate();
};

class HeadersJsonExample extends JsonSchema {
constructor(expected, actual) {
if (typeof actual !== 'object') {
throw new errors.MalformedDataError('Actual is not an Object');
}

class HeadersJsonExample extends JsonSchemaLegacy {
constructor(expected) {
if (typeof expected !== 'object') {
throw new errors.MalformedDataError('Expected is not an Object');
}

const preparedExpected = prepareHeaders(expected);
const preparedActual = prepareHeaders(actual);
const preparedSchema = getSchema(preparedExpected);
const resolvedExpected = resolveHeaders(expected);
const resolvedJsonSchema = getSchema(resolvedExpected);

if (preparedSchema && preparedSchema.properties) {
if (resolvedJsonSchema && resolvedJsonSchema.properties) {
const skippedHeaders = ['date', 'expires'];
skippedHeaders.forEach((headerName) => {
if (preparedSchema.properties[headerName]) {
delete preparedSchema.properties[headerName].enum;
if (resolvedJsonSchema.properties[headerName]) {
delete resolvedJsonSchema.properties[headerName].enum;
}
});
}

super(preparedSchema, preparedActual);
super(resolvedJsonSchema);

this.expected = preparedExpected;
this.actual = preparedActual;
this.schema = preparedSchema;
this.expected = resolvedExpected;
this.jsonSchema = resolvedJsonSchema;
}

validate() {
const result = super.validate();

if (result.length > 0) {
const resultCopy = clone(result, false);

for (let i = 0; i < result.length; i++) {
resultCopy[i].message = tv4ToHeadersMessage(
resultCopy[i].message,
this.expected
);
}

return resultCopy;
validate(data) {
if (typeof data !== 'object') {
throw new errors.MalformedDataError('Actual is not an Object');
}

return result;
const resolvedData = resolveHeaders(data);
const results = super.validate(resolvedData);

/**
* @TODO Revert custom formatting of TV4 header-related validation errors.
* @see https://github.com/apiaryio/gavel.js/issues/360
* @see https://github.com/apiaryio/gavel.js/blob/816b7ab1fb8fec345f842e93edc214a532758323/lib/validators/headers-json-example.js#L76-L81
*/
return results;
}
}

Expand Down
20 changes: 10 additions & 10 deletions lib/validators/json-example.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,18 @@ function getSchema(json) {
}

class JsonExample extends JsonSchema {
constructor(expected) {
// Generate JSON Schema from the given expected JSON data.
const jsonSchema = getSchema(expected);
super(jsonSchema);
}

/**
* Construct a BodyValidator, check data and choose the right validator.
* If actual and expected data are valid JSON, and a valid schema is given,
* choose JsonValidator, otherwise choose StringValidator.
* @param {string} expected
* @param {string} actual
* @throw {MalformedDataError} when actual is not a String or when no schema provided and expected is not a String
* @throw {SchemaNotJsonParsableError} when given schema is not a json parsable string or valid json
* @throw {NotEnoughDataError} when at least one of expected data and json schema is not given
*/
constructor(expected, actual) {
validate(actual) {
const { jsonSchema: expected } = this;

if (typeof actual !== 'string') {
const outError = new errors.MalformedDataError(
'JsonExample validator: provided actual data is not string'
Expand All @@ -43,8 +44,7 @@ class JsonExample extends JsonSchema {
throw outError;
}

const schema = getSchema(expected);
super(schema, actual);
return super.validate(actual);
}
}

Expand Down
162 changes: 162 additions & 0 deletions lib/validators/json-schema-legacy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
const amanda = require('amanda');
const tv4 = require('tv4');
const jsonPointer = require('json-pointer');

const { JsonSchemaValidator, META_SCHEMA } = require('./json-schema-next');
const { ValidationErrors } = require('./validation-errors');
const toGavelResult = require('../utils/to-gavel-result');

/**
* Returns a proper article for a given string.
* @param {string} str
* @returns {string}
*/
function getArticle(str) {
return ['a', 'e', 'i', 'o', 'u'].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 JsonSchemaLegacy extends JsonSchemaValidator {
validateSchema() {
const { jsonSchema, jsonMetaSchema } = this;

// In case schema version is unidentified,
// assume JSON Schema Draft V3.
const metaSchema = jsonMetaSchema || META_SCHEMA.draftV3;

tv4.reset();
tv4.addSchema('', metaSchema);
tv4.addSchema(metaSchema.$schema, metaSchema);
const validationResult = tv4.validateResult(jsonSchema, metaSchema);

return validationResult.valid;
}

validate(data) {
const parsedData = this.parseData(data);

switch (this.jsonSchemaVersion) {
case 'draftV3':
return this.validateUsingAmanda(parsedData);
case 'draftV4':
return this.validateUsingTV4(parsedData);
default:
throw new Error(
`Attempted to use JsonSchemaLegacy on non-legacy JSON Schema ${this.jsonSchemaVersion}!`
);
}
}

validateUsingAmanda(data) {
let errors = {
length: 0,
errorMessages: {}
};

try {
amanda.validate(data, this.jsonSchema, jsonSchemaOptions, (error) => {
if (error && error.length > 0) {
for (let i = 0; i < error.length; i++) {
if (error[i].property === '') {
error[i].property = [];
}
}

errors = new ValidationErrors(error);
}
});
} catch (internalError) {
errors = new ValidationErrors({
'0': {
property: [],
attributeValue: true,
message: `Validator internal error: ${internalError.message}`,
validatorName: 'error'
},
length: 1,
errorMessages: {}
});
}

return toGavelResult(errors);
}

validateUsingTV4(data) {
const result = tv4.validateMultiple(data, this.jsonSchema);
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'
};
}

const errors = new ValidationErrors(amandaCompatibleError);
return toGavelResult(errors);
}
}

module.exports = { JsonSchemaLegacy };
Loading

0 comments on commit 3215a44

Please sign in to comment.