From 98f7cacb551e8a46e439e29e3cc0aa0810464ca8 Mon Sep 17 00:00:00 2001 From: Jos de Jong Date: Thu, 30 Jul 2020 11:41:35 +0200 Subject: [PATCH] Implement support for JSON Schema validation --- package-lock.json | 65 +++++++++ package.json | 9 +- .../examples/07_json_schema_validation.html | 138 ++++++++++++++++++ rollup.config.js | 2 + src/components/JSONEditor.svelte | 8 +- src/components/JSONNode.scss | 1 - src/main.js | 2 + src/plugins/createAjvValidator.mjs | 80 ++++++++++ src/plugins/createAjvValidator.test.mjs | 106 ++++++++++++++ src/styles.scss | 2 +- tools/{fix-lodash-es.cjs => fixLodashEs.cjs} | 0 tools/generateAjvDrafts.mjs | 25 ++++ 12 files changed, 432 insertions(+), 6 deletions(-) create mode 100644 public/examples/07_json_schema_validation.html create mode 100644 src/plugins/createAjvValidator.mjs create mode 100644 src/plugins/createAjvValidator.test.mjs rename tools/{fix-lodash-es.cjs => fixLodashEs.cjs} (100%) create mode 100644 tools/generateAjvDrafts.mjs diff --git a/package-lock.json b/package-lock.json index f665444..0555ac7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -72,6 +72,15 @@ "resolve": "^1.11.0" } }, + "@rollup/plugin-json": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-4.1.0.tgz", + "integrity": "sha512-yfLbTdNS6amI/2OpmbiBoW12vngr5NW2jCJVZSBEz+H5KfUJZ2M7sDjk0U6GOOdCWFVScShte29o9NezJ53TPw==", + "dev": true, + "requires": { + "@rollup/pluginutils": "^3.0.8" + } + }, "@rollup/plugin-node-resolve": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-8.4.0.tgz", @@ -134,6 +143,22 @@ "@types/node": "*" } }, + "ace-builds": { + "version": "1.4.12", + "resolved": "https://registry.npmjs.org/ace-builds/-/ace-builds-1.4.12.tgz", + "integrity": "sha512-G+chJctFPiiLGvs3+/Mly3apXTcfgE45dT5yp12BcWZ1kUs+gm0qd3/fv4gsz6fVag4mM0moHVpjHDIgph6Psg==" + }, + "ajv": { + "version": "6.12.3", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.3.tgz", + "integrity": "sha512-4K0cK3L1hsqk9xIb2z9vs/XU+PGJZ9PNpJRDS9YLzmNdX6jmVPfamLvTJr0aDAusnHyCHO6MjzlkAsgtqp9teA==", + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, "ansi-colors": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz", @@ -229,6 +254,12 @@ "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", "dev": true }, + "btoa": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz", + "integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==", + "dev": true + }, "buffer-from": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", @@ -491,6 +522,16 @@ "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", "dev": true }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" + }, "fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -804,6 +845,11 @@ "esprima": "^4.0.0" } }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + }, "kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -899,6 +945,12 @@ "brace-expansion": "^1.1.7" } }, + "mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true + }, "mocha": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/mocha/-/mocha-8.0.1.tgz", @@ -1083,6 +1135,11 @@ "iterate-value": "^1.0.0" } }, + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" + }, "randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -1422,6 +1479,14 @@ "integrity": "sha512-gduQwd1rOdDMGxFG1gEvhV88Oirdo2p+KjoYFU7k2g+i7n6AFFbDQ5kMPUsW0pNbfQsB/cwXvT1i4Bue0s9g5g==", "dev": true }, + "uri-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", + "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", + "requires": { + "punycode": "^2.1.0" + } + }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index f23f5d4..a24538d 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,8 @@ "build": "rollup -c", "dev": "rollup -c -w", "start": "sirv public", - "test": "mocha ./src/**/*.test.js", - "prepare": "node tools/fix-lodash-es.cjs" + "test": "mocha ./src/**/*.test.js ./src/**/*.test.mjs", + "prepare": "node tools/fixLodashEs.cjs && node tools/generateAceWorker.mjs && node tools/generateAjvDrafts.mjs" }, "license": "(MIT OR Apache-2.0)", "repository": { @@ -18,13 +18,18 @@ "dependencies": { "@fortawesome/free-regular-svg-icons": "5.14.0", "@fortawesome/free-solid-svg-icons": "5.14.0", + "ace-builds": "1.4.12", + "ajv": "6.12.3", "classnames": "2.2.6", "lodash-es": "4.17.15", "svelte-awesome": "2.3.0" }, "devDependencies": { "@rollup/plugin-commonjs": "14.0.0", + "@rollup/plugin-json": "4.1.0", "@rollup/plugin-node-resolve": "8.4.0", + "btoa": "1.2.1", + "mkdirp": "1.0.4", "mocha": "8.0.1", "rollup": "2.23.0", "rollup-plugin-livereload": "1.3.0", diff --git a/public/examples/07_json_schema_validation.html b/public/examples/07_json_schema_validation.html new file mode 100644 index 0000000..94d5fbc --- /dev/null +++ b/public/examples/07_json_schema_validation.html @@ -0,0 +1,138 @@ + + + + + + JSONEditor | JSON schema validation + + + + +

