diff --git a/package.json b/package.json index 133b1c0..0f713fd 100644 --- a/package.json +++ b/package.json @@ -27,12 +27,20 @@ "vitest": "^1.2.2" }, "dependencies": { + "@babel/code-frame": "^7.23.5", + "@babel/runtime": "^7.23.9", + "@humanwhocodes/momoa": "^3.0.0", + "@readme/better-ajv-errors": "^1.6.0", "@types/node": "^20.11.10", "ajv": "^8.12.0", "ajv-draft-04": "^1.0.0", "ajv-formats": "^2.1.1", + "chalk": "^5.3.0", "glob": "^10.3.10", "js-yaml": "^4.1.0", + "json-to-ast": "^2.1.0", + "jsonpointer": "^5.0.1", + "leven": "^4.0.0", "openapi-types": "^12.1.3", "vite": "^5.0.12", "vite-node": "^1.2.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9021f0..e709e69 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,18 @@ settings: excludeLinksFromLockfile: false dependencies: + '@babel/code-frame': + specifier: ^7.23.5 + version: 7.23.5 + '@babel/runtime': + specifier: ^7.23.9 + version: 7.23.9 + '@humanwhocodes/momoa': + specifier: ^3.0.0 + version: 3.0.0 + '@readme/better-ajv-errors': + specifier: ^1.6.0 + version: 1.6.0(ajv@8.12.0) '@types/node': specifier: ^20.11.10 version: 20.11.13 @@ -17,12 +29,24 @@ dependencies: ajv-formats: specifier: ^2.1.1 version: 2.1.1(ajv@8.12.0) + chalk: + specifier: ^5.3.0 + version: 5.3.0 glob: specifier: ^10.3.10 version: 10.3.10 js-yaml: specifier: ^4.1.0 version: 4.1.0 + json-to-ast: + specifier: ^2.1.0 + version: 2.1.0 + jsonpointer: + specifier: ^5.0.1 + version: 5.0.1 + leven: + specifier: ^4.0.0 + version: 4.0.0 openapi-types: specifier: ^12.1.3 version: 12.1.3 @@ -66,6 +90,14 @@ packages: '@jridgewell/trace-mapping': 0.3.22 dev: true + /@babel/code-frame@7.23.5: + resolution: {integrity: sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/highlight': 7.23.4 + chalk: 2.4.2 + dev: false + /@babel/helper-string-parser@7.23.4: resolution: {integrity: sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==} engines: {node: '>=6.9.0'} @@ -74,7 +106,15 @@ packages: /@babel/helper-validator-identifier@7.22.20: resolution: {integrity: sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==} engines: {node: '>=6.9.0'} - dev: true + + /@babel/highlight@7.23.4: + resolution: {integrity: sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-validator-identifier': 7.22.20 + chalk: 2.4.2 + js-tokens: 4.0.0 + dev: false /@babel/parser@7.23.9: resolution: {integrity: sha512-9tcKgqKbs3xGJ+NtKF2ndOBBLVwPjl1SHxPQkd36r3Dlirw3xWUeGaTbqr7uGZcTaxkVNwc+03SVP7aCdWrTlA==} @@ -84,6 +124,13 @@ packages: '@babel/types': 7.23.9 dev: true + /@babel/runtime@7.23.9: + resolution: {integrity: sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==} + engines: {node: '>=6.9.0'} + dependencies: + regenerator-runtime: 0.14.1 + dev: false + /@babel/types@7.23.9: resolution: {integrity: sha512-dQjSq/7HaSjRM43FFGnv5keM2HsxpmyV1PfaSVm0nzzjwwTmjOe6J4bC8e3+pTEIgHaHj+1ZlLThRJ2auc/w1Q==} engines: {node: '>=6.9.0'} @@ -369,6 +416,16 @@ packages: requiresBuild: true optional: true + /@humanwhocodes/momoa@2.0.4: + resolution: {integrity: sha512-RE815I4arJFtt+FVeU1Tgp9/Xvecacji8w/V6XtXsWWH/wz/eNkNbhb+ny/+PlVZjV0rxQpRSQKNKE3lcktHEA==} + engines: {node: '>=10.10.0'} + dev: false + + /@humanwhocodes/momoa@3.0.0: + resolution: {integrity: sha512-w9ZDr0lxELbpdzQPmNpYxKHCq4IrG9ZaGFMmzvlbk4zA7+t/zSONCqrp8Tzg17vo2ZJlkL1jS87codK0W5uovQ==} + engines: {node: '>=18'} + dev: false + /@isaacs/cliui@8.0.2: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -430,6 +487,22 @@ packages: dev: false optional: true + /@readme/better-ajv-errors@1.6.0(ajv@8.12.0): + resolution: {integrity: sha512-9gO9rld84Jgu13kcbKRU+WHseNhaVt76wYMeRDGsUGYxwJtI3RmEJ9LY9dZCYQGI8eUZLuxb5qDja0nqklpFjQ==} + engines: {node: '>=14'} + peerDependencies: + ajv: 4.11.8 - 8 + dependencies: + '@babel/code-frame': 7.23.5 + '@babel/runtime': 7.23.9 + '@humanwhocodes/momoa': 2.0.4 + ajv: 8.12.0 + chalk: 4.1.2 + json-to-ast: 2.1.0 + jsonpointer: 5.0.1 + leven: 3.1.0 + dev: false + /@rollup/plugin-inject@5.0.5: resolution: {integrity: sha512-2+DEJbNBoPROPkgTDNe8/1YXWcqxbN5DTjASVIOx8HS+pITXushyNiBV56RB08zuptzz8gT3YfkqriTBVycepg==} engines: {node: '>=14.0.0'} @@ -683,6 +756,13 @@ packages: engines: {node: '>=12'} dev: false + /ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + dependencies: + color-convert: 1.9.3 + dev: false + /ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -866,6 +946,28 @@ packages: type-detect: 4.0.8 dev: true + /chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + dev: false + + /chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + dev: false + + /chalk@5.3.0: + resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + dev: false + /check-error@1.0.3: resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} dependencies: @@ -879,6 +981,17 @@ packages: safe-buffer: 5.2.1 dev: true + /code-error-fragment@0.0.230: + resolution: {integrity: sha512-cadkfKp6932H8UkhzE/gcUqhRMNf8jHzkAN7+5Myabswaghu4xABTgPHDCjW+dBAJxj/SpkTYokpzDqY4pCzQw==} + engines: {node: '>= 4'} + dev: false + + /color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + dependencies: + color-name: 1.1.3 + dev: false + /color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -886,6 +999,10 @@ packages: color-name: 1.1.4 dev: false + /color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + dev: false + /color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} dev: false @@ -1077,6 +1194,11 @@ packages: '@esbuild/win32-ia32': 0.19.12 '@esbuild/win32-x64': 0.19.12 + /escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + dev: false + /estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} dev: true @@ -1206,10 +1328,18 @@ packages: get-intrinsic: 1.2.2 dev: true + /grapheme-splitter@1.0.4: + resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==} + dev: false + + /has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + dev: false + /has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} - dev: true /has-property-descriptors@1.0.1: resolution: {integrity: sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==} @@ -1394,6 +1524,10 @@ packages: '@pkgjs/parseargs': 0.11.0 dev: false + /js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + dev: false + /js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true @@ -1405,10 +1539,33 @@ packages: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} dev: false + /json-to-ast@2.1.0: + resolution: {integrity: sha512-W9Lq347r8tA1DfMvAGn9QNcgYm4Wm7Yc+k8e6vezpMnRT+NHbtlxgNBXRVjXe9YM6eTn6+p/MKOlV/aABJcSnQ==} + engines: {node: '>= 4'} + dependencies: + code-error-fragment: 0.0.230 + grapheme-splitter: 1.0.4 + dev: false + /jsonc-parser@3.2.1: resolution: {integrity: sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==} dev: true + /jsonpointer@5.0.1: + resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} + engines: {node: '>=0.10.0'} + dev: false + + /leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + dev: false + + /leven@4.0.0: + resolution: {integrity: sha512-puehA3YKku3osqPlNuzGDUHq8WpwXupUg1V6NXdV38G+gr+gkBwFC8g1b/+YcIvp8gnqVIus+eJCH/eGsRmJNw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dev: false + /local-pkg@0.5.0: resolution: {integrity: sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==} engines: {node: '>=14'} @@ -1811,6 +1968,10 @@ packages: util-deprecate: 1.0.2 dev: true + /regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + dev: false + /require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -2001,12 +2162,18 @@ packages: acorn: 8.11.3 dev: true + /supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + dependencies: + has-flag: 3.0.0 + dev: false + /supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} dependencies: has-flag: 4.0.0 - dev: true /supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} diff --git a/src/lib/index.ts b/src/lib/index.ts index 46944ae..90990bd 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -2,8 +2,14 @@ import Ajv04 from 'ajv-draft-04' import addFormats from 'ajv-formats' import Ajv2020 from 'ajv/dist/2020' import { JSON_SCHEMA, load } from 'js-yaml' -import type { AjvOptions, Specification, ValidationResult } from '../types' +import type { + AjvOptions, + Specification, + ValidationResult, + ValidateOptions, +} from '../types' import { checkRefs, replaceRefs } from './resolve' +import betterAjvErrors from '../utils/betterAjvErrors' const supportedVersions = new Set(['2.0', '3.0', '3.1']) @@ -141,7 +147,10 @@ export class Validator { this.externalRefs[newUri] = spec } - async validate(data: string | object): Promise { + async validate( + data: string | object, + options?: ValidateOptions, + ): Promise { const specification = await getSpecFromData(data) this.specification = specification @@ -185,7 +194,22 @@ export class Validator { } if (validateSchema.errors) { - result.errors = validateSchema.errors + if (typeof validateSchema.errors === 'string') { + result.errors = validateSchema.errors + } else { + result.errors = betterAjvErrors( + schemaResult, + {}, + validateSchema.errors, + { + format: options?.format ?? 'js', + indent: options?.indent ?? 2, + colorize: false, + }, + ) + } + + console.log(result.errors) } return result diff --git a/src/types.ts b/src/types.ts index 974fecc..5713347 100644 --- a/src/types.ts +++ b/src/types.ts @@ -7,6 +7,11 @@ export type ValidationResult = { errors?: string | ErrorObject[] } +export type ValidateOptions = { + format?: 'js' | 'cli' + indent?: number +} + export type ParseResult = OpenAPI.Document export type EmptyObject = Record diff --git a/src/utils/betterAjvErrors/helpers.js b/src/utils/betterAjvErrors/helpers.js new file mode 100644 index 0000000..839e6d5 --- /dev/null +++ b/src/utils/betterAjvErrors/helpers.js @@ -0,0 +1,131 @@ +/* eslint-disable no-param-reassign */ +import { + getChildren, + getErrors, + getSiblings, + isAnyOfError, + isEnumError, + isRequiredError, + concatAll, + notUndefined, +} from './utils'; +import { + AdditionalPropValidationError, + DefaultValidationError, + EnumValidationError, + PatternValidationError, + RequiredValidationError, + UnevaluatedPropValidationError, +} from './validation-errors'; + +// eslint-disable-next-line unicorn/no-unsafe-regex +const JSON_POINTERS_REGEX = /\/[\w_-]+(\/\d+)?/g; + +// Make a tree of errors from ajv errors array +export function makeTree(ajvErrors = []) { + const root = { children: {} }; + ajvErrors.forEach(ajvError => { + const instancePath = typeof ajvError.instancePath !== 'undefined' ? ajvError.instancePath : ajvError.dataPath; + + // `dataPath === ''` is root + const paths = instancePath === '' ? [''] : instancePath.match(JSON_POINTERS_REGEX); + if (paths) { + paths.reduce((obj, path, i) => { + obj.children[path] = obj.children[path] || { children: {}, errors: [] }; + if (i === paths.length - 1) { + obj.children[path].errors.push(ajvError); + } + return obj.children[path]; + }, root); + } + }); + return root; +} + +export function filterRedundantErrors(root, parent, key) { + /** + * If there is a `required` error then we can just skip everythig else. + * And, also `required` should have more priority than `anyOf`. @see #8 + */ + getErrors(root).forEach(error => { + if (isRequiredError(error)) { + root.errors = [error]; + root.children = {}; + } + }); + + /** + * If there is an `anyOf` error that means we have more meaningful errors + * inside children. So we will just remove all errors from this level. + * + * If there are no children, then we don't delete the errors since we should + * have at least one error to report. + */ + if (getErrors(root).some(isAnyOfError)) { + if (Object.keys(root.children).length > 0) { + delete root.errors; + } + } + + /** + * If all errors are `enum` and siblings have any error then we can safely + * ignore the node. + * + * **CAUTION** + * Need explicit `root.errors` check because `[].every(fn) === true` + * https://en.wikipedia.org/wiki/Vacuous_truth#Vacuous_truths_in_mathematics + */ + if (root.errors && root.errors.length && getErrors(root).every(isEnumError)) { + if ( + getSiblings(parent)(root) + // Remove any reference which becomes `undefined` later + .filter(notUndefined) + .some(getErrors) + ) { + delete parent.children[key]; + } + } + + Object.entries(root.children).forEach(([k, child]) => filterRedundantErrors(child, root, k)); +} + +export function createErrorInstances(root, options) { + const errors = getErrors(root); + if (errors.length && errors.every(isEnumError)) { + const uniqueValues = new Set(concatAll([])(errors.map(e => e.params.allowedValues))); + const allowedValues = [...uniqueValues]; + const error = errors[0]; + return [ + new EnumValidationError( + { + ...error, + params: { allowedValues }, + }, + options + ), + ]; + } + + return concatAll( + errors.reduce((ret, error) => { + switch (error.keyword) { + case 'additionalProperties': + return ret.concat(new AdditionalPropValidationError(error, options)); + case 'pattern': + return ret.concat(new PatternValidationError(error, options)); + case 'required': + return ret.concat(new RequiredValidationError(error, options)); + case 'unevaluatedProperties': + return ret.concat(new UnevaluatedPropValidationError(error, options)); + default: + return ret.concat(new DefaultValidationError(error, options)); + } + }, []) + )(getChildren(root).map(child => createErrorInstances(child, options))); +} + +export default function prettify(ajvErrors, options) { + const tree = makeTree(ajvErrors || []); + filterRedundantErrors(tree); + return createErrorInstances(tree, options); +} diff --git a/src/utils/betterAjvErrors/index.js b/src/utils/betterAjvErrors/index.js new file mode 100644 index 0000000..49dd0e1 --- /dev/null +++ b/src/utils/betterAjvErrors/index.js @@ -0,0 +1,26 @@ +import { parse } from '@humanwhocodes/momoa'; + +import prettify from './helpers'; + +export default function betterAjvErrors(schema, data, errors, options = {}) { + const { colorize = true, format = 'cli', indent = null, json = null } = options; + + const jsonRaw = json || JSON.stringify(data, null, indent); + const jsonAst = parse(jsonRaw); + + const customErrorToText = error => error.print().join('\n'); + const customErrorToStructure = error => error.getError(); + const customErrors = prettify(errors, { + colorize, + data, + schema, + jsonAst, + jsonRaw, + }); + + if (format === 'cli') { + return customErrors.map(customErrorToText).join('\n\n'); + } + + return customErrors.map(customErrorToStructure); +} diff --git a/src/utils/betterAjvErrors/json/get-decorated-data-path.js b/src/utils/betterAjvErrors/json/get-decorated-data-path.js new file mode 100644 index 0000000..3ae4c04 --- /dev/null +++ b/src/utils/betterAjvErrors/json/get-decorated-data-path.js @@ -0,0 +1,38 @@ +import { getPointers } from './utils'; + +function getTypeName(obj) { + if (!obj || !obj.elements) { + return ''; + } + const type = obj.elements.filter(child => child && child.name && child.name.value === 'type'); + + if (!type.length) { + return ''; + } + + return (type[0].value && `:${type[0].value.value}`) || ''; +} + +export default function getDecoratedDataPath(jsonAst, dataPath) { + let decoratedPath = ''; + getPointers(dataPath).reduce((obj, pointer) => { + switch (obj.type) { + case 'Object': { + decoratedPath += `/${pointer}`; + const filtered = obj.members.filter(child => child.name.value === pointer); + if (filtered.length !== 1) { + throw new Error(`Couldn't find property ${pointer} of ${dataPath}`); + } + return filtered[0].value; + } + case 'Array': { + decoratedPath += `/${pointer}${getTypeName(obj.elements[pointer])}`; + return obj.elements[pointer]; + } + default: + // eslint-disable-next-line no-console + console.log(obj); + } + }, jsonAst.body); + return decoratedPath; +} diff --git a/src/utils/betterAjvErrors/json/get-meta-from-path.js b/src/utils/betterAjvErrors/json/get-meta-from-path.js new file mode 100644 index 0000000..88427cc --- /dev/null +++ b/src/utils/betterAjvErrors/json/get-meta-from-path.js @@ -0,0 +1,24 @@ +import { getPointers } from './utils'; + +export default function getMetaFromPath(jsonAst, dataPath, includeIdentifierLocation) { + const pointers = getPointers(dataPath); + const lastPointerIndex = pointers.length - 1; + return pointers.reduce((obj, pointer, idx) => { + switch (obj.type) { + case 'Object': { + const filtered = obj.members.filter(child => child.name.value === pointer); + if (filtered.length !== 1) { + throw new Error(`Couldn't find property ${pointer} of ${dataPath}`); + } + + const { name, value } = filtered[0]; + return includeIdentifierLocation && idx === lastPointerIndex ? name : value; + } + case 'Array': + return obj.elements[pointer]; + default: + // eslint-disable-next-line no-console + console.log(obj); + } + }, jsonAst.body); +} diff --git a/src/utils/betterAjvErrors/json/index.js b/src/utils/betterAjvErrors/json/index.js new file mode 100644 index 0000000..4034d81 --- /dev/null +++ b/src/utils/betterAjvErrors/json/index.js @@ -0,0 +1,2 @@ +export { default as getMetaFromPath } from './get-meta-from-path'; +export { default as getDecoratedDataPath } from './get-decorated-data-path'; diff --git a/src/utils/betterAjvErrors/json/utils.js b/src/utils/betterAjvErrors/json/utils.js new file mode 100644 index 0000000..9a7afbb --- /dev/null +++ b/src/utils/betterAjvErrors/json/utils.js @@ -0,0 +1,8 @@ +// TODO: Better error handling +export const getPointers = dataPath => { + const pointers = dataPath.split('/').slice(1); + for (const index in pointers) { + pointers[index] = pointers[index].split('~1').join('/').split('~0').join('~'); + } + return pointers; +}; diff --git a/src/utils/betterAjvErrors/utils.js b/src/utils/betterAjvErrors/utils.js new file mode 100644 index 0000000..0f05a07 --- /dev/null +++ b/src/utils/betterAjvErrors/utils.js @@ -0,0 +1,26 @@ +// Basic +const eq = x => y => x === y; +const not = fn => x => !fn(x); + +const getValues = o => Object.values(o); + +export const notUndefined = x => x !== undefined; + +// Error +const isXError = x => error => error.keyword === x; +export const isRequiredError = isXError('required'); +export const isAnyOfError = isXError('anyOf'); +export const isEnumError = isXError('enum'); +export const getErrors = node => (node && node.errors) || []; + +// Node +export const getChildren = node => (node && getValues(node.children)) || []; + +export const getSiblings = (parent /*: Node */) => (node /*: Node */) /*: $ReadOnlyArray */ => + getChildren(parent).filter(not(eq(node))); + +export const concatAll = + /* :: */ + + (xs /*: $ReadOnlyArray */) => (ys /* : $ReadOnlyArray */) /* : $ReadOnlyArray */ => + ys.reduce((zs, z) => zs.concat(z), xs); diff --git a/src/utils/betterAjvErrors/validation-errors/additional-prop.js b/src/utils/betterAjvErrors/validation-errors/additional-prop.js new file mode 100644 index 0000000..4c600db --- /dev/null +++ b/src/utils/betterAjvErrors/validation-errors/additional-prop.js @@ -0,0 +1,32 @@ +import BaseValidationError from './base'; + +export default class AdditionalPropValidationError extends BaseValidationError { + constructor(...args) { + super(...args); + this.name = 'AdditionalPropValidationError'; + this.options.isIdentifierLocation = true; + } + + print() { + const { message, params } = this.options; + const chalk = this.getChalk(); + const output = [chalk`{red {bold ADDITIONAL PROPERTY} ${message}}\n`]; + + return output.concat( + this.getCodeFrame( + chalk`😲 {magentaBright ${params.additionalProperty}} is not expected to be here!`, + `${this.instancePath}/${params.additionalProperty}` + ) + ); + } + + getError() { + const { params } = this.options; + + return { + ...this.getLocation(`${this.instancePath}/${params.additionalProperty}`), + error: `${this.getDecoratedPath()} Property ${params.additionalProperty} is not expected to be here`, + path: this.instancePath, + }; + } +} diff --git a/src/utils/betterAjvErrors/validation-errors/base.js b/src/utils/betterAjvErrors/validation-errors/base.js new file mode 100644 index 0000000..b2779c3 --- /dev/null +++ b/src/utils/betterAjvErrors/validation-errors/base.js @@ -0,0 +1,67 @@ +// import { codeFrameColumns } from '@babel/code-frame'; +// import chalk from 'chalk'; + +import { getMetaFromPath, getDecoratedDataPath } from '../json'; + +export default class BaseValidationError { + // eslint-disable-next-line default-param-last + constructor(options = { isIdentifierLocation: false }, { colorize, data, schema, jsonAst, jsonRaw }) { + this.options = options; + this.colorize = !!(!!colorize || colorize === undefined); + this.data = data; + this.schema = schema; + this.jsonAst = jsonAst; + this.jsonRaw = jsonRaw; + } + + getChalk() { + return this.colorize ? chalk : new chalk.Instance({ level: 0 }); + } + + getLocation(dataPath = this.instancePath) { + const { isIdentifierLocation, isSkipEndLocation } = this.options; + const { loc } = getMetaFromPath(this.jsonAst, dataPath, isIdentifierLocation); + return { + start: loc.start, + end: isSkipEndLocation ? undefined : loc.end, + }; + } + + getDecoratedPath(dataPath = this.instancePath) { + return getDecoratedDataPath(this.jsonAst, dataPath); + } + + getCodeFrame(message, dataPath = this.instancePath) { + return codeFrameColumns(this.jsonRaw, this.getLocation(dataPath), { + /** + * `@babel/highlight`, by way of `@babel/code-frame`, highlights out entire block of raw JSON + * instead of just our `location` block -- so if you have a block of raw JSON that's upwards + * of 2mb+ and have a lot of errors to generate code frames for then we're re-highlighting + * the same huge chunk of code over and over and over and over again, all just so + * `@babel/code-frame` will eventually extract a small <10 line chunk out of it to return to + * us. + * + * Disabling `highlightCode` here will only disable highlighting the code we're showing users; + * if `options.colorize` is supplied to this library then the error message we're adding will + * still be highlighted. + */ + highlightCode: false, + message, + }); + } + + /** + * @return {string} + */ + get instancePath() { + return typeof this.options.instancePath !== 'undefined' ? this.options.instancePath : this.options.dataPath; + } + + print() { + throw new Error(`Implement the 'print' method inside ${this.constructor.name}!`); + } + + getError() { + throw new Error(`Implement the 'getError' method inside ${this.constructor.name}!`); + } +} diff --git a/src/utils/betterAjvErrors/validation-errors/default.js b/src/utils/betterAjvErrors/validation-errors/default.js new file mode 100644 index 0000000..2d85059 --- /dev/null +++ b/src/utils/betterAjvErrors/validation-errors/default.js @@ -0,0 +1,27 @@ +import BaseValidationError from './base'; + +export default class DefaultValidationError extends BaseValidationError { + constructor(...args) { + super(...args); + this.name = 'DefaultValidationError'; + this.options.isSkipEndLocation = true; + } + + print() { + const { keyword, message } = this.options; + const chalk = this.getChalk(); + const output = [chalk`{red {bold ${keyword.toUpperCase()}} ${message}}\n`]; + + return output.concat(this.getCodeFrame(chalk`👈đŸŊ {magentaBright ${keyword}} ${message}`)); + } + + getError() { + const { keyword, message } = this.options; + + return { + ...this.getLocation(), + error: `${this.getDecoratedPath()}: ${keyword} ${message}`, + path: this.instancePath, + }; + } +} diff --git a/src/utils/betterAjvErrors/validation-errors/enum.js b/src/utils/betterAjvErrors/validation-errors/enum.js new file mode 100644 index 0000000..9be2fbb --- /dev/null +++ b/src/utils/betterAjvErrors/validation-errors/enum.js @@ -0,0 +1,69 @@ +import pointer from 'jsonpointer'; +import leven from 'leven'; + +import BaseValidationError from './base'; + +export default class EnumValidationError extends BaseValidationError { + constructor(...args) { + super(...args); + this.name = 'EnumValidationError'; + } + + print() { + const { + message, + params: { allowedValues }, + } = this.options; + const chalk = this.getChalk(); + const bestMatch = this.findBestMatch(); + + const output = [chalk`{red {bold ENUM} ${message}}`, chalk`{red (${allowedValues.join(', ')})}\n`]; + + return output.concat( + this.getCodeFrame( + bestMatch !== null + ? chalk`👈đŸŊ Did you mean {magentaBright ${bestMatch}} here?` + : chalk`👈đŸŊ Unexpected value, should be equal to one of the allowed values` + ) + ); + } + + getError() { + const { message, params } = this.options; + const bestMatch = this.findBestMatch(); + const allowedValues = params.allowedValues.join(', '); + + const output = { + ...this.getLocation(), + error: `${this.getDecoratedPath()} ${message}: ${allowedValues}`, + path: this.instancePath, + }; + + if (bestMatch !== null) { + output.suggestion = `Did you mean ${bestMatch}?`; + } + + return output; + } + + findBestMatch() { + const { + params: { allowedValues }, + } = this.options; + + const currentValue = this.instancePath === '' ? this.data : pointer.get(this.data, this.instancePath); + + if (!currentValue) { + return null; + } + + const bestMatch = allowedValues + .map(value => ({ + value, + weight: leven(value, currentValue.toString()), + })) + .sort((x, y) => (x.weight > y.weight ? 1 : x.weight < y.weight ? -1 : 0))[0]; + + return allowedValues.length === 1 || bestMatch.weight < bestMatch.value.length ? bestMatch.value : null; + } +} diff --git a/src/utils/betterAjvErrors/validation-errors/index.js b/src/utils/betterAjvErrors/validation-errors/index.js new file mode 100644 index 0000000..e841dba --- /dev/null +++ b/src/utils/betterAjvErrors/validation-errors/index.js @@ -0,0 +1,6 @@ +export { default as AdditionalPropValidationError } from './additional-prop'; +export { default as DefaultValidationError } from './default'; +export { default as EnumValidationError } from './enum'; +export { default as PatternValidationError } from './pattern'; +export { default as RequiredValidationError } from './required'; +export { default as UnevaluatedPropValidationError } from './unevaluated-prop'; diff --git a/src/utils/betterAjvErrors/validation-errors/pattern.js b/src/utils/betterAjvErrors/validation-errors/pattern.js new file mode 100644 index 0000000..3fc4691 --- /dev/null +++ b/src/utils/betterAjvErrors/validation-errors/pattern.js @@ -0,0 +1,33 @@ +import BaseValidationError from './base'; + +export default class PatternValidationError extends BaseValidationError { + constructor(...args) { + super(...args); + this.name = 'PatternValidationError'; + this.options.isIdentifierLocation = true; + } + + print() { + const { message, params, propertyName } = this.options; + const chalk = this.getChalk(); + const output = [chalk`{red {bold PROPERTY} ${message}}\n`]; + + return output.concat( + this.getCodeFrame( + chalk`😲 must match pattern {magentaBright ${params.pattern}}`, + `${this.instancePath}/${propertyName}` + ) + ); + } + + getError() { + const { params, propertyName } = this.options; + + return { + // ...this.getLocation(`${this.instancePath}/${params.propertyName}`), + ...this.getLocation(), + error: `${this.getDecoratedPath()} Property "${propertyName}" must match pattern ${params.pattern}`, + path: this.instancePath, + }; + } +} diff --git a/src/utils/betterAjvErrors/validation-errors/required.js b/src/utils/betterAjvErrors/validation-errors/required.js new file mode 100644 index 0000000..be1d5b1 --- /dev/null +++ b/src/utils/betterAjvErrors/validation-errors/required.js @@ -0,0 +1,31 @@ +import BaseValidationError from './base'; + +export default class RequiredValidationError extends BaseValidationError { + constructor(...args) { + super(...args); + this.name = 'RequiredValidationError'; + } + + getLocation(dataPath = this.instancePath) { + const { start } = super.getLocation(dataPath); + return { start }; + } + + print() { + const { message, params } = this.options; + const chalk = this.getChalk(); + const output = [chalk`{red {bold REQUIRED} ${message}}\n`]; + + return output.concat(this.getCodeFrame(chalk`☚ī¸ {magentaBright ${params.missingProperty}} is missing here!`)); + } + + getError() { + const { message } = this.options; + + return { + ...this.getLocation(), + error: `${this.getDecoratedPath()} ${message}`, + path: this.instancePath, + }; + } +} diff --git a/src/utils/betterAjvErrors/validation-errors/unevaluated-prop.js b/src/utils/betterAjvErrors/validation-errors/unevaluated-prop.js new file mode 100644 index 0000000..ecabc2c --- /dev/null +++ b/src/utils/betterAjvErrors/validation-errors/unevaluated-prop.js @@ -0,0 +1,32 @@ +import BaseValidationError from './base'; + +export default class UnevaluatedPropValidationError extends BaseValidationError { + constructor(...args) { + super(...args); + this.name = 'UnevaluatedPropValidationError'; + this.options.isIdentifierLocation = true; + } + + print() { + const { message, params } = this.options; + const chalk = this.getChalk(); + const output = [chalk`{red {bold UNEVALUATED PROPERTY} ${message}}\n`]; + + return output.concat( + this.getCodeFrame( + chalk`😲 {magentaBright ${params.unevaluatedProperty}} is not expected to be here!`, + `${this.instancePath}/${params.unevaluatedProperty}` + ) + ); + } + + getError() { + const { params } = this.options; + + return { + ...this.getLocation(`${this.instancePath}/${params.unevaluatedProperty}`), + error: `${this.getDecoratedPath()} Property ${params.unevaluatedProperty} is not expected to be here`, + path: this.instancePath, + }; + } +} diff --git a/src/utils/parse.ts b/src/utils/parse.ts index 177f0a8..e1a372a 100644 --- a/src/utils/parse.ts +++ b/src/utils/parse.ts @@ -10,6 +10,7 @@ export async function parse(value: string): Promise { const result = await validator.validate(value) if (!result.valid) { + // return result.errors throw new Error(`Invalid Schema: ${result.errors}`) } diff --git a/src/utils/validate.test.ts b/src/utils/validate.test.ts index d73efb2..e37961b 100644 --- a/src/utils/validate.test.ts +++ b/src/utils/validate.test.ts @@ -10,15 +10,26 @@ describe('validate', async () => { }) it('returns errors for an invalid schema', async () => { - const result = await validate(`{ + const result = await validate( + `{ "openapi": "3.1.0", "paths": {} - }`) + }`, + ) expect(result.valid).toBe(false) - expect(Array.isArray(result.errors) ? result.errors[0].message : '').toBe( - `must have required property 'info'`, - ) + expect(result.errors).toBeTypeOf('object') + expect(Array.isArray(result.errors)).toBe(true) + expect(result.errors.length).toBe(1) + expect(result.errors[0]).toMatchObject({ + error: " must have required property 'info'", + path: '', + start: { + column: 1, + line: 1, + offset: 0, + }, + }) }) }) diff --git a/src/utils/validate.ts b/src/utils/validate.ts index 482c874..3d90def 100644 --- a/src/utils/validate.ts +++ b/src/utils/validate.ts @@ -1,12 +1,15 @@ import { Validator } from '../lib' -import type { ValidationResult } from '../types' +import type { ValidateOptions, ValidationResult } from '../types' /** * Validates an OpenAPI schema. */ -export async function validate(value: string): Promise { +export async function validate( + value: string, + options?: ValidateOptions, +): Promise { const validator = new Validator() - const result = await validator.validate(value) + const result = await validator.validate(value, options) return result }