This repository has been archived by the owner on Nov 8, 2024. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 22
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: adds JSON Schema Draft 6/7 support
- Loading branch information
1 parent
816b7ab
commit fda7b4e
Showing
16 changed files
with
1,157 additions
and
345 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
}; |
Oops, something went wrong.