chore: initial clean commit without large binaries

This commit is contained in:
2026-05-06 17:24:09 +02:00
parent 2f2cbed5bd
commit 8a4ed88b93
16624 changed files with 2650353 additions and 8 deletions
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 Apideck
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,71 @@
[![npm (scoped)](https://img.shields.io/npm/v/@apideck/better-ajv-errors?color=brightgreen)](https://npmjs.com/@apideck/better-ajv-errors) [![npm](https://img.shields.io/npm/dm/@apideck/better-ajv-errors)](https://npmjs.com/@apideck/better-ajv-errors) [![GitHub Workflow Status](https://img.shields.io/github/workflow/status/apideck-libraries/better-ajv-errors/CI)](https://github.com/apideck-libraries/better-ajv-errors/actions/workflows/main.yml?query=branch%3Amain++)
# @apideck/better-ajv-errors 👮‍♀️
> Human-friendly JSON Schema validation for APIs
- Readable and helpful [ajv](https://github.com/ajv-validator/ajv) errors
- API-friendly format
- Suggestions for spelling mistakes
- Minimal footprint: 1.56 kB (gzip + minified)
![better-ajv-errors output Example](https://user-images.githubusercontent.com/8850410/118274790-e0529e80-b4c5-11eb-8188-9097c8064c61.png)
## Install
```bash
$ yarn add @apideck/better-ajv-errors
```
or
```bash
$ npm i @apideck/better-ajv-errors
```
Also make sure that you've installed [ajv](https://www.npmjs.com/package/ajv) at version 8 or higher.
## Usage
After validating some data with ajv, pass the errors to `betterAjvErrors`
```ts
import Ajv from 'ajv';
import { betterAjvErrors } from '@apideck/better-ajv-errors';
// Without allErrors: true, ajv will only return the first error
const ajv = new Ajv({ allErrors: true });
const valid = ajv.validate(schema, data);
if (!valid) {
const betterErrors = betterAjvErrors({ schema, data, errors: ajv.errors });
}
```
## API
### betterAjvErrors
Function that formats ajv validation errors in a human-friendly format.
#### Parameters
- `options: BetterAjvErrorsOptions`
- `errors: ErrorObject[] | null | undefined` Your ajv errors, you will find these in the `errors` property of your ajv instance (`ErrorObject` is a type from the ajv package).
- `data: Object` The data you passed to ajv to be validated.
- `schema: JSONSchema` The schema you passed to ajv to validate against.
- `basePath?: string` An optional base path to prefix paths returned by `betterAjvErrors`. For example, in APIs, it could be useful to use `'{requestBody}'` or `'{queryParemeters}'` as a basePath. This will make it clear to users where exactly the error occurred.
#### Return Value
- `ValidationError[]` Array of formatted errors (properties of `ValidationError` below)
- `message: string` Formatted error message
- `suggestion?: string` Optional suggestion based on provided data and schema
- `path: string` Object path where the error occurred (example: `.foo.bar.0.quz`)
- `context: { errorType: DefinedError['keyword']; [additionalContext: string]: unknown }` `errorType` is `error.keyword` proxied from `ajv`. `errorType` can be used as a key for i18n if needed. There might be additional properties on context, based on the type of error.
## Related
- [atlassian/better-ajv-errors](https://github.com/atlassian/better-ajv-errors) was the inspiration for this library. Atlassian's library is more focused on CLI errors, this library is focused on developer-friendly API error messages.
@@ -0,0 +1,246 @@
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; }
var leven = _interopDefault(require('leven'));
var pointer = _interopDefault(require('jsonpointer'));
function _extends() {
_extends = Object.assign || function (target) {
for (var i = 1; i < arguments.length; i++) {
var source = arguments[i];
for (var key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
target[key] = source[key];
}
}
}
return target;
};
return _extends.apply(this, arguments);
}
var AJV_ERROR_KEYWORD_WEIGHT_MAP = {
"enum": 1,
type: 0
};
var QUOTES_REGEX = /"/g;
var NOT_REGEX = /NOT/g;
var SLASH_REGEX = /\//g;
var filterSingleErrorPerProperty = function filterSingleErrorPerProperty(errors) {
var errorsPerProperty = errors.reduce(function (acc, error) {
var _ref, _error$params$additio, _error$params, _error$params2, _AJV_ERROR_KEYWORD_WE, _AJV_ERROR_KEYWORD_WE2;
var prop = error.instancePath + ((_ref = (_error$params$additio = (_error$params = error.params) == null ? void 0 : _error$params.additionalProperty) != null ? _error$params$additio : (_error$params2 = error.params) == null ? void 0 : _error$params2.missingProperty) != null ? _ref : '');
var existingError = acc[prop];
if (!existingError) {
acc[prop] = error;
return acc;
}
var weight = (_AJV_ERROR_KEYWORD_WE = AJV_ERROR_KEYWORD_WEIGHT_MAP[error.keyword]) != null ? _AJV_ERROR_KEYWORD_WE : 0;
var existingWeight = (_AJV_ERROR_KEYWORD_WE2 = AJV_ERROR_KEYWORD_WEIGHT_MAP[existingError.keyword]) != null ? _AJV_ERROR_KEYWORD_WE2 : 0;
if (weight > existingWeight) {
acc[prop] = error;
}
return acc;
}, {});
return Object.values(errorsPerProperty);
};
var getSuggestion = function getSuggestion(_ref) {
var value = _ref.value,
suggestions = _ref.suggestions,
_ref$format = _ref.format,
format = _ref$format === void 0 ? function (suggestion) {
return "Did you mean '" + suggestion + "'?";
} : _ref$format;
if (!value) return '';
var bestSuggestion = suggestions.reduce(function (best, current) {
var distance = leven(value, current);
if (best.distance > distance) {
return {
value: current,
distance: distance
};
}
return best;
}, {
distance: Infinity,
value: ''
});
return bestSuggestion.distance < value.length ? format(bestSuggestion.value) : '';
};
var pointerToDotNotation = function pointerToDotNotation(pointer) {
return pointer.replace(SLASH_REGEX, '.');
};
var cleanAjvMessage = function cleanAjvMessage(message) {
return message.replace(QUOTES_REGEX, "'").replace(NOT_REGEX, 'not');
};
var getLastSegment = function getLastSegment(path) {
var segments = path.split('/');
return segments.pop();
};
var safeJsonPointer = function safeJsonPointer(_ref) {
var object = _ref.object,
pnter = _ref.pnter,
fallback = _ref.fallback;
try {
return pointer.get(object, pnter);
} catch (err) {
return fallback;
}
};
var betterAjvErrors = function betterAjvErrors(_ref) {
var errors = _ref.errors,
data = _ref.data,
schema = _ref.schema,
_ref$basePath = _ref.basePath,
basePath = _ref$basePath === void 0 ? '{base}' : _ref$basePath;
if (!Array.isArray(errors) || errors.length === 0) {
return [];
}
var definedErrors = filterSingleErrorPerProperty(errors);
return definedErrors.map(function (error) {
var path = pointerToDotNotation(basePath + error.instancePath);
var prop = getLastSegment(error.instancePath);
var defaultContext = {
errorType: error.keyword
};
var defaultMessage = (prop ? "property '" + prop + "'" : path) + " " + cleanAjvMessage(error.message);
var validationError;
switch (error.keyword) {
case 'additionalProperties':
{
var additionalProp = error.params.additionalProperty;
var suggestionPointer = error.schemaPath.replace('#', '').replace('/additionalProperties', '');
var _safeJsonPointer = safeJsonPointer({
object: schema,
pnter: suggestionPointer,
fallback: {
properties: {}
}
}),
properties = _safeJsonPointer.properties;
validationError = {
message: "'" + additionalProp + "' property is not expected to be here",
suggestion: getSuggestion({
value: additionalProp,
suggestions: Object.keys(properties != null ? properties : {}),
format: function format(suggestion) {
return "Did you mean property '" + suggestion + "'?";
}
}),
path: path,
context: defaultContext
};
break;
}
case 'enum':
{
var suggestions = error.params.allowedValues.map(function (value) {
return String(value != null ? value : '');
});
var _prop = getLastSegment(error.instancePath);
var value = safeJsonPointer({
object: data,
pnter: error.instancePath,
fallback: ''
});
validationError = {
message: "'" + _prop + "' property must be equal to one of the allowed values",
suggestion: getSuggestion({
value: value,
suggestions: suggestions
}),
path: path,
context: _extends({}, defaultContext, {
allowedValues: error.params.allowedValues
})
};
break;
}
case 'type':
{
var _prop2 = getLastSegment(error.instancePath);
var type = error.params.type;
validationError = {
message: "'" + _prop2 + "' property type must be " + type,
path: path,
context: defaultContext
};
break;
}
case 'required':
{
validationError = {
message: path + " must have required property '" + error.params.missingProperty + "'",
path: path,
context: defaultContext
};
break;
}
case 'const':
{
return {
message: "'" + prop + "' property must be equal to the allowed value",
path: path,
context: _extends({}, defaultContext, {
allowedValue: error.params.allowedValue
})
};
}
default:
return {
message: defaultMessage,
path: path,
context: defaultContext
};
} // Remove empty properties
var errorEntries = Object.entries(validationError);
for (var _i = 0, _errorEntries = errorEntries; _i < _errorEntries.length; _i++) {
var _errorEntries$_i = _errorEntries[_i],
key = _errorEntries$_i[0],
_value = _errorEntries$_i[1];
if (_value === null || _value === undefined || _value === '') {
delete validationError[key];
}
}
return validationError;
});
};
exports.betterAjvErrors = betterAjvErrors;
//# sourceMappingURL=better-ajv-errors.cjs.development.js.map
@@ -0,0 +1,2 @@
"use strict";function e(e){return e&&"object"==typeof e&&"default"in e?e.default:e}Object.defineProperty(exports,"__esModule",{value:!0});var t=e(require("leven")),r=e(require("jsonpointer"));function a(){return(a=Object.assign||function(e){for(var t=1;t<arguments.length;t++){var r=arguments[t];for(var a in r)Object.prototype.hasOwnProperty.call(r,a)&&(e[a]=r[a])}return e}).apply(this,arguments)}var n={enum:1,type:0},o=/"/g,s=/NOT/g,u=/\//g,l=function(e){var r=e.value,a=e.format,n=void 0===a?function(e){return"Did you mean '"+e+"'?"}:a;if(!r)return"";var o=e.suggestions.reduce((function(e,a){var n=t(r,a);return e.distance>n?{value:a,distance:n}:e}),{distance:Infinity,value:""});return o.distance<r.length?n(o.value):""},i=function(e){return e.split("/").pop()},p=function(e){var t=e.object,a=e.pnter,n=e.fallback;try{return r.get(t,a)}catch(e){return n}};exports.betterAjvErrors=function(e){var t=e.errors,r=e.data,c=e.schema,d=e.basePath,v=void 0===d?"{base}":d;return Array.isArray(t)&&0!==t.length?function(e){var t=e.reduce((function(e,t){var r,a,o,s,u,l,i=t.instancePath+(null!=(r=null!=(a=null==(o=t.params)?void 0:o.additionalProperty)?a:null==(s=t.params)?void 0:s.missingProperty)?r:""),p=e[i];return p?((null!=(u=n[t.keyword])?u:0)>(null!=(l=n[p.keyword])?l:0)&&(e[i]=t),e):(e[i]=t,e)}),{});return Object.values(t)}(t).map((function(e){var t,n=function(e){return e.replace(u,".")}(v+e.instancePath),d=i(e.instancePath),y={errorType:e.keyword},f=(d?"property '"+d+"'":n)+" "+e.message.replace(o,"'").replace(s,"not");switch(e.keyword){case"additionalProperties":var m=e.params.additionalProperty,g=e.schemaPath.replace("#","").replace("/additionalProperties",""),h=p({object:c,pnter:g,fallback:{properties:{}}}).properties;t={message:"'"+m+"' property is not expected to be here",suggestion:l({value:m,suggestions:Object.keys(null!=h?h:{}),format:function(e){return"Did you mean property '"+e+"'?"}}),path:n,context:y};break;case"enum":var b=e.params.allowedValues.map((function(e){return String(null!=e?e:"")})),P=i(e.instancePath),w=p({object:r,pnter:e.instancePath,fallback:""});t={message:"'"+P+"' property must be equal to one of the allowed values",suggestion:l({value:w,suggestions:b}),path:n,context:a({},y,{allowedValues:e.params.allowedValues})};break;case"type":t={message:"'"+i(e.instancePath)+"' property type must be "+e.params.type,path:n,context:y};break;case"required":t={message:n+" must have required property '"+e.params.missingProperty+"'",path:n,context:y};break;case"const":return{message:"'"+d+"' property must be equal to the allowed value",path:n,context:a({},y,{allowedValue:e.params.allowedValue})};default:return{message:f,path:n,context:y}}for(var j=0,k=Object.entries(t);j<k.length;j++){var x=k[j],O=x[1];null!=O&&""!==O||delete t[x[0]]}return t})):[]};
//# sourceMappingURL=better-ajv-errors.cjs.production.min.js.map
@@ -0,0 +1,240 @@
import leven from 'leven';
import pointer from 'jsonpointer';
function _extends() {
_extends = Object.assign || function (target) {
for (var i = 1; i < arguments.length; i++) {
var source = arguments[i];
for (var key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
target[key] = source[key];
}
}
}
return target;
};
return _extends.apply(this, arguments);
}
var AJV_ERROR_KEYWORD_WEIGHT_MAP = {
"enum": 1,
type: 0
};
var QUOTES_REGEX = /"/g;
var NOT_REGEX = /NOT/g;
var SLASH_REGEX = /\//g;
var filterSingleErrorPerProperty = function filterSingleErrorPerProperty(errors) {
var errorsPerProperty = errors.reduce(function (acc, error) {
var _ref, _error$params$additio, _error$params, _error$params2, _AJV_ERROR_KEYWORD_WE, _AJV_ERROR_KEYWORD_WE2;
var prop = error.instancePath + ((_ref = (_error$params$additio = (_error$params = error.params) == null ? void 0 : _error$params.additionalProperty) != null ? _error$params$additio : (_error$params2 = error.params) == null ? void 0 : _error$params2.missingProperty) != null ? _ref : '');
var existingError = acc[prop];
if (!existingError) {
acc[prop] = error;
return acc;
}
var weight = (_AJV_ERROR_KEYWORD_WE = AJV_ERROR_KEYWORD_WEIGHT_MAP[error.keyword]) != null ? _AJV_ERROR_KEYWORD_WE : 0;
var existingWeight = (_AJV_ERROR_KEYWORD_WE2 = AJV_ERROR_KEYWORD_WEIGHT_MAP[existingError.keyword]) != null ? _AJV_ERROR_KEYWORD_WE2 : 0;
if (weight > existingWeight) {
acc[prop] = error;
}
return acc;
}, {});
return Object.values(errorsPerProperty);
};
var getSuggestion = function getSuggestion(_ref) {
var value = _ref.value,
suggestions = _ref.suggestions,
_ref$format = _ref.format,
format = _ref$format === void 0 ? function (suggestion) {
return "Did you mean '" + suggestion + "'?";
} : _ref$format;
if (!value) return '';
var bestSuggestion = suggestions.reduce(function (best, current) {
var distance = leven(value, current);
if (best.distance > distance) {
return {
value: current,
distance: distance
};
}
return best;
}, {
distance: Infinity,
value: ''
});
return bestSuggestion.distance < value.length ? format(bestSuggestion.value) : '';
};
var pointerToDotNotation = function pointerToDotNotation(pointer) {
return pointer.replace(SLASH_REGEX, '.');
};
var cleanAjvMessage = function cleanAjvMessage(message) {
return message.replace(QUOTES_REGEX, "'").replace(NOT_REGEX, 'not');
};
var getLastSegment = function getLastSegment(path) {
var segments = path.split('/');
return segments.pop();
};
var safeJsonPointer = function safeJsonPointer(_ref) {
var object = _ref.object,
pnter = _ref.pnter,
fallback = _ref.fallback;
try {
return pointer.get(object, pnter);
} catch (err) {
return fallback;
}
};
var betterAjvErrors = function betterAjvErrors(_ref) {
var errors = _ref.errors,
data = _ref.data,
schema = _ref.schema,
_ref$basePath = _ref.basePath,
basePath = _ref$basePath === void 0 ? '{base}' : _ref$basePath;
if (!Array.isArray(errors) || errors.length === 0) {
return [];
}
var definedErrors = filterSingleErrorPerProperty(errors);
return definedErrors.map(function (error) {
var path = pointerToDotNotation(basePath + error.instancePath);
var prop = getLastSegment(error.instancePath);
var defaultContext = {
errorType: error.keyword
};
var defaultMessage = (prop ? "property '" + prop + "'" : path) + " " + cleanAjvMessage(error.message);
var validationError;
switch (error.keyword) {
case 'additionalProperties':
{
var additionalProp = error.params.additionalProperty;
var suggestionPointer = error.schemaPath.replace('#', '').replace('/additionalProperties', '');
var _safeJsonPointer = safeJsonPointer({
object: schema,
pnter: suggestionPointer,
fallback: {
properties: {}
}
}),
properties = _safeJsonPointer.properties;
validationError = {
message: "'" + additionalProp + "' property is not expected to be here",
suggestion: getSuggestion({
value: additionalProp,
suggestions: Object.keys(properties != null ? properties : {}),
format: function format(suggestion) {
return "Did you mean property '" + suggestion + "'?";
}
}),
path: path,
context: defaultContext
};
break;
}
case 'enum':
{
var suggestions = error.params.allowedValues.map(function (value) {
return String(value != null ? value : '');
});
var _prop = getLastSegment(error.instancePath);
var value = safeJsonPointer({
object: data,
pnter: error.instancePath,
fallback: ''
});
validationError = {
message: "'" + _prop + "' property must be equal to one of the allowed values",
suggestion: getSuggestion({
value: value,
suggestions: suggestions
}),
path: path,
context: _extends({}, defaultContext, {
allowedValues: error.params.allowedValues
})
};
break;
}
case 'type':
{
var _prop2 = getLastSegment(error.instancePath);
var type = error.params.type;
validationError = {
message: "'" + _prop2 + "' property type must be " + type,
path: path,
context: defaultContext
};
break;
}
case 'required':
{
validationError = {
message: path + " must have required property '" + error.params.missingProperty + "'",
path: path,
context: defaultContext
};
break;
}
case 'const':
{
return {
message: "'" + prop + "' property must be equal to the allowed value",
path: path,
context: _extends({}, defaultContext, {
allowedValue: error.params.allowedValue
})
};
}
default:
return {
message: defaultMessage,
path: path,
context: defaultContext
};
} // Remove empty properties
var errorEntries = Object.entries(validationError);
for (var _i = 0, _errorEntries = errorEntries; _i < _errorEntries.length; _i++) {
var _errorEntries$_i = _errorEntries[_i],
key = _errorEntries$_i[0],
_value = _errorEntries$_i[1];
if (_value === null || _value === undefined || _value === '') {
delete validationError[key];
}
}
return validationError;
});
};
export { betterAjvErrors };
//# sourceMappingURL=better-ajv-errors.esm.js.map
@@ -0,0 +1,5 @@
import { DefinedError } from 'ajv';
export declare const AJV_ERROR_KEYWORD_WEIGHT_MAP: Partial<Record<DefinedError['keyword'], number>>;
export declare const QUOTES_REGEX: RegExp;
export declare const NOT_REGEX: RegExp;
export declare const SLASH_REGEX: RegExp;
@@ -0,0 +1,10 @@
import { ErrorObject } from 'ajv';
import { ValidationError } from './types/ValidationError';
export interface BetterAjvErrorsOptions<S = any> {
errors: ErrorObject[] | null | undefined;
data: any;
schema: S;
basePath?: string;
}
export declare const betterAjvErrors: <S = any>({ errors, data, schema, basePath, }: BetterAjvErrorsOptions<S>) => ValidationError[];
export { ValidationError };
@@ -0,0 +1,8 @@
'use strict'
if (process.env.NODE_ENV === 'production') {
module.exports = require('./better-ajv-errors.cjs.production.min.js')
} else {
module.exports = require('./better-ajv-errors.cjs.development.js')
}
@@ -0,0 +1,2 @@
import { DefinedError } from 'ajv';
export declare const filterSingleErrorPerProperty: (errors: DefinedError[]) => DefinedError[];
@@ -0,0 +1,5 @@
export declare const getSuggestion: ({ value, suggestions, format, }: {
value: string | null;
suggestions: string[];
format?: ((suggestion: string) => string) | undefined;
}) => string;
@@ -0,0 +1,8 @@
export declare const pointerToDotNotation: (pointer: string) => string;
export declare const cleanAjvMessage: (message: string) => string;
export declare const getLastSegment: (path: string) => string;
export declare const safeJsonPointer: <T>({ object, pnter, fallback }: {
object: any;
pnter: string;
fallback: T;
}) => T;
@@ -0,0 +1,10 @@
import { DefinedError } from 'ajv';
export interface ValidationError {
message: string;
path: string;
suggestion?: string;
context: {
errorType: DefinedError['keyword'];
[additionalContext: string]: unknown;
};
}
@@ -0,0 +1,88 @@
{
"name": "@apideck/better-ajv-errors",
"description": "Human-friendly JSON Schema validation for APIs",
"version": "0.3.7",
"author": "Apideck <support@apideck.com> (https://apideck.com/)",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/apideck-libraries/better-ajv-errors"
},
"bugs": {
"url": "https://github.com/apideck-libraries/better-ajv-errors/issues"
},
"contributors": [
"Elias Meire <elias@apideck.com>"
],
"main": "dist/index.js",
"module": "dist/better-ajv-errors.esm.js",
"typings": "dist/index.d.ts",
"files": [
"dist",
"src"
],
"engines": {
"node": ">=10"
},
"scripts": {
"start": "tsdx watch",
"build": "tsdx build",
"test": "tsdx test",
"lint": "tsdx lint",
"prepare": "tsdx build",
"size": "size-limit",
"release": "np --no-publish && npm publish --access public --registry https://registry.npmjs.org",
"analyze": "size-limit --why"
},
"husky": {
"hooks": {
"pre-commit": "tsdx lint"
}
},
"prettier": {
"printWidth": 120,
"singleQuote": true,
"trailingComma": "es5"
},
"size-limit": [
{
"path": "dist/better-ajv-errors.cjs.production.min.js",
"limit": "2 KB"
},
{
"path": "dist/better-ajv-errors.esm.js",
"limit": "2.5 KB"
}
],
"devDependencies": {
"@size-limit/preset-small-lib": "^7.0.8",
"ajv": "^8.11.0",
"eslint-plugin-prettier": "^4.0.0",
"husky": "^8.0.1",
"np": "^7.6.1",
"size-limit": "^7.0.8",
"tsdx": "^0.14.1",
"json-schema": "^0.4.0",
"tslib": "^2.4.0",
"typescript": "^4.7.2"
},
"peerDependencies": {
"ajv": ">=8"
},
"dependencies": {
"jsonpointer": "^5.0.1",
"leven": "^3.1.0"
},
"resolutions": {
"prettier": "^2.3.0"
},
"keywords": [
"apideck",
"ajv",
"json",
"schema",
"json-schema",
"errors",
"human"
]
}
@@ -0,0 +1,10 @@
import { DefinedError } from 'ajv';
export const AJV_ERROR_KEYWORD_WEIGHT_MAP: Partial<Record<DefinedError['keyword'], number>> = {
enum: 1,
type: 0,
};
export const QUOTES_REGEX = /"/g;
export const NOT_REGEX = /NOT/g;
export const SLASH_REGEX = /\//g;
@@ -0,0 +1,484 @@
import Ajv from 'ajv';
import { JSONSchema6 } from 'json-schema';
import { betterAjvErrors } from './index';
describe('betterAjvErrors', () => {
let ajv: Ajv;
let schema: JSONSchema6;
let data: Record<string, unknown>;
beforeEach(() => {
ajv = new Ajv({ allErrors: true });
schema = {
type: 'object',
required: ['str'],
properties: {
str: {
type: 'string',
},
enum: {
type: 'string',
enum: ['one', 'two'],
},
bounds: {
type: 'number',
minimum: 2,
maximum: 4,
},
nested: {
type: 'object',
required: ['deepReq'],
properties: {
deepReq: {
type: 'boolean',
},
deep: {
type: 'string',
},
},
additionalProperties: false,
},
},
additionalProperties: false,
};
});
describe('combined schemas', () => {
it('should handle type errors', () => {
data = {
str: 123,
};
const combinedSchema = {type: 'boolean'};
const validateCombined = ajv.addSchema(combinedSchema).compile(schema);
validateCombined(data);
const betterErrors = betterAjvErrors({ data, schema, errors: validateCombined.errors });
expect(betterErrors).toEqual([
{
context: {
errorType: 'type',
},
message: "'str' property type must be string",
path: '{base}.str',
},
]);
});
});
describe('additionalProperties', () => {
it('should handle additionalProperties=false', () => {
data = {
str: 'str',
foo: 'bar',
};
ajv.validate(schema, data);
const errors = betterAjvErrors({ data, schema, errors: ajv.errors });
expect(errors).toEqual([
{
context: {
errorType: 'additionalProperties',
},
message: "'foo' property is not expected to be here",
path: '{base}',
},
]);
});
it('should handle additionalProperties=true', () => {
data = {
str: 'str',
foo: 'bar',
};
schema.additionalProperties = true;
ajv.validate(schema, data);
const errors = betterAjvErrors({ data, schema, errors: ajv.errors });
expect(errors).toEqual([]);
});
it('should give suggestions when relevant', () => {
data = {
str: 'str',
bonds: 'bar',
};
ajv.validate(schema, data);
const errors = betterAjvErrors({ data, schema, errors: ajv.errors });
expect(errors).toEqual([
{
context: {
errorType: 'additionalProperties',
},
message: "'bonds' property is not expected to be here",
path: '{base}',
suggestion: "Did you mean property 'bounds'?",
},
]);
});
it('should handle object schemas without properties', () => {
data = {
empty: { foo: 1 },
};
schema = {
type: 'object',
properties: {
empty: {
type: 'object',
additionalProperties: false,
},
},
};
ajv.validate(schema, data);
const errors = betterAjvErrors({ data, schema, errors: ajv.errors });
expect(errors).toEqual([
{
context: {
errorType: 'additionalProperties',
},
message: "'foo' property is not expected to be here",
path: '{base}.empty',
},
]);
});
});
describe('required', () => {
it('should handle required properties', () => {
data = {
nested: {},
};
ajv.validate(schema, data);
const errors = betterAjvErrors({ data, schema, errors: ajv.errors });
expect(errors).toEqual([
{
context: {
errorType: 'required',
},
message: "{base} must have required property 'str'",
path: '{base}',
},
{
context: {
errorType: 'required',
},
message: "{base}.nested must have required property 'deepReq'",
path: '{base}.nested',
},
]);
});
it('should handle multiple required properties', () => {
schema = {
type: 'object',
required: ['req1', 'req2'],
properties: {
req1: {
type: 'string',
},
req2: {
type: 'string',
},
},
};
data = {};
ajv.validate(schema, data);
const errors = betterAjvErrors({ data, schema, errors: ajv.errors });
expect(errors).toEqual([
{
context: {
errorType: 'required',
},
message: "{base} must have required property 'req1'",
path: '{base}',
},
{
context: {
errorType: 'required',
},
message: "{base} must have required property 'req2'",
path: '{base}',
},
]);
});
});
describe('type', () => {
it('should handle type errors', () => {
data = {
str: 123,
};
ajv.validate(schema, data);
const errors = betterAjvErrors({ data, schema, errors: ajv.errors });
expect(errors).toEqual([
{
context: {
errorType: 'type',
},
message: "'str' property type must be string",
path: '{base}.str',
},
]);
});
});
describe('minimum/maximum', () => {
it('should handle minimum/maximum errors', () => {
data = {
str: 'str',
bounds: 123,
};
ajv.validate(schema, data);
const errors = betterAjvErrors({ data, schema, errors: ajv.errors });
expect(errors).toEqual([
{
context: {
errorType: 'maximum',
},
message: "property 'bounds' must be <= 4",
path: '{base}.bounds',
},
]);
});
});
describe('enum', () => {
it('should handle enum errors', () => {
data = {
str: 'str',
enum: 'zzzz',
};
ajv.validate(schema, data);
const errors = betterAjvErrors({ data, schema, errors: ajv.errors });
expect(errors).toEqual([
{
context: {
errorType: 'enum',
allowedValues: ['one', 'two'],
},
message: "'enum' property must be equal to one of the allowed values",
path: '{base}.enum',
},
]);
});
it('should provide suggestions when relevant', () => {
data = {
str: 'str',
enum: 'pne',
};
ajv.validate(schema, data);
const errors = betterAjvErrors({ data, schema, errors: ajv.errors });
expect(errors).toEqual([
{
context: {
errorType: 'enum',
allowedValues: ['one', 'two'],
},
message: "'enum' property must be equal to one of the allowed values",
path: '{base}.enum',
suggestion: "Did you mean 'one'?",
},
]);
});
it('should not crash when allowedValues contains null', () => {
data = {
str: 'str',
enum: 'invalid',
};
schema = {
type: 'object',
properties: {
str: { type: 'string' },
enum: {
type: ['string', 'null'],
enum: ['one', 'two', null],
},
},
};
ajv.validate(schema, data);
const errors = betterAjvErrors({ data, schema, errors: ajv.errors });
expect(errors).toEqual([
{
context: {
errorType: 'enum',
allowedValues: ['one', 'two', null],
},
message: "'enum' property must be equal to one of the allowed values",
path: '{base}.enum',
suggestion: "Did you mean 'one'?",
},
]);
});
it('should not crash on null value', () => {
data = {
type: null,
};
schema = {
type: 'object',
properties: {
type: {
type: 'string',
enum: ['primary', 'secondary'],
},
},
};
ajv.validate(schema, data);
const errors = betterAjvErrors({ data, schema, errors: ajv.errors });
expect(errors).toEqual([
{
context: {
allowedValues: ['primary', 'secondary'],
errorType: 'enum',
},
message: "'type' property must be equal to one of the allowed values",
path: '{base}.type',
},
]);
});
});
it('should handle array paths', () => {
data = {
custom: [{ foo: 'bar' }, { aaa: 'zzz' }],
};
schema = {
type: 'object',
properties: {
custom: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
properties: {
id: {
type: 'string',
},
title: {
type: 'string',
},
},
},
},
},
};
ajv.validate(schema, data);
const errors = betterAjvErrors({ data, schema, errors: ajv.errors });
expect(errors).toEqual([
{
context: {
errorType: 'additionalProperties',
},
message: "'foo' property is not expected to be here",
path: '{base}.custom.0',
},
{
context: {
errorType: 'additionalProperties',
},
message: "'aaa' property is not expected to be here",
path: '{base}.custom.1',
},
]);
});
it('should handle file $refs', () => {
data = {
child: [{ foo: 'bar' }, { aaa: 'zzz' }],
};
schema = {
$id: 'http://example.com/schemas/Main.json',
type: 'object',
properties: {
child: {
type: 'array',
items: {
$ref: './Child.json',
},
},
},
};
ajv.addSchema({
$id: 'http://example.com/schemas/Child.json',
additionalProperties: false,
type: 'object',
properties: {
id: {
type: 'string',
},
},
});
ajv.validate(schema, data);
const errors = betterAjvErrors({ data, schema, errors: ajv.errors });
expect(errors).toEqual([
{
context: {
errorType: 'additionalProperties',
},
message: "'foo' property is not expected to be here",
path: '{base}.child.0',
},
{
context: {
errorType: 'additionalProperties',
},
message: "'aaa' property is not expected to be here",
path: '{base}.child.1',
},
]);
});
it('should handle number enums', () => {
data = {
isLive: 2,
};
schema = {
type: 'object',
properties: {
isLive: {
type: 'integer',
enum: [0, 1],
},
},
};
ajv.validate(schema, data);
const errors = betterAjvErrors({ data, schema, errors: ajv.errors });
expect(errors).toEqual([
{
context: {
allowedValues: [0, 1],
errorType: 'enum',
},
message: "'isLive' property must be equal to one of the allowed values",
path: '{base}.isLive',
},
]);
});
describe('const', () => {
it('should handle const errors', () => {
data = {
const: 2,
};
schema = {
type: 'object',
properties: {
const: {
type: 'integer',
const: 42,
},
},
};
ajv.validate(schema, data);
const errors = betterAjvErrors({ data, schema, errors: ajv.errors });
expect(errors).toEqual([
{
context: {
allowedValue: 42,
errorType: 'const',
},
message: "'const' property must be equal to the allowed value",
path: '{base}.const',
},
]);
});
});
});
@@ -0,0 +1,120 @@
import { DefinedError, ErrorObject } from 'ajv';
import { ValidationError } from './types/ValidationError';
import { filterSingleErrorPerProperty } from './lib/filter';
import { getSuggestion } from './lib/suggestions';
import { cleanAjvMessage, getLastSegment, pointerToDotNotation, safeJsonPointer } from './lib/utils';
export interface BetterAjvErrorsOptions<S = any> {
errors: ErrorObject[] | null | undefined;
data: any;
schema: S;
basePath?: string;
}
export const betterAjvErrors = <S = any>({
errors,
data,
schema,
basePath = '{base}',
}: BetterAjvErrorsOptions<S>): ValidationError[] => {
if (!Array.isArray(errors) || errors.length === 0) {
return [];
}
const definedErrors = filterSingleErrorPerProperty(errors as DefinedError[]);
return definedErrors.map((error) => {
const path = pointerToDotNotation(basePath + error.instancePath);
const prop = getLastSegment(error.instancePath);
const defaultContext = {
errorType: error.keyword,
};
const defaultMessage = `${prop ? `property '${prop}'` : path} ${cleanAjvMessage(error.message as string)}`;
let validationError: ValidationError;
switch (error.keyword) {
case 'additionalProperties': {
const additionalProp = error.params.additionalProperty;
const suggestionPointer = error.schemaPath.replace('#', '').replace('/additionalProperties', '');
const { properties } = safeJsonPointer({
object: schema,
pnter: suggestionPointer,
fallback: { properties: {} },
});
validationError = {
message: `'${additionalProp}' property is not expected to be here`,
suggestion: getSuggestion({
value: additionalProp,
suggestions: Object.keys(properties ?? {}),
format: (suggestion) => `Did you mean property '${suggestion}'?`,
}),
path,
context: defaultContext,
};
break;
}
case 'enum': {
const suggestions = error.params.allowedValues.map((value) => String(value ?? ''));
const prop = getLastSegment(error.instancePath);
const value = safeJsonPointer({ object: data, pnter: error.instancePath, fallback: '' });
validationError = {
message: `'${prop}' property must be equal to one of the allowed values`,
suggestion: getSuggestion({
value,
suggestions,
}),
path,
context: {
...defaultContext,
allowedValues: error.params.allowedValues,
},
};
break;
}
case 'type': {
const prop = getLastSegment(error.instancePath);
const type = error.params.type;
validationError = {
message: `'${prop}' property type must be ${type}`,
path,
context: defaultContext,
};
break;
}
case 'required': {
validationError = {
message: `${path} must have required property '${error.params.missingProperty}'`,
path,
context: defaultContext,
};
break;
}
case 'const': {
return {
message: `'${prop}' property must be equal to the allowed value`,
path,
context: {
...defaultContext,
allowedValue: error.params.allowedValue,
},
};
}
default:
return { message: defaultMessage, path, context: defaultContext };
}
// Remove empty properties
const errorEntries = Object.entries(validationError);
for (const [key, value] of errorEntries as [keyof ValidationError, unknown][]) {
if (value === null || value === undefined || value === '') {
delete validationError[key];
}
}
return validationError;
});
};
export { ValidationError };
@@ -0,0 +1,23 @@
import { DefinedError } from 'ajv';
import { AJV_ERROR_KEYWORD_WEIGHT_MAP } from '../constants';
export const filterSingleErrorPerProperty = (errors: DefinedError[]): DefinedError[] => {
const errorsPerProperty = errors.reduce<Record<string, DefinedError>>((acc, error) => {
const prop =
error.instancePath + ((error.params as any)?.additionalProperty ?? (error.params as any)?.missingProperty ?? '');
const existingError = acc[prop];
if (!existingError) {
acc[prop] = error;
return acc;
}
const weight = AJV_ERROR_KEYWORD_WEIGHT_MAP[error.keyword] ?? 0;
const existingWeight = AJV_ERROR_KEYWORD_WEIGHT_MAP[existingError.keyword] ?? 0;
if (weight > existingWeight) {
acc[prop] = error;
}
return acc;
}, {});
return Object.values(errorsPerProperty);
};
@@ -0,0 +1,29 @@
import leven from 'leven';
export const getSuggestion = ({
value,
suggestions,
format = (suggestion) => `Did you mean '${suggestion}'?`,
}: {
value: string | null;
suggestions: string[];
format?: (suggestion: string) => string;
}): string => {
if (!value) return '';
const bestSuggestion = suggestions.reduce(
(best, current) => {
const distance = leven(value, current);
if (best.distance > distance) {
return { value: current, distance };
}
return best;
},
{
distance: Infinity,
value: '',
}
);
return bestSuggestion.distance < value.length ? format(bestSuggestion.value) : '';
};
@@ -0,0 +1,23 @@
import { NOT_REGEX, QUOTES_REGEX, SLASH_REGEX } from '../constants';
import pointer from 'jsonpointer';
export const pointerToDotNotation = (pointer: string): string => {
return pointer.replace(SLASH_REGEX, '.');
};
export const cleanAjvMessage = (message: string): string => {
return message.replace(QUOTES_REGEX, "'").replace(NOT_REGEX, 'not');
};
export const getLastSegment = (path: string): string => {
const segments = path.split('/');
return segments.pop() as string;
};
export const safeJsonPointer = <T>({ object, pnter, fallback }: { object: any; pnter: string; fallback: T }): T => {
try {
return pointer.get(object, pnter);
} catch (err) {
return fallback;
}
};
@@ -0,0 +1,11 @@
import { DefinedError } from 'ajv';
export interface ValidationError {
message: string;
path: string;
suggestion?: string;
context: {
errorType: DefinedError['keyword'];
[additionalContext: string]: unknown;
};
}
+1
View File
@@ -0,0 +1 @@
../../ajv@8.20.0/node_modules/ajv
@@ -0,0 +1 @@
../../jsonpointer@5.0.1/node_modules/jsonpointer
@@ -0,0 +1 @@
../../leven@3.1.0/node_modules/leven