Skip to content

Commit

Permalink
Merge pull request #404 from apiaryio/ajv-draft-4
Browse files Browse the repository at this point in the history
Uses AJV for JSON Schema Draft V4 validation
  • Loading branch information
artem-zakharchenko authored Feb 4, 2020
2 parents e63e141 + bdb9b90 commit 888b34d
Show file tree
Hide file tree
Showing 18 changed files with 456 additions and 355 deletions.
7 changes: 4 additions & 3 deletions lib/units/validateBody.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ const mediaTyper = require('media-typer');
const contentTypeUtils = require('content-type');

const { isValidField } = require('./isValid');
const { TextDiff, JsonExample, JsonSchema } = require('../validators');
const { TextDiff, JsonExample, JsonSchemaValidator } = require('../validators');
const isset = require('../utils/isset');
const parseJson = require('../utils/parseJson');

Expand Down Expand Up @@ -132,7 +132,7 @@ function getBodyValidator(expectedType, actualType) {
// List JsonSchema first, because weak predicate of JsonExample
// would resolve on "application/schema+json" media type too.
[
JsonSchema,
JsonSchemaValidator,
(expected, actual) => {
return isJson(actual) && isJsonSchema(expected);
},
Expand Down Expand Up @@ -235,7 +235,8 @@ function validateBody(expected, actual) {
}
}

const usesJsonSchema = ValidatorClass && ValidatorClass.name === 'JsonSchema';
const usesJsonSchema =
ValidatorClass && ValidatorClass.name === 'JsonSchemaValidator';
const validator =
ValidatorClass &&
new ValidatorClass(usesJsonSchema ? expected.bodySchema : expected.body);
Expand Down
4 changes: 2 additions & 2 deletions lib/utils/schema-v4-generator.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,11 @@ class SchemaV4Generator {

if (firstLevel) {
schemaDict.$schema = SCHEMA_VERSION;
schemaDict.id = '#';
schemaDict.$id = '#';
}

if (objectId != null) {
schemaDict.id = objectId;
schemaDict.$id = objectId;
}

const schemaType = this.getSchemaTypeFor(baseObject);
Expand Down
4 changes: 2 additions & 2 deletions lib/validators/headers-json-example.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const errors = require('../errors');
const { JsonSchemaLegacy } = require('./json-schema-legacy');
const { JsonSchemaValidator } = require('./json-schema-next');
const {
SchemaV4Generator,
SchemaV4Properties
Expand Down Expand Up @@ -34,7 +34,7 @@ const getSchema = (json) => {
return schemaGenerator.generate();
};

class HeadersJsonExample extends JsonSchemaLegacy {
class HeadersJsonExample extends JsonSchemaValidator {
constructor(expected) {
if (typeof expected !== 'object') {
throw new errors.MalformedDataError('Expected is not an Object');
Expand Down
4 changes: 2 additions & 2 deletions lib/validators/index.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
const { HeadersJsonExample } = require('./headers-json-example');
const { TextDiff } = require('./text-diff');
const { JsonExample } = require('./json-example');
const { JsonSchema } = require('./json-schema');
const { JsonSchemaValidator } = require('./json-schema-next');

module.exports = {
HeadersJsonExample,
TextDiff,
JsonExample,
JsonSchema
JsonSchemaValidator
};
38 changes: 8 additions & 30 deletions lib/validators/json-example.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
const errors = require('../errors');
const { JsonSchema } = require('./json-schema');
const { JsonSchemaValidator } = require('./json-schema-next');
const {
SchemaV4Generator,
SchemaV4Properties
} = require('../utils/schema-v4-generator');

function getSchema(json) {
function generateSchema(json) {
const properties = new SchemaV4Properties({
keysStrict: false,
valuesStrict: false,
Expand All @@ -15,37 +14,16 @@ function getSchema(json) {
return schemaGenerator.generate();
}

class JsonExample extends JsonSchema {
/**
* JSON Example validator is a superset of JSON Schema validation
* that generates a JSON Schema based on the given actual data.
*/
class JsonExample extends JsonSchemaValidator {
constructor(expected) {
// Generate JSON Schema from the given expected JSON data.
const jsonSchema = getSchema(expected);
const jsonSchema = generateSchema(expected);
super(jsonSchema);
}

/**
* @throw {MalformedDataError} when actual is not a String or when no schema provided and expected is not a String
*/
validate(actual) {
const { jsonSchema: expected } = this;

if (typeof actual !== 'string') {
const outError = new errors.MalformedDataError(
'JsonExample validator: provided actual data is not string'
);
outError.data = actual;
throw outError;
}

if (typeof expected !== 'string') {
const outError = new errors.MalformedDataError(
'JsonExample validator: provided expected data is not string'
);
outError.data = expected;
throw outError;
}

return super.validate(actual);
}
}

module.exports = {
Expand Down
76 changes: 0 additions & 76 deletions lib/validators/json-schema-legacy.js

This file was deleted.

107 changes: 97 additions & 10 deletions lib/validators/json-schema-next.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ const META_SCHEMA = {
draftV7: metaSchemaV7
};

const last = (list) => {
return list[list.length - 1];
};

/**
* Returns a JSON Schema Draft version of the given JSON Schema.
*/
Expand All @@ -32,6 +36,7 @@ const getExplicitSchemaVersion = (jsonSchema) => {
};

const getImplicitSchemaVersion = (jsonSchema) => {
// A single boolean value is a valid JSON Schema Draft 7
if (typeof jsonSchema === 'boolean') {
return 'draftV7';
}
Expand Down Expand Up @@ -66,8 +71,8 @@ const getSchemaVersion = (jsonSchema) => {

class JsonSchemaValidator {
constructor(jsonSchema) {
this.jsonSchema = jsonSchema;
this.jsonSchemaVersion = getSchemaVersion(jsonSchema);
this.jsonSchema = this.resolveJsonSchema(jsonSchema);
this.jsonSchemaVersion = getSchemaVersion(this.jsonSchema);

if (this.jsonSchemaVersion == null) {
const supportedVersions = Object.keys(SCHEMA_VERSIONS).join('/');
Expand All @@ -90,6 +95,28 @@ class JsonSchemaValidator {
}
}

/**
* Parses given JSON Schema string.
* Prevents invalid JSON to be consumed by a validator.
*/
resolveJsonSchema(jsonSchema) {
let resolvedJsonSchema = jsonSchema;

if (typeof jsonSchema === 'string') {
try {
resolvedJsonSchema = parseJson(jsonSchema);
} catch (error) {
const unparsableJsonSchemaError = new errors.SchemaNotJsonParsableError(
`Given JSON Schema is not a valid JSON. ${error.message}`
);
unparsableJsonSchemaError.schema = jsonSchema;
throw unparsableJsonSchemaError;
}
}

return resolvedJsonSchema;
}

/**
* Returns a meta schema for the provided JSON Schema.
*/
Expand All @@ -103,7 +130,11 @@ class JsonSchemaValidator {
*/
validateSchema() {
const { jsonSchemaVersion, jsonSchema } = this;
const ajv = new Ajv();
const ajv = new Ajv({
// In order to use AJV with draft-4 and draft-6/7
// provide the "auto" value to "schemaId".
schemaId: 'auto'
});

const metaSchema = META_SCHEMA[jsonSchemaVersion];
ajv.addMetaSchema(metaSchema, 'meta');
Expand Down Expand Up @@ -141,11 +172,18 @@ class JsonSchemaValidator {
const parsedData = this.parseData(data);

const ajv = new Ajv({
// Enable for error messages to include enum violations.
allErrors: true,
// Enalbe verbose mode for error messages to include
// the actual values in `error[n].data`.
verbose: true,
// Disable adding JSON Schema Draft 7 meta schema by default.
// Allows to always add a meta schema depending on the schema version.
meta: false,
// No need to validate schema again, already validated
// in "validateSchema()" method.
// in "validateSchema()" method. Once AJV is the only validator
// it would make sense to remove the custom schema validation method
// and use this built-in behavior instead.
validateSchema: false,
jsonPointers: true
});
Expand All @@ -154,13 +192,26 @@ class JsonSchemaValidator {

// Convert AJV validation errors to the Gavel public validation errors.
return (ajv.errors || []).map((ajvError) => {
const errorMessage = ajv.errorsText([ajvError]);
const { dataPath: pointer } = ajvError;
const relevantProperty = this.getErrorProperty(ajvError);

// When AJV does not return a property name
// compose it from the pointer.
const property =
ajvError.params.missingProperty || pointer.split('/').filter(Boolean);
const pointer = ajvError.dataPath.concat(
// Handle root-level pointers.
// AJV returns an empty `dataPath` when a root-level property
// rejects. TV4, however, used to return a pointer to a root-level
// property regardless. Keep backward-compatibility.
relevantProperty ? ['/', relevantProperty].join('') : ''
);

// Property is pretty much 1-1 representation of the pointer
// stored in the list of strings.
const property = pointer.split('/').filter(Boolean);

const errorMessage = this.getBackwardCompatibleErrorMessage(
ajv,
ajvError,
pointer,
property
);

return {
message: errorMessage,
Expand All @@ -171,6 +222,42 @@ class JsonSchemaValidator {
};
});
}

getErrorProperty(ajvError) {
switch (ajvError.keyword) {
case 'required':
return ajvError.params.missingProperty;
case 'additionalProperties':
return ajvError.params.additionalProperty;
default:
return null;
}
}

/**
* @deprecate
* Coerces AJV validation error message to the error message
* previously produced by Amanda/TV4 for backward-compatibility.
*/
getBackwardCompatibleErrorMessage(ajv, ajvError, pointer, property) {
const { keyword, data, params } = ajvError;

switch (keyword) {
case 'type':
return `At '${pointer}' Invalid type: ${typeof data} (expected ${
params.type
})`;

case 'required':
return `At '${pointer}' Missing required property: ${last(property)}`;

case 'enum':
return `At '${pointer}' No enum match for: "${data}"`;

default:
return ajv.errorsText([ajvError]);
}
}
}

module.exports = {
Expand Down
Loading

0 comments on commit 888b34d

Please sign in to comment.