JSON schema validation

+

+ This example demonstrates JSON schema validation. The JSON object in this example must contain properties like firstName and lastName, can can optionally have a property age which must be a positive integer. +

+

+ See http://json-schema.org/ for more information. +

+ +
+ + + + diff --git a/rollup.config.js b/rollup.config.js index 539e74d..b040db3 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -1,6 +1,7 @@ import svelte from 'rollup-plugin-svelte'; import resolve from '@rollup/plugin-node-resolve'; import commonjs from '@rollup/plugin-commonjs'; +import json from '@rollup/plugin-json'; import livereload from 'rollup-plugin-livereload'; import { terser } from 'rollup-plugin-terser'; import autoPreprocess from 'svelte-preprocess'; @@ -39,6 +40,7 @@ export default { dedupe: ['svelte'] }), commonjs(), + json(), // In dev mode, call `npm run start` once // the bundle has been generated diff --git a/src/components/JSONEditor.svelte b/src/components/JSONEditor.svelte index 191d91b..a789381 100644 --- a/src/components/JSONEditor.svelte +++ b/src/components/JSONEditor.svelte @@ -38,13 +38,17 @@ let divContents let domHiddenInput - export let validate = () => [] + export let validate = null export let onChangeJson = () => {} export function setValidator (newValidate) { validate = newValidate } + export function getValidator () { + return validate + } + export let doc = {} let state = undefined @@ -52,7 +56,7 @@ let clipboard = null $: state = syncState(doc, state, [], (path) => path.length < 1) - $: validationErrorsList = validate(doc) + $: validationErrorsList = validate ? validate(doc) : [] $: validationErrors = mapValidationErrors(validationErrorsList) let showSearch = false diff --git a/src/components/JSONNode.scss b/src/components/JSONNode.scss index 1617f61..a5bf456 100644 --- a/src/components/JSONNode.scss +++ b/src/components/JSONNode.scss @@ -151,7 +151,6 @@ margin: 0 5px; cursor: pointer; position: relative; - top: 1px; &:hover { background: lighten($light-gray, 5%); diff --git a/src/main.js b/src/main.js index 0f94155..ffd9400 100644 --- a/src/main.js +++ b/src/main.js @@ -3,3 +3,5 @@ import JSONEditor from './components/JSONEditor.svelte' export default function jsoneditor (config) { return new JSONEditor(config) } + +export { createAjvValidator } from './plugins/createAjvValidator.mjs' diff --git a/src/plugins/createAjvValidator.mjs b/src/plugins/createAjvValidator.mjs new file mode 100644 index 0000000..b82e4b2 --- /dev/null +++ b/src/plugins/createAjvValidator.mjs @@ -0,0 +1,80 @@ +import Ajv from 'ajv' +import { parseJSONPointer } from '../utils/jsonPointer.js' +import { draft04 } from '../generated/ajv/draft04.mjs' +import { draft06 } from '../generated/ajv/draft06.mjs' + +/** + * Create a JSON Schema validator powered by Ajv. + * @param {JSON} schema + * @param {Object} [schemaRefs=undefined] An object containing JSON Schema references + */ +export function createAjvValidator (schema, schemaRefs) { + const ajv = Ajv({ + allErrors: true, + verbose: true, + schemaId: 'auto', + jsonPointers: true, + $data: true + }) + + // support both draft-04 and draft-06 alongside the latest draft-07 + ajv.addMetaSchema(draft04) // FIXME + ajv.addMetaSchema(draft06) // FIXME + + if (schemaRefs) { + Object.keys(schemaRefs).forEach(ref => { + ajv.addSchema(schemaRefs[ref], ref) + }) + } + + const validateAjv = ajv.compile(schema) + + return function validate (doc) { + validateAjv(doc) + const ajvErrors = validateAjv.errors + + return ajvErrors + .map(improveAjvError) + .map(normalizeAjvError) + } +} + +/** + * @param {Object} ajvError + * @return {ValidationError} + */ +function normalizeAjvError (ajvError) { + return { + path: parseJSONPointer(ajvError.dataPath), + message: ajvError.message + } +} + +/** + * Improve the error message of a JSON schema error, + * for example list the available values of an enum. + * + * @param {Object} ajvError + * @return {Object} Returns the error with improved message + */ +function improveAjvError (ajvError) { + if (ajvError.keyword === 'enum' && Array.isArray(ajvError.schema)) { + let enums = ajvError.schema + if (enums) { + enums = enums.map(value => JSON.stringify(value)) + + if (enums.length > 5) { + const more = ['(' + (enums.length - 5) + ' more...)'] + enums = enums.slice(0, 5) + enums.push(more) + } + ajvError.message = 'should be equal to one of: ' + enums.join(', ') + } + } + + if (ajvError.keyword === 'additionalProperties') { + ajvError.message = 'should NOT have additional property: ' + ajvError.params.additionalProperty + } + + return ajvError +} diff --git a/src/plugins/createAjvValidator.test.mjs b/src/plugins/createAjvValidator.test.mjs new file mode 100644 index 0000000..6737a98 --- /dev/null +++ b/src/plugins/createAjvValidator.test.mjs @@ -0,0 +1,106 @@ +import assert from "assert" +import { createAjvValidator } from './createAjvValidator.mjs' + +const schema = { + "title": "Employee", + "description": "Object containing employee details", + "type": "object", + "properties": { + "firstName": { + "title": "First Name", + "description": "The given name.", + "examples": [ + "John" + ], + "type": "string" + }, + "lastName": { + "title": "Last Name", + "description": "The family name.", + "examples": [ + "Smith" + ], + "type": "string" + }, + "gender": { + "title": "Gender", + "enum": ["male", "female"] + }, + "availableToHire": { + "type": "boolean", + "default": false + }, + "age": { + "description": "Age in years", + "type": "integer", + "minimum": 0, + "examples": [28, 32] + }, + "job": { + "$ref": "job" + } + }, + "required": ["firstName", "lastName"] +} + +const schemaRefs = { + job: { + "title": "Job description", + "type": "object", + "required": ["address"], + "properties": { + "company": { + "type": "string", + "examples": [ + "ACME", + "Dexter Industries" + ] + }, + "role": { + "description": "Job title.", + "type": "string", + "examples": [ + "Human Resources Coordinator", + "Software Developer" + ], + "default": "Software Developer" + }, + "address": { + "type": "string" + }, + "salary": { + "type": "number", + "minimum": 120, + "examples": [100, 110, 120] + } + } + } +} + +describe('createAjvValidator', () => { + it('should create a validate function', () => { + const validate = createAjvValidator(schema, schemaRefs) + + const invalidDoc = { + firstName: 'John', + lastName: 'Doe', + gender: null, + age: "28", + availableToHire: true, + job: { + company: 'freelance', + role: 'developer', + salary: 100 + } + } + + assert.deepStrictEqual(validate(invalidDoc), [ + { path: ['gender'], message: 'should be equal to one of: \"male\", \"female\"' }, + { path: ['age'], message: 'should be integer' }, + { path: ['job'], message: 'should have required property \'address\'' }, + { path: ['job', 'salary'], message: 'should be >= 120' } + ]) + }) + + // TODO: test support for draft04, draft-06, draft-07 +}) diff --git a/src/styles.scss b/src/styles.scss index b64e3d8..1db3698 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -1,6 +1,6 @@ $font-family: consolas, monaco, "lucida console", "courier new", "dejavu sans mono", "droid sans mono", courier, monospace, sans-serif; -$font-size: 13px; +$font-size: 14px; $font-size-small: 11px; $font-family-menu: arial, "sans-serif"; $font-size-icon: 16px; diff --git a/tools/fix-lodash-es.cjs b/tools/fixLodashEs.cjs similarity index 100% rename from tools/fix-lodash-es.cjs rename to tools/fixLodashEs.cjs diff --git a/tools/generateAjvDrafts.mjs b/tools/generateAjvDrafts.mjs new file mode 100644 index 0000000..2ee4137 --- /dev/null +++ b/tools/generateAjvDrafts.mjs @@ -0,0 +1,25 @@ +import { readFileSync, writeFileSync } from 'fs' +import mkdirp from 'mkdirp' +import path from 'path' + +const outputFolder = './src/generated/ajv' +const jsonDrafts = [ + { + name: 'draft04', + path: './node_modules/ajv/lib/refs/json-schema-draft-04.json' + }, + { + name: 'draft06', + path: './node_modules/ajv/lib/refs/json-schema-draft-06.json' + } +] + +mkdirp.sync(outputFolder) + +// Create an embedded version of the json drafts for Ajv: a data url +jsonDrafts.forEach(jsonDraft => { + const json = String(readFileSync(jsonDraft.path)) + const outputFile = path.join(outputFolder, jsonDraft.name + '.mjs') + + writeFileSync(outputFile, `export const ${jsonDraft.name} = ${json}\n`) +})