Implement support for JSON Schema validation

This commit is contained in:
Jos de Jong 2020-07-30 11:41:35 +02:00
parent b30aac082b
commit 98f7cacb55
12 changed files with 432 additions and 6 deletions

65
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -0,0 +1,138 @@
<!DOCTYPE HTML>
<html>
<head>
<meta charset='utf-8'>
<title>JSONEditor | JSON schema validation</title>
<style type="text/css">
body {
width: 600px;
font: 11pt sans-serif;
}
#jsoneditor {
width: 100%;
height: 500px;
}
/* custom bold styling for non-default JSON schema values */
.jsoneditor-is-not-default {
font-weight: bold;
}
</style>
</head>
<body>
<h1>JSON schema validation</h1>
<p>
This example demonstrates JSON schema validation. The JSON object in this example must contain properties like <code>firstName</code> and <code>lastName</code>, can can optionally have a property <code>age</code> which must be a positive integer.
</p>
<p>
See <a href="http://json-schema.org/" target="_blank">http://json-schema.org/</a> for more information.
</p>
<div id="jsoneditor"></div>
<script type="module">
import jsoneditor, { createAjvValidator } from "../dist/es/jsoneditor.js"
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]
}
}
}
}
const doc = {
firstName: 'John',
lastName: 'Doe',
gender: null,
age: "28",
availableToHire: true,
job: {
company: 'freelance',
role: 'developer',
salary: 100
}
}
// create the editor
const editor = jsoneditor({
target: document.getElementById('jsoneditor'),
props: {
doc,
onChangeJson: doc => console.log('onChangeJson', doc),
validate: createAjvValidator(schema, schemaRefs)
}
})
</script>
</body>
</html>

View File

@ -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

View File

@ -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

View File

@ -151,7 +151,6 @@
margin: 0 5px;
cursor: pointer;
position: relative;
top: 1px;
&:hover {
background: lighten($light-gray, 5%);

View File

@ -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'

View File

@ -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
}

View File

@ -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
})

View File

@ -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;

View File

@ -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`)
})