Enable jsonb fields with json schema

This adds the ability to verify JSON/JSONB fields using
json-schema, and adds a simple edit history JSON formatting.
This commit is contained in:
Maciej Ziarkowski 2021-03-11 18:40:01 +00:00
parent 58bc11be04
commit 3726a0b81c
7 changed files with 161 additions and 17 deletions

110
app/package-lock.json generated
View File

@ -2631,15 +2631,21 @@
}
},
"ajv": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.0.tgz",
"integrity": "sha512-nffhOpkymDECQyR0mnsUtoCE8RlX38G0rYP+wgLWFyZuUyuuojSSvi/+euOiQBIn63whYwYVIIH1TvE3tu4OEg==",
"dev": true,
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-7.1.1.tgz",
"integrity": "sha512-ga/aqDYnUy/o7vbsRTFhhTsNeXiYb5JWDIcRIeZfwRNCefwjNTVYCGdGSUrEmiu3yDK3vFvNbgJxvrQW4JXrYQ==",
"requires": {
"fast-deep-equal": "^2.0.1",
"fast-json-stable-stringify": "^2.0.0",
"json-schema-traverse": "^0.4.1",
"fast-deep-equal": "^3.1.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2",
"uri-js": "^4.2.2"
},
"dependencies": {
"json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="
}
}
},
"ajv-errors": {
@ -2648,6 +2654,14 @@
"integrity": "sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==",
"dev": true
},
"ajv-formats": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-1.5.1.tgz",
"integrity": "sha512-s1RBVF4HZd2UjGkb6t6uWoXjf6o7j7dXPQIL7vprcIT/67bTD6+5ocsU0UKShS2qWxueGDWuGfKHfOxHWrlTQg==",
"requires": {
"ajv": "^7.0.0"
}
},
"ajv-keywords": {
"version": "3.5.2",
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
@ -5971,6 +5985,18 @@
"text-table": "^0.2.0"
},
"dependencies": {
"ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"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-regex": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
@ -6577,10 +6603,9 @@
"dev": true
},
"fast-deep-equal": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz",
"integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=",
"dev": true
"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-glob": {
"version": "2.2.7",
@ -9393,6 +9418,20 @@
"ajv": "^6.1.0",
"ajv-errors": "^1.0.0",
"ajv-keywords": "^3.1.0"
},
"dependencies": {
"ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"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"
}
}
}
}
}
@ -11116,6 +11155,20 @@
"ajv": "^6.1.0",
"ajv-errors": "^1.0.0",
"ajv-keywords": "^3.1.0"
},
"dependencies": {
"ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"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"
}
}
}
}
}
@ -11976,8 +12029,7 @@
"punycode": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
"dev": true
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A=="
},
"q": {
"version": "1.5.1",
@ -12905,6 +12957,11 @@
"integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=",
"dev": true
},
"require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="
},
"require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
@ -14194,6 +14251,18 @@
"string-width": "^3.0.0"
},
"dependencies": {
"ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"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-regex": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
@ -14890,7 +14959,6 @@
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz",
"integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==",
"dev": true,
"requires": {
"punycode": "^2.1.0"
}
@ -15551,6 +15619,20 @@
"ajv": "^6.1.0",
"ajv-errors": "^1.0.0",
"ajv-keywords": "^3.1.0"
},
"dependencies": {
"ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"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"
}
}
}
},
"semver": {

View File

@ -17,6 +17,8 @@
"@fortawesome/free-solid-svg-icons": "^5.14.0",
"@fortawesome/react-fontawesome": "^0.1.11",
"@mapbox/sphericalmercator": "^1.1.0",
"ajv": "^7.1.1",
"ajv-formats": "^1.5.1",
"babel-runtime": "^6.26.0",
"body-parser": "^1.19.0",
"bootstrap": "^4.5.0",

View File

@ -0,0 +1,7 @@
import { JSONSchemaType } from 'ajv';
import { SomeJSONSchema } from 'ajv/dist/types/json-schema';
import { dataFieldsConfig } from './dataFields';
export const fieldSchemaConfig: { [key in keyof typeof dataFieldsConfig]?: SomeJSONSchema} = { /*eslint-disable @typescript-eslint/camelcase */
} as const;

View File

@ -21,6 +21,15 @@ export class InvalidOperationError extends UserError {
}
}
export class FieldTypeError extends UserError {
public fieldName: string;
constructor(message?: string, fieldName?: string) {
super(message);
this.name = 'FieldTypeError';
this.fieldName = fieldName;
}
}
export class DatabaseError extends Error {
public detail: any;
constructor(detail?: string) {

View File

@ -8,6 +8,7 @@ import { pickFields } from '../../../helpers';
import { dataFieldsConfig } from '../../config/dataFields';
import * as buildingDataAccess from '../../dataAccess/building';
import { processBuildingUpdate } from '../domainLogic/processBuildingUpdate';
import { validateBuildingUpdate } from '../domainLogic/validateBuildingUpdate';
import { getBuildingEditHistory } from './history';
import { updateBuildingData } from './save';
@ -34,6 +35,7 @@ const FIELD_EDIT_WHITELIST = new Set(Object.entries(dataFieldsConfig).filter(([,
export async function editBuilding(buildingId: number, building: any, userId: string): Promise<object> { // TODO add proper building type
return await updateBuildingData(buildingId, userId, async () => {
validateBuildingUpdate(buildingId, building);
const processedBuilding = await processBuildingUpdate(buildingId, building);
// remove read-only fields from consideration

View File

@ -0,0 +1,21 @@
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import { mapObject } from '../../../helpers';
import { FieldTypeError } from '../../errors/general';
import { fieldSchemaConfig } from '../../config/fieldSchemaConfig';
const ajv = new Ajv();
addFormats(ajv);
const compiledSchemas = mapObject(fieldSchemaConfig, ([, val]) => ajv.compile(val))
export function validateBuildingUpdate(buildingId: number, building: any) {
for(const field of Object.keys(building)) {
if(field in compiledSchemas) {
if(!compiledSchemas[field](building[field])) {
throw new FieldTypeError('Invalid format of data sent', field);
}
}
}
}

View File

@ -16,12 +16,33 @@ function formatValue(value: any) {
return value;
}
function isComplex(x: any): boolean {
return x != undefined && (Array.isArray(x) || typeof x === 'object');
}
const ObjectDiffSummary: React.FC<{ oldValue: any; newValue: any}> = ({oldValue, newValue}) => (
<>
<pre title="Value before edit" className='edit-history-diff old'>{JSON.stringify(oldValue, null, 4)}</pre>
<pre title="Value after edit" className='edit-history-diff new'>{JSON.stringify(newValue, null, 4)}</pre>
</>
);
const SimpleSummary: React.FC<{ oldValue: any; newValue: any}> = (props) => (
<>
<code title="Value before edit" className='edit-history-diff old'>{formatValue(props.oldValue)}</code>
&nbsp;
<code title="Value after edit" className='edit-history-diff new'>{formatValue(props.newValue)}</code>
</>
);
const FieldEditSummary: React.FunctionComponent<FieldEditSummaryProps> = props => (
<>
{props.title}:&nbsp;
<code title="Value before edit" className='edit-history-diff old'>{formatValue(props.oldValue)}</code>
&nbsp;
<code title="Value after edit" className='edit-history-diff new'>{formatValue(props.value)}</code>
{
(isComplex(props.oldValue) || isComplex(props.value)) ?
<ObjectDiffSummary oldValue={props.oldValue} newValue={props.value} /> :
<SimpleSummary oldValue={props.oldValue} newValue={props.value} />
}
</>
);