From 3726a0b81c0990928fbee21943aa95ab32738feb Mon Sep 17 00:00:00 2001 From: Maciej Ziarkowski Date: Thu, 11 Mar 2021 18:40:01 +0000 Subject: [PATCH] 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. --- app/package-lock.json | 110 +++++++++++++++--- app/package.json | 2 + app/src/api/config/fieldSchemaConfig.ts | 7 ++ app/src/api/errors/general.ts | 9 ++ app/src/api/services/building/base.ts | 2 + .../domainLogic/validateBuildingUpdate.ts | 21 ++++ .../edit-history/field-edit-summary.tsx | 27 ++++- 7 files changed, 161 insertions(+), 17 deletions(-) create mode 100644 app/src/api/config/fieldSchemaConfig.ts create mode 100644 app/src/api/services/domainLogic/validateBuildingUpdate.ts diff --git a/app/package-lock.json b/app/package-lock.json index 38974e1f..451a0c5a 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -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": { diff --git a/app/package.json b/app/package.json index 1fa6314c..92d6f534 100644 --- a/app/package.json +++ b/app/package.json @@ -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", diff --git a/app/src/api/config/fieldSchemaConfig.ts b/app/src/api/config/fieldSchemaConfig.ts new file mode 100644 index 00000000..c042896f --- /dev/null +++ b/app/src/api/config/fieldSchemaConfig.ts @@ -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; diff --git a/app/src/api/errors/general.ts b/app/src/api/errors/general.ts index df3dc648..ae1f29fa 100644 --- a/app/src/api/errors/general.ts +++ b/app/src/api/errors/general.ts @@ -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) { diff --git a/app/src/api/services/building/base.ts b/app/src/api/services/building/base.ts index d5c636c4..fe2bcd1b 100644 --- a/app/src/api/services/building/base.ts +++ b/app/src/api/services/building/base.ts @@ -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 { // 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 diff --git a/app/src/api/services/domainLogic/validateBuildingUpdate.ts b/app/src/api/services/domainLogic/validateBuildingUpdate.ts new file mode 100644 index 00000000..12a5769f --- /dev/null +++ b/app/src/api/services/domainLogic/validateBuildingUpdate.ts @@ -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); + } + } + } +} \ No newline at end of file diff --git a/app/src/frontend/building/edit-history/field-edit-summary.tsx b/app/src/frontend/building/edit-history/field-edit-summary.tsx index d7808022..20b73ff3 100644 --- a/app/src/frontend/building/edit-history/field-edit-summary.tsx +++ b/app/src/frontend/building/edit-history/field-edit-summary.tsx @@ -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}) => ( + <> +
{JSON.stringify(oldValue, null, 4)}
+
{JSON.stringify(newValue, null, 4)}
+ +); + +const SimpleSummary: React.FC<{ oldValue: any; newValue: any}> = (props) => ( + <> + {formatValue(props.oldValue)} +   + {formatValue(props.newValue)} + +); + const FieldEditSummary: React.FunctionComponent = props => ( <> {props.title}:  - {formatValue(props.oldValue)} -   - {formatValue(props.value)} + { + (isComplex(props.oldValue) || isComplex(props.value)) ? + : + + } );