From a7184c3853f7fb855cb294052292ac76805ea3aa Mon Sep 17 00:00:00 2001 From: "Aode (lion)" Date: Thu, 30 Dec 2021 16:43:52 -0600 Subject: [PATCH] json schema validation --- .eslintignore | 2 + package.json | 1 + src/App.tsx | 3 +- src/Board.tsx | 26 +- src/boardState.ts | 19 ++ ...e.test.ts => boardState.validator.test.ts} | 8 +- src/boardState.validator.ts | 232 ++++++++++++++++++ src/client.ts | 9 +- src/validate.ts | 91 ------- webpack.config.js | 7 + yarn.lock | 56 ++++- 11 files changed, 330 insertions(+), 124 deletions(-) create mode 100644 src/boardState.ts rename src/{validate.test.ts => boardState.validator.test.ts} (88%) create mode 100644 src/boardState.validator.ts delete mode 100644 src/validate.ts create mode 100644 webpack.config.js diff --git a/.eslintignore b/.eslintignore index 0d67ec3..aa99dd6 100644 --- a/.eslintignore +++ b/.eslintignore @@ -4,3 +4,5 @@ node_modules dist # Ignore eslintrc .eslintrc.js +# Ignore webpack +webpack.config.js diff --git a/package.json b/package.json index 3618153..9fa5f5a 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "react-dom": "^17.0.2", "react-scripts": "5.0.0", "typescript": "^4.5.4", + "util": "^0.12.4", "web-vitals": "^2.1.2" }, "scripts": { diff --git a/src/App.tsx b/src/App.tsx index 8987e27..3e2cf9e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import React from "react"; -import Board, { BoardState, Coordinates, Selected } from "./Board"; +import Board, { Coordinates, Selected } from "./Board"; +import BoardState from "./boardState"; import { init } from "./client"; import "./App.css"; diff --git a/src/Board.tsx b/src/Board.tsx index 4398d05..cced8c8 100644 --- a/src/Board.tsx +++ b/src/Board.tsx @@ -1,20 +1,11 @@ import React from "react"; - -type COLOR = "black" | "white"; -type PIECE = "pawn" | "rook" | "knight" | "bishop" | "queen" | "king"; -type FILE = "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h"; -type RANK = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; +import BoardState, { COLOR, FILE, PIECE, RANK, PieceState } from "./boardState"; export type Coordinates = { rank: RANK; file: FILE; }; -export type PieceState = { - kind: PIECE; - color: COLOR; -}; - export type Selected = { piece: PieceState; location: Coordinates; @@ -43,13 +34,6 @@ export interface CellProps { onSelectDestination: OnSelectDestination; } -export type FileState = { - [key in RANK]?: PieceState; -}; - -export type BoardState = { - [key in FILE]?: FileState; -}; const RANKS: RANK[] = [1, 2, 3, 4, 5, 6, 7, 8]; const FILES: FILE[] = ["a", "b", "c", "d", "e", "f", "g", "h"]; @@ -114,10 +98,10 @@ const Cell = ({ const classNames = selected !== null && - selected.piece.kind === piece.kind && - selected.piece.color === piece.color && - selected.location.file === file && - selected.location.rank === rank + selected.piece.kind === piece.kind && + selected.piece.color === piece.color && + selected.location.file === file && + selected.location.rank === rank ? ["icon-cell", "selected"] : ["icon-cell"]; diff --git a/src/boardState.ts b/src/boardState.ts new file mode 100644 index 0000000..35f3248 --- /dev/null +++ b/src/boardState.ts @@ -0,0 +1,19 @@ +export type COLOR = "black" | "white"; +export type PIECE = "pawn" | "rook" | "knight" | "bishop" | "queen" | "king"; +export type FILE = "a" | "b" | "c" | "d" | "e" | "f" | "g" | "h"; +export type RANK = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; + +export type PieceState = { + kind: PIECE; + color: COLOR; +}; + +export type FileState = { + [key in RANK]?: PieceState; +}; + +export type BoardState = { + [key in FILE]?: FileState; +}; + +export default BoardState; diff --git a/src/validate.test.ts b/src/boardState.validator.test.ts similarity index 88% rename from src/validate.test.ts rename to src/boardState.validator.test.ts index a7a3093..f22a358 100644 --- a/src/validate.test.ts +++ b/src/boardState.validator.test.ts @@ -1,4 +1,4 @@ -import { validateBoardState } from "./validate"; +import validate from "./boardState.validator"; test("validates valid states", () => { const inputs = [ @@ -43,7 +43,7 @@ test("validates valid states", () => { ]; inputs.map((input) => { - const result = validateBoardState(input); + const result = validate(input); expect(result).not.toBeNull(); }); @@ -92,8 +92,6 @@ test("does not validate invalid states", () => { ]; inputs.map((input) => { - const result = validateBoardState(input); - - expect(result).toBeNull(); + expect(() => validate(input)).toThrow(); }); }); diff --git a/src/boardState.validator.ts b/src/boardState.validator.ts new file mode 100644 index 0000000..e0eb252 --- /dev/null +++ b/src/boardState.validator.ts @@ -0,0 +1,232 @@ +/* tslint:disable */ +// generated by typescript-json-validator +import { inspect } from 'util'; +import Ajv, { ErrorObject } from 'ajv'; +import BoardState from './boardState'; +import schema from 'ajv/lib/refs/json-schema-draft-06.json'; +export const ajv = new Ajv({ "allErrors": true, "coerceTypes": false, "format": "fast", "nullable": true, "unicode": true, "uniqueItems": true, "useDefaults": true }); + +ajv.addMetaSchema(schema); + +export const BoardStateSchema = { + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "defaultProperties": [ + ], + "definitions": { + "COLOR": { + "enum": [ + "black", + "white" + ], + "type": "string" + }, + "FileState": { + "additionalProperties": false, + "defaultProperties": [ + ], + "properties": { + "1": { + "additionalProperties": false, + "defaultProperties": [ + ], + "properties": { + "color": { + "$ref": "#/definitions/COLOR" + }, + "kind": { + "$ref": "#/definitions/PIECE" + } + }, + "required": [ + "color", + "kind" + ], + "type": "object" + }, + "2": { + "additionalProperties": false, + "defaultProperties": [ + ], + "properties": { + "color": { + "$ref": "#/definitions/COLOR" + }, + "kind": { + "$ref": "#/definitions/PIECE" + } + }, + "required": [ + "color", + "kind" + ], + "type": "object" + }, + "3": { + "additionalProperties": false, + "defaultProperties": [ + ], + "properties": { + "color": { + "$ref": "#/definitions/COLOR" + }, + "kind": { + "$ref": "#/definitions/PIECE" + } + }, + "required": [ + "color", + "kind" + ], + "type": "object" + }, + "4": { + "additionalProperties": false, + "defaultProperties": [ + ], + "properties": { + "color": { + "$ref": "#/definitions/COLOR" + }, + "kind": { + "$ref": "#/definitions/PIECE" + } + }, + "required": [ + "color", + "kind" + ], + "type": "object" + }, + "5": { + "additionalProperties": false, + "defaultProperties": [ + ], + "properties": { + "color": { + "$ref": "#/definitions/COLOR" + }, + "kind": { + "$ref": "#/definitions/PIECE" + } + }, + "required": [ + "color", + "kind" + ], + "type": "object" + }, + "6": { + "additionalProperties": false, + "defaultProperties": [ + ], + "properties": { + "color": { + "$ref": "#/definitions/COLOR" + }, + "kind": { + "$ref": "#/definitions/PIECE" + } + }, + "required": [ + "color", + "kind" + ], + "type": "object" + }, + "7": { + "additionalProperties": false, + "defaultProperties": [ + ], + "properties": { + "color": { + "$ref": "#/definitions/COLOR" + }, + "kind": { + "$ref": "#/definitions/PIECE" + } + }, + "required": [ + "color", + "kind" + ], + "type": "object" + }, + "8": { + "additionalProperties": false, + "defaultProperties": [ + ], + "properties": { + "color": { + "$ref": "#/definitions/COLOR" + }, + "kind": { + "$ref": "#/definitions/PIECE" + } + }, + "required": [ + "color", + "kind" + ], + "type": "object" + } + }, + "type": "object" + }, + "PIECE": { + "enum": [ + "bishop", + "king", + "knight", + "pawn", + "queen", + "rook" + ], + "type": "string" + } + }, + "properties": { + "a": { + "$ref": "#/definitions/FileState" + }, + "b": { + "$ref": "#/definitions/FileState" + }, + "c": { + "$ref": "#/definitions/FileState" + }, + "d": { + "$ref": "#/definitions/FileState" + }, + "e": { + "$ref": "#/definitions/FileState" + }, + "f": { + "$ref": "#/definitions/FileState" + }, + "g": { + "$ref": "#/definitions/FileState" + }, + "h": { + "$ref": "#/definitions/FileState" + } + }, + "type": "object" +}; +export type ValidateFunction = ((data: unknown) => data is T) & Pick +export const isBoardState = ajv.compile(BoardStateSchema) as ValidateFunction; +export default function validate(value: unknown): BoardState { + if (isBoardState(value)) { + return value; + } else { + if (Array.isArray(isBoardState.errors)) { + throw new Error( + ajv.errorsText(isBoardState.errors.filter((e: ErrorObject) => e.keyword !== 'if'), { dataVar: 'BoardState' }) + + '\n\n' + + inspect(value), + ); + } + + throw new Error("Unknown error"); + } +} diff --git a/src/client.ts b/src/client.ts index 20750ff..ec7e799 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,5 +1,6 @@ -import { BoardState, Selected, Coordinates } from "./Board"; -import { validateBoardState } from "./validate"; +import { Selected, Coordinates } from "./Board"; +import BoardState from "./boardState"; +import validate from "./boardState.validator"; export type StartGame = () => Promise; export type MakeMove = ( @@ -40,7 +41,7 @@ const startGame = (baseUri: string): StartGame => { const json: unknown = await response.json(); - return validateBoardState(json); + return validate(json); } catch (error) { console.error("Failed to start game,", error); return null; @@ -80,7 +81,7 @@ const makeMove = (baseUri: string): MakeMove => { const json: unknown = await response.json(); - return validateBoardState(json); + return validate(json); } catch (error) { console.error("Failed to make move,", error); return null; diff --git a/src/validate.ts b/src/validate.ts deleted file mode 100644 index 7ca4beb..0000000 --- a/src/validate.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { COLORS, PIECES, FILES, RANKS, BoardState } from "./Board"; - -const COLORS_AS_STRINGS: string[] = COLORS.map((color) => color as string); -const PIECES_AS_STRINGS: string[] = PIECES.map((piece) => piece as string); -const FILES_AS_STRINGS: string[] = FILES.map((file) => file as string); -const RANKS_AS_NUMBERS: number[] = RANKS.map((rank) => rank as number); - -const validateBoardState = (input: unknown): BoardState | null => { - if (typeof input !== "object" || Array.isArray(input) || input === null) { - console.error("Input is not keyed object"); - return null; - } - - const hasInvalidShape = Object.entries(input).some(([file, ranks]) => { - if (typeof file !== "string") { - console.error("file is not string"); - return true; - } - - if (!FILES_AS_STRINGS.includes(file)) { - console.error("file is not included in valid files"); - return true; - } - - if (typeof ranks !== "object" || Array.isArray(ranks) || ranks === null) { - console.error("ranks is not a keyed object"); - return true; - } - - return Object.entries(ranks as { [s: string]: unknown }).some( - ([rank, pieces]) => { - if (typeof rank !== "string") { - console.error("rank is not a string"); - return true; - } - - if (!RANKS_AS_NUMBERS.includes(Number(rank))) { - console.error("rank is not included in valid ranks"); - return true; - } - - if ( - typeof pieces !== "object" || - Array.isArray(pieces) || - pieces === null - ) { - console.error("piece state is not a keyed object"); - return true; - } - - return Object.entries(pieces).some(([key, value]) => { - if (typeof key !== "string") { - console.error("piece key is not a string"); - return true; - } - - if (!["kind", "color"].includes(key)) { - console.error("piece key is not included in valid keys"); - return true; - } - - if ( - key === "kind" && - (typeof value !== "string" || !PIECES_AS_STRINGS.includes(value)) - ) { - console.error("kind's value is not a string, or not a valid piece"); - return true; - } else if ( - key === "color" && - (typeof value !== "string" || !COLORS_AS_STRINGS.includes(value)) - ) { - console.error( - "colors' value is not a string, or not a valid color" - ); - return true; - } - - return false; - }); - } - ); - }); - - if (hasInvalidShape) { - return null; - } - - return input as BoardState; -}; - -export { validateBoardState }; diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..b0f0e97 --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,7 @@ +module.exports = { + resolve: { + fallback: { + util: require.resolve("util/"), + }, + }, +}; diff --git a/yarn.lock b/yarn.lock index 8011fa8..f914614 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2355,6 +2355,11 @@ autoprefixer@^10.4.0: picocolors "^1.0.0" postcss-value-parser "^4.2.0" +available-typed-arrays@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz#92f95616501069d07d10edb2fc37d3e1c65123b7" + integrity sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw== + axe-core@^4.3.5: version "4.3.5" resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.3.5.tgz#78d6911ba317a8262bfee292aeafcc1e04b49cc5" @@ -3582,7 +3587,7 @@ error-stack-parser@^2.0.6: dependencies: stackframe "^1.1.1" -es-abstract@^1.17.2, es-abstract@^1.19.0, es-abstract@^1.19.1: +es-abstract@^1.17.2, es-abstract@^1.18.5, es-abstract@^1.19.0, es-abstract@^1.19.1: version "1.19.1" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.19.1.tgz#d4885796876916959de78edaa0df456627115ec3" integrity sha512-2vJ6tjA/UfqLm2MPs7jxVybLoB8i1t1Jd9R3kISld20sIxPcTbLuggQOUxeWeAvIUkduv/CfMjuh4WmiXr2v9w== @@ -4155,6 +4160,11 @@ follow-redirects@^1.0.0: resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.6.tgz#8cfb281bbc035b3c067d6cd975b0f6ade6e855cd" integrity sha512-fhUl5EwSJbbl8AR+uYL2KQDxLkdSjZGR36xy46AO7cOMTrCMON6Sa28FmAnC2tRTDbd/Uuzz3aJBv7EBN7JH8A== +foreach@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/foreach/-/foreach-2.0.5.tgz#0bee005018aeb260d0a3af3ae658dd0136ec1b99" + integrity sha1-C+4AUBiusmDQo6865ljdATbsG5k= + fork-ts-checker-webpack-plugin@^6.5.0: version "6.5.0" resolved "https://registry.yarnpkg.com/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.0.tgz#0282b335fa495a97e167f69018f566ea7d2a2b5e" @@ -4762,6 +4772,13 @@ is-generator-fn@^2.0.0: resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-2.1.0.tgz#7d140adc389aaf3011a8f2a2a4cfa6faadffb118" integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ== +is-generator-function@^1.0.7: + version "1.0.10" + resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.10.tgz#f1558baf1ac17e0deea7c0415c438351ff2b3c72" + integrity sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A== + dependencies: + has-tostringtag "^1.0.0" + is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: version "4.0.3" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" @@ -4858,6 +4875,17 @@ is-symbol@^1.0.2, is-symbol@^1.0.3: dependencies: has-symbols "^1.0.2" +is-typed-array@^1.1.3, is-typed-array@^1.1.7: + version "1.1.8" + resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.8.tgz#cbaa6585dc7db43318bc5b89523ea384a6f65e79" + integrity sha512-HqH41TNZq2fgtGT8WHVFVJhBVGuY3AnP3Q36K8JKXUxSxRgk/d+7NjmwG2vo2mYmXK8UYZKu0qH8bVP5gEisjA== + dependencies: + available-typed-arrays "^1.0.5" + call-bind "^1.0.2" + es-abstract "^1.18.5" + foreach "^2.0.5" + has-tostringtag "^1.0.0" + is-typedarray@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" @@ -7302,7 +7330,7 @@ safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@~5.2.0: +safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.2, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -8201,6 +8229,18 @@ util.promisify@~1.0.0: has-symbols "^1.0.1" object.getownpropertydescriptors "^2.1.0" +util@^0.12.4: + version "0.12.4" + resolved "https://registry.yarnpkg.com/util/-/util-0.12.4.tgz#66121a31420df8f01ca0c464be15dfa1d1850253" + integrity sha512-bxZ9qtSlGUWSOy9Qa9Xgk11kSslpuZwaxCg4sNIDj6FLucDab2JxnHwyNTCpHMtK1MjoQiWQ6DiUMZYbSrO+Sw== + dependencies: + inherits "^2.0.3" + is-arguments "^1.0.4" + is-generator-function "^1.0.7" + is-typed-array "^1.1.3" + safe-buffer "^5.1.2" + which-typed-array "^1.1.2" + utila@~0.4: version "0.4.0" resolved "https://registry.yarnpkg.com/utila/-/utila-0.4.0.tgz#8a16a05d445657a3aea5eecc5b12a4fa5379772c" @@ -8456,6 +8496,18 @@ which-boxed-primitive@^1.0.2: is-string "^1.0.5" is-symbol "^1.0.3" +which-typed-array@^1.1.2: + version "1.1.7" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.7.tgz#2761799b9a22d4b8660b3c1b40abaa7739691793" + integrity sha512-vjxaB4nfDqwKI0ws7wZpxIlde1XrLX5uB0ZjpfshgmapJMD7jJWhZI+yToJTqaFByF0eNBcYxbjmCzoRP7CfEw== + dependencies: + available-typed-arrays "^1.0.5" + call-bind "^1.0.2" + es-abstract "^1.18.5" + foreach "^2.0.5" + has-tostringtag "^1.0.0" + is-typed-array "^1.1.7" + which@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"