AjvValidator.js 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286
  1. const Validator = require('./Validator');
  2. const { Type: ValidationErrorType } = require('../model/ValidationError');
  3. const { isObject, once, cloneDeep: lodashCloneDeep, omit } = require('../utils/objectUtils');
  4. const getAjv = once(() => {
  5. try {
  6. return require('ajv');
  7. } catch (err) {
  8. throw new Error('Optional ajv dependency not installed. Please run `npm install ajv --save`');
  9. }
  10. });
  11. class AjvValidator extends Validator {
  12. constructor(conf) {
  13. super();
  14. this.ajvOptions = Object.assign({ errorDataPath: 'property' }, conf.options, {
  15. allErrors: true
  16. });
  17. // Create a normal Ajv instance.
  18. this.ajv = new getAjv()(
  19. Object.assign(
  20. {
  21. useDefaults: true
  22. },
  23. this.ajvOptions
  24. )
  25. );
  26. // Create an instance that doesn't set default values. We need this one
  27. // to validate `patch` objects (objects that have a subset of properties).
  28. this.ajvNoDefaults = new getAjv()(
  29. Object.assign({}, this.ajvOptions, {
  30. useDefaults: false
  31. })
  32. );
  33. // A cache for the compiled validator functions.
  34. this.cache = new Map();
  35. conf.onCreateAjv(this.ajv);
  36. conf.onCreateAjv(this.ajvNoDefaults);
  37. }
  38. beforeValidate({ json, model, options, ctx }) {
  39. ctx.jsonSchema = model.constructor.getJsonSchema();
  40. // Objection model's have a `$beforeValidate` hook that is allowed to modify the schema.
  41. // We need to clone the schema in case the function modifies it. We only do this in the
  42. // rare case that the given model has implemented the hook.
  43. if (model.$beforeValidate !== model.$objectionModelClass.prototype.$beforeValidate) {
  44. ctx.jsonSchema = cloneDeep(ctx.jsonSchema);
  45. const ret = model.$beforeValidate(ctx.jsonSchema, json, options);
  46. if (ret !== undefined) {
  47. ctx.jsonSchema = ret;
  48. }
  49. }
  50. }
  51. validate({ json, model, options, ctx }) {
  52. if (!ctx.jsonSchema) {
  53. return json;
  54. }
  55. const modelClass = model.constructor;
  56. const validator = this.getValidator(modelClass, ctx.jsonSchema, !!options.patch);
  57. // We need to clone the input json if we are about to set default values.
  58. if (!options.mutable && !options.patch && setsDefaultValues(ctx.jsonSchema)) {
  59. json = cloneDeep(json);
  60. }
  61. validator.call(model, json);
  62. const error = parseValidationError(validator.errors, modelClass, options);
  63. if (error) {
  64. throw error;
  65. }
  66. return json;
  67. }
  68. getValidator(ModelClass, jsonSchema, isPatchObject) {
  69. // Use the AJV custom serializer if provided.
  70. const createCacheKey = this.ajvOptions.serialize || JSON.stringify;
  71. // Optimization for the common case where jsonSchema is never modified.
  72. // In that case we don't need to call the costly createCacheKey function.
  73. const cacheKey =
  74. jsonSchema === ModelClass.getJsonSchema()
  75. ? ModelClass.uniqueTag()
  76. : createCacheKey(jsonSchema);
  77. let validators = this.cache.get(cacheKey);
  78. let validator = null;
  79. if (!validators) {
  80. validators = {
  81. // Validator created for the schema object without `required` properties
  82. // using the AJV instance that doesn't set default values.
  83. patchValidator: null,
  84. // Validator created for the unmodified schema.
  85. normalValidator: null
  86. };
  87. this.cache.set(cacheKey, validators);
  88. }
  89. if (isPatchObject) {
  90. validator = validators.patchValidator;
  91. if (!validator) {
  92. validator = this.compilePatchValidator(jsonSchema);
  93. validators.patchValidator = validator;
  94. }
  95. } else {
  96. validator = validators.normalValidator;
  97. if (!validator) {
  98. validator = this.compileNormalValidator(jsonSchema);
  99. validators.normalValidator = validator;
  100. }
  101. }
  102. return validator;
  103. }
  104. compilePatchValidator(jsonSchema) {
  105. jsonSchema = jsonSchemaWithoutRequired(jsonSchema);
  106. // We need to use the ajv instance that doesn't set the default values.
  107. return this.ajvNoDefaults.compile(jsonSchema);
  108. }
  109. compileNormalValidator(jsonSchema) {
  110. return this.ajv.compile(jsonSchema);
  111. }
  112. }
  113. function parseValidationError(errors, modelClass, options) {
  114. if (!errors) {
  115. return null;
  116. }
  117. let relations = modelClass.getRelations();
  118. let errorHash = {};
  119. let numErrors = 0;
  120. for (let i = 0; i < errors.length; ++i) {
  121. const error = errors[i];
  122. const dataPath = `${options.dataPath || ''}${error.dataPath}`;
  123. // If additionalProperties = false, relations can pop up as additionalProperty
  124. // errors. Skip those.
  125. if (
  126. error.params &&
  127. error.params.additionalProperty &&
  128. relations[error.params.additionalProperty]
  129. ) {
  130. continue;
  131. }
  132. // Unknown properties are reported in `['propertyName']` notation,
  133. // so replace those with dot-notation, see:
  134. // https://github.com/epoberezkin/ajv/issues/671
  135. const key = dataPath.replace(/\['([^' ]*)'\]/g, '.$1').substring(1);
  136. // More than one error can occur for the same key in Ajv, merge them in the array:
  137. const array = errorHash[key] || (errorHash[key] = []);
  138. // Use unshift instead of push so that the last error ends up at [0],
  139. // preserving previous behavior where only the last error was stored.
  140. array.unshift({
  141. message: error.message,
  142. keyword: error.keyword,
  143. params: error.params
  144. });
  145. ++numErrors;
  146. }
  147. if (numErrors === 0) {
  148. return null;
  149. }
  150. return modelClass.createValidationError({
  151. type: ValidationErrorType.ModelValidation,
  152. data: errorHash
  153. });
  154. }
  155. function cloneDeep(obj) {
  156. if (isObject(obj) && obj.$isObjectionModel) {
  157. return obj.$clone();
  158. } else {
  159. return lodashCloneDeep(obj);
  160. }
  161. }
  162. function setsDefaultValues(jsonSchema) {
  163. return jsonSchema && jsonSchema.properties && hasDefaults(jsonSchema.properties);
  164. }
  165. function hasDefaults(obj) {
  166. if (Array.isArray(obj)) {
  167. return arrayHasDefaults(obj);
  168. } else {
  169. return objectHasDefaults(obj);
  170. }
  171. }
  172. function arrayHasDefaults(arr) {
  173. for (let i = 0, l = arr.length; i < l; ++i) {
  174. const val = arr[i];
  175. if (isObject(val) && hasDefaults(val)) {
  176. return true;
  177. }
  178. }
  179. return false;
  180. }
  181. function objectHasDefaults(obj) {
  182. const keys = Object.keys(obj);
  183. for (let i = 0, l = keys.length; i < l; ++i) {
  184. const key = keys[i];
  185. if (key === 'default') {
  186. return true;
  187. } else {
  188. const val = obj[key];
  189. if (isObject(val) && hasDefaults(val)) {
  190. return true;
  191. }
  192. }
  193. }
  194. return false;
  195. }
  196. function jsonSchemaWithoutRequired(jsonSchema) {
  197. const subSchemaProps = ['anyOf', 'oneOf', 'allOf', 'not', 'then', 'else'];
  198. return Object.assign(
  199. omit(jsonSchema, ['required', ...subSchemaProps]),
  200. ...subSchemaProps.map(prop => subSchemaWithoutRequired(jsonSchema, prop))
  201. );
  202. }
  203. function subSchemaWithoutRequired(jsonSchema, prop) {
  204. if (jsonSchema[prop]) {
  205. if (Array.isArray(jsonSchema[prop])) {
  206. const schemaArray = jsonSchemaArrayWithoutRequired(jsonSchema[prop]);
  207. if (schemaArray.length !== 0) {
  208. return {
  209. [prop]: schemaArray
  210. };
  211. } else {
  212. return {};
  213. }
  214. } else {
  215. return {
  216. [prop]: jsonSchemaWithoutRequired(jsonSchema[prop])
  217. };
  218. }
  219. } else {
  220. return {};
  221. }
  222. }
  223. function jsonSchemaArrayWithoutRequired(jsonSchemaArray) {
  224. return jsonSchemaArray.map(jsonSchemaWithoutRequired).filter(isNotEmptyObject);
  225. }
  226. function isNotEmptyObject(obj) {
  227. return Object.keys(obj).length !== 0;
  228. }
  229. module.exports = AjvValidator;