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

Adds JSON Schema Draft 6/7 support #355

Merged
merged 20 commits into from
Dec 6, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
57f1b05
feat: adds JSON Schema Draft 6/7 support
artem-zakharchenko Nov 26, 2019
e82a1e2
refactor: stores JSON Schema Draft versions as "draftV{x}"
artem-zakharchenko Nov 27, 2019
28d94b7
refactor: sets implicit JSON Schema version, if not set
artem-zakharchenko Nov 29, 2019
1930ec5
refactor: parses given data in case of string in JsonSchemaValidator
artem-zakharchenko Nov 29, 2019
cb8c01a
refactor: adjusts "validateBody" to new JsonSchema call signature
artem-zakharchenko Nov 29, 2019
f9f9357
refactor: adjusts "validateHeaders" to new JsonSchema call signature
artem-zakharchenko Nov 29, 2019
e8eadf9
refactor: adjusts "HeadersJsonExample" to new JsonSchema signature
artem-zakharchenko Nov 29, 2019
ae746ae
refactor: removes "amanda-to-gavel-shared" test suites
artem-zakharchenko Nov 29, 2019
549bc03
refactor: adjusts call signature of "TextDiff.validate()"
artem-zakharchenko Dec 2, 2019
0304227
refactor: fixes unsupported schema test suite for JsonSchemaValidator
artem-zakharchenko Dec 2, 2019
cd142fc
refactor: Simplifies tv4 to headers coercion in HeadersJsonExample
artem-zakharchenko Dec 2, 2019
d8a6e56
refactor: fixes the order of expected/actual values in custom chai as…
artem-zakharchenko Dec 2, 2019
a74f245
refactor: removes the "json-ajv" test suite
artem-zakharchenko Dec 2, 2019
cf4952f
refactor: removes "JsonSchema" dependency from "json-example" test suite
artem-zakharchenko Dec 2, 2019
f33466f
test: adjusts JsonSchemaLegacy suite to assert unmatching value again…
artem-zakharchenko Dec 2, 2019
7537859
feat: uses ["path", "array"] structure for "location.property" value
artem-zakharchenko Dec 2, 2019
6223720
chore: installs "@rollup/plugin-json"
artem-zakharchenko Dec 2, 2019
75650c2
chore: uses "@rollup/plugin-json" to import ".json" modules
artem-zakharchenko Dec 2, 2019
9bccf12
refactor: uses raw TV4 error format for header-related error messages
artem-zakharchenko Dec 2, 2019
593ea41
feat: infers JSON Schema Draft 7 when schema is boolean
artem-zakharchenko Dec 4, 2019
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 {}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explicit error type to use in unit tests instead of asserting an exact message.


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() {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overriding JsonSchemaValidator.validateSchema() to use TV4 to validate both V3 and V4 (copying previous implementation, refactored).

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