From 6f3899fb5fb850b4f75054eb295fb0c6b11238d7 Mon Sep 17 00:00:00 2001 From: "maxime.batista" Date: Tue, 31 Oct 2023 14:32:46 +0100 Subject: [PATCH 01/10] add editor view, with possibility to edit name --- ci/.drone.yml | 1 - ci/build_react.msh | 2 +- front/components/TitleInput.tsx | 24 +++++++++++++++ front/style/colors.css | 8 +++++ front/style/editor.css | 20 +++++++++++++ front/style/title_input.css | 17 +++++++++++ front/views/Editor.tsx | 29 ++++++++++++++++++ package.json | 1 + public/api/index.php | 29 ++++++++++++++++++ public/index.php | 9 +++++- sql/setup-tables.sql | 8 +++-- src/Api/TacticEndpoint.php | 44 +++++++++++++++++++++++++++ src/Controller/EditorController.php | 36 ++++++++++++++++++++++ src/Data/TacticInfo.php | 36 ++++++++++++++++++++++ src/Gateway/TacticInfoGateway.php | 46 +++++++++++++++++++++++++++++ src/react-display-file.php | 12 ++++++++ vite.config.ts | 4 +-- 17 files changed, 319 insertions(+), 7 deletions(-) create mode 100644 front/components/TitleInput.tsx create mode 100644 front/style/colors.css create mode 100644 front/style/editor.css create mode 100644 front/style/title_input.css create mode 100644 front/views/Editor.tsx create mode 100644 public/api/index.php create mode 100644 src/Api/TacticEndpoint.php create mode 100644 src/Controller/EditorController.php create mode 100644 src/Data/TacticInfo.php create mode 100644 src/Gateway/TacticInfoGateway.php diff --git a/ci/.drone.yml b/ci/.drone.yml index e3ae556..e4d5305 100644 --- a/ci/.drone.yml +++ b/ci/.drone.yml @@ -46,4 +46,3 @@ steps: commands: - chmod +x ci/deploy.sh - ci/deploy.sh - diff --git a/ci/build_react.msh b/ci/build_react.msh index 9bd9d52..d253914 100755 --- a/ci/build_react.msh +++ b/ci/build_react.msh @@ -27,5 +27,5 @@ echo "];" >> views-mappings.php chmod +r views-mappings.php // moshell does not supports file patterns -bash <<< "mv dist/* public/* front/assets/ /outputs/public/" +bash <<< "mv dist/* public/* front/assets/ front/style/ /outputs/public/" mv views-mappings.php /outputs/ diff --git a/front/components/TitleInput.tsx b/front/components/TitleInput.tsx new file mode 100644 index 0000000..655071d --- /dev/null +++ b/front/components/TitleInput.tsx @@ -0,0 +1,24 @@ +import React, {useRef, useState} from "react"; +import "../style/title_input.css"; + +export default function TitleInput({default_value, on_validated}: { + default_value: string, + on_validated: (a: string) => void +}) { + const [value, setValue] = useState(default_value); + const ref = useRef(null); + + return ( + setValue(event.target.value)} + onBlur={_ => on_validated(value)} + onKeyDown={event => { + if (event.key == 'Enter') + ref.current?.blur(); + }} + /> + ) +} \ No newline at end of file diff --git a/front/style/colors.css b/front/style/colors.css new file mode 100644 index 0000000..34bdbb5 --- /dev/null +++ b/front/style/colors.css @@ -0,0 +1,8 @@ + + +:root { + --main-color: #ffffff; + --second-color: #ccde54; + + --background-color: #d2cdd3; +} \ No newline at end of file diff --git a/front/style/editor.css b/front/style/editor.css new file mode 100644 index 0000000..2ed88d6 --- /dev/null +++ b/front/style/editor.css @@ -0,0 +1,20 @@ +@import "colors.css"; + + +#main { + height: 100%; + width: 100%; + background-color: var(--background-color); +} + +#topbar { + display: flex; + background-color: var(--main-color); + + justify-content: space-between; + align-items: stretch; +} + +.title_input { + width: 25ch; +} \ No newline at end of file diff --git a/front/style/title_input.css b/front/style/title_input.css new file mode 100644 index 0000000..57af59b --- /dev/null +++ b/front/style/title_input.css @@ -0,0 +1,17 @@ +.title_input { + background: transparent; + border-top: none; + border-right: none; + border-left: none; + text-align: center; + + border-bottom-width: 2px; + border-bottom-color: transparent; +} + +.title_input:focus { + outline: none; + + border-bottom-color: blueviolet; +} + diff --git a/front/views/Editor.tsx b/front/views/Editor.tsx new file mode 100644 index 0000000..6fa49e6 --- /dev/null +++ b/front/views/Editor.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import "../style/editor.css"; +import TitleInput from "../components/TitleInput"; + + +export default function Editor({id, name}: { id: number, name: string }) { + return ( +
+
+
LEFT
+ update_tactic_name(id, name)}/> +
RIGHT
+
+
+ ) +} + +function update_tactic_name(id: number, new_name: string) { + //FIXME avoid absolute path as they would not work on staging server + fetch(`/api/tactic/${id}/edit/name`, { + method: "POST", + body: JSON.stringify({ + name: new_name + }) + }).then(response => { + if (!response.ok) + alert("could not update tactic name!") + }) +} \ No newline at end of file diff --git a/package.json b/package.json index 5a18741..0af1e0e 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "@types/node": "^16.18.59", "@types/react": "^18.2.31", "@types/react-dom": "^18.2.14", + "node-promises": "^0.2.1", "react": "^18.2.0", "react-dom": "^18.2.0", "typescript": "^5.2.2", diff --git a/public/api/index.php b/public/api/index.php new file mode 100644 index 0000000..c0a8f8f --- /dev/null +++ b/public/api/index.php @@ -0,0 +1,29 @@ +setBasePath("/api"); + +$tacticEndpoint = new TacticEndpoint(new TacticInfoGateway($con)); +$router->map("POST", "/tactic/[i:id]/edit/name", fn(int $id) => $tacticEndpoint->update_name($id)); +$router->map("GET", "/tactic/[i:id]", fn(int $id) => $tacticEndpoint->get_tactic_info($id)); +$router->map("POST", "/tactic/new", fn() => $tacticEndpoint->new_tactic()); + +$match = $router->match(); + +if ($match == null) { + echo "404 not found"; + header($_SERVER['SERVER_PROTOCOL'] . ' 404 Not Found'); + exit(1); +} + +call_user_func_array($match['target'], $match['params']); \ No newline at end of file diff --git a/public/index.php b/public/index.php index 4c5290b..91e15f6 100644 --- a/public/index.php +++ b/public/index.php @@ -7,7 +7,10 @@ require "../sql/database.php"; use \Twig\Loader\FilesystemLoader; use App\Connexion; use App\Controller\SampleFormController; +use App\Controller\EditorController; + use App\Gateway\FormResultGateway; +use App\Gateway\TacticInfoGateway; /** * relative path of the index.php's directory from the server's document root. @@ -35,10 +38,14 @@ $router = new AltoRouter(); $router->setBasePath($basePath); $sampleFormController = new SampleFormController(new FormResultGateway($con), $twig); +$editorController = new EditorController(new TacticInfoGateway($con)); + $router->map("GET", "/", fn() => $sampleFormController->displayForm()); $router->map("POST", "/submit", fn() => $sampleFormController->submitForm($_POST)); $router->map("GET", "/twig", fn() => $sampleFormController->displayFormTwig()); $router->map("POST", "/submit-twig", fn() => $sampleFormController->submitFormTwig($_POST)); +$router->map("GET", "/tactic/new", fn() => $editorController->makeNew()); +$router->map("GET", "/tactic/[i:id]/edit", fn(int $id) => $editorController->edit($id)); $match = $router->match(); @@ -49,4 +56,4 @@ if ($match == null) { exit(1); } -call_user_func($match['target']); +call_user_func_array($match['target'], $match['params']); diff --git a/sql/setup-tables.sql b/sql/setup-tables.sql index 0c6fbe7..949e902 100644 --- a/sql/setup-tables.sql +++ b/sql/setup-tables.sql @@ -1,8 +1,12 @@ -- drop tables here DROP TABLE IF EXISTS FormEntries; +DROP TABLE IF EXISTS TacticInfo; CREATE TABLE FormEntries(name varchar, description varchar); - - +CREATE TABLE TacticInfo( + id integer PRIMARY KEY AUTOINCREMENT, + name varchar, + creation_date timestamp +); \ No newline at end of file diff --git a/src/Api/TacticEndpoint.php b/src/Api/TacticEndpoint.php new file mode 100644 index 0000000..67af25b --- /dev/null +++ b/src/Api/TacticEndpoint.php @@ -0,0 +1,44 @@ +tactics = $tactics; + } + + + public function update_name(int $tactic_id) { + $request_body = file_get_contents('php://input'); + $data = json_decode($request_body); + + $new_name = $data->name; + + $this->tactics->update($tactic_id, $new_name); + } + + public function new_tactic() { + $request_body = file_get_contents('php://input'); + $data = json_decode($request_body); + + $initial_name = $data->name; + $id = $this->tactics->insert($initial_name)->getId(); + + echo "{id: $id}"; + } + + public function get_tactic_info(int $id) { + $tactic_info = $this->tactics->get($id); + echo json_encode($tactic_info); + } + +} \ No newline at end of file diff --git a/src/Controller/EditorController.php b/src/Controller/EditorController.php new file mode 100644 index 0000000..1c0c2ed --- /dev/null +++ b/src/Controller/EditorController.php @@ -0,0 +1,36 @@ +tactics = $tactics; + } + + private function openEditor(TacticInfo $tactic) { + send_react_front("views/Editor.tsx", ["name" => $tactic->getName(), "id" => $tactic->getId()]); + } + + public function makeNew() { + $info = $this->tactics->insert(self::TACTIC_DEFAULT_NAME); + $this->openEditor($info); + } + + public function edit(int $id) { + $tactic = $this->tactics->get($id); + + $this->openEditor($tactic); + } + +} \ No newline at end of file diff --git a/src/Data/TacticInfo.php b/src/Data/TacticInfo.php new file mode 100644 index 0000000..c8db912 --- /dev/null +++ b/src/Data/TacticInfo.php @@ -0,0 +1,36 @@ +id = $id; + $this->name = $name; + $this->creation_date = $creation_date; + } + + public function getId(): int { + return $this->id; + } + + public function getName(): string { + return $this->name; + } + + public function getCreationDate(): int { + return $this->creation_date; + } + + public function jsonSerialize() { + return get_object_vars($this); + } +} \ No newline at end of file diff --git a/src/Gateway/TacticInfoGateway.php b/src/Gateway/TacticInfoGateway.php new file mode 100644 index 0000000..9cc39db --- /dev/null +++ b/src/Gateway/TacticInfoGateway.php @@ -0,0 +1,46 @@ +con = $con; + } + + public function get(int $id): TacticInfo { + $row = $this->con->fetch( + "SELECT * FROM TacticInfo WHERE id = :id", + [":id" => [$id, PDO::PARAM_INT]] + )[0]; + + return new TacticInfo($id, $row["name"], strtotime($row["creation_date"])); + } + + public function insert(string $name): TacticInfo { + $row = $this->con->fetch( + "INSERT INTO TacticInfo(name, creation_date) VALUES(:name, CURRENT_TIMESTAMP) RETURNING id, creation_date", + [":name" => [$name, PDO::PARAM_STR]] + )[0]; + return new TacticInfo(intval($row["id"]), $name, strtotime($row["creation_date"])); + } + + public function update(int $id, string $name) { + $this->con->exec( + "UPDATE TacticInfo SET name = :name WHERE id = :id", + [ + ":name" => [$name, PDO::PARAM_STR], + ":id" => [$id, PDO::PARAM_INT] + ] + ); + } + +} \ No newline at end of file diff --git a/src/react-display-file.php b/src/react-display-file.php index 55d9656..f937fed 100755 --- a/src/react-display-file.php +++ b/src/react-display-file.php @@ -23,6 +23,17 @@ content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"> Document + + + + @@ -39,6 +50,7 @@ see ViewRenderer.tsx::renderView for more info renderView(Component, ) + diff --git a/vite.config.ts b/vite.config.ts index 55456dc..af2a9bb 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,7 +6,7 @@ import fs from "fs"; function resolve_entries(dirname: string): [string, string][] { //exclude assets - if (dirname == "front/assets") { + if (dirname == "front/assets" || dirname == "front/style") { return [] } @@ -29,7 +29,7 @@ export default defineConfig({ manifest: true, rollupOptions: { input: Object.fromEntries(resolve_entries("front")), - preserveEntrySignatures: "allow-extension" + preserveEntrySignatures: "allow-extension", } }, plugins: [ -- 2.36.3 From ff41df4d6b4a2bb5528a24a646e00118a3ebad86 Mon Sep 17 00:00:00 2001 From: "maxime.batista" Date: Thu, 2 Nov 2023 19:18:51 +0100 Subject: [PATCH 02/10] fix css inclusion on production build --- package.json | 1 + vite.config.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 0af1e0e..4399c64 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "react-dom": "^18.2.0", "typescript": "^5.2.2", "vite": "^4.5.0", + "vite-plugin-css-injected-by-js": "^3.3.0", "web-vitals": "^2.1.4" }, "scripts": { diff --git a/vite.config.ts b/vite.config.ts index af2a9bb..82f9796 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,7 @@ import {defineConfig} from "vite"; -import react from '@vitejs/plugin-react' +import react from '@vitejs/plugin-react'; import fs from "fs"; +import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'; function resolve_entries(dirname: string): [string, string][] { @@ -33,6 +34,9 @@ export default defineConfig({ } }, plugins: [ - react() + react(), + cssInjectedByJsPlugin({ + relativeCSSInjection: true, + }) ] }) -- 2.36.3 From 06abc31067e10ceaef8839d269212c8104fbb8f2 Mon Sep 17 00:00:00 2001 From: "maxime.batista" Date: Thu, 2 Nov 2023 21:34:29 +0100 Subject: [PATCH 03/10] fix production-server api calls --- .env | 1 + .gitignore | 5 ++++- ci/.drone.yml | 3 ++- ci/build_react.msh | 3 +-- front/Constants.ts | 4 ++++ front/views/Editor.tsx | 4 ++-- profiles/prod-config-profile.php | 2 +- public/api/index.php | 3 ++- public/index.php | 17 ++--------------- public/utils.php | 16 ++++++++++++++++ vite.config.ts | 1 + 11 files changed, 36 insertions(+), 23 deletions(-) create mode 100644 .env create mode 100644 front/Constants.ts create mode 100644 public/utils.php diff --git a/.env b/.env new file mode 100644 index 0000000..951db6b --- /dev/null +++ b/.env @@ -0,0 +1 @@ +VITE_API_ENDPOINT=/api \ No newline at end of file diff --git a/.gitignore b/.gitignore index 48a117a..05806f7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ -.* +.vs +.idea +.code + vendor composer.lock diff --git a/ci/.drone.yml b/ci/.drone.yml index e4d5305..d6c474c 100644 --- a/ci/.drone.yml +++ b/ci/.drone.yml @@ -20,7 +20,8 @@ steps: - curl -L moshell.dev/setup.sh > /tmp/moshell_setup.sh - chmod +x /tmp/moshell_setup.sh - echo n | /tmp/moshell_setup.sh - + - echo "VITE_API_ENDPOINT=/IQBall/$DRONE_BRANCH/public/api" >> .env.PROD + - - /root/.local/bin/moshell ci/build_react.msh - image: composer:latest diff --git a/ci/build_react.msh b/ci/build_react.msh index d253914..203afa0 100755 --- a/ci/build_react.msh +++ b/ci/build_react.msh @@ -1,11 +1,10 @@ #!/usr/bin/env moshell -npm build react mkdir -p /outputs/public apt update && apt install jq -y npm install -npm run build -- --base=/IQBall/public +npm run build -- --base=/IQBall/public --mode PROD // Read generated mappings from build val result = $(jq -r 'to_entries|map(.key + " " +.value.file)|.[]' dist/manifest.json) diff --git a/front/Constants.ts b/front/Constants.ts new file mode 100644 index 0000000..aaaaa43 --- /dev/null +++ b/front/Constants.ts @@ -0,0 +1,4 @@ +/** + * This constant defines the API endpoint. + */ +export const API = import.meta.env.VITE_API_ENDPOINT; \ No newline at end of file diff --git a/front/views/Editor.tsx b/front/views/Editor.tsx index 6fa49e6..3cbbf9b 100644 --- a/front/views/Editor.tsx +++ b/front/views/Editor.tsx @@ -1,7 +1,7 @@ import React from "react"; import "../style/editor.css"; import TitleInput from "../components/TitleInput"; - +import {API} from "../Constants"; export default function Editor({id, name}: { id: number, name: string }) { return ( @@ -17,7 +17,7 @@ export default function Editor({id, name}: { id: number, name: string }) { function update_tactic_name(id: number, new_name: string) { //FIXME avoid absolute path as they would not work on staging server - fetch(`/api/tactic/${id}/edit/name`, { + fetch(`${API}/tactic/${id}/edit/name`, { method: "POST", body: JSON.stringify({ name: new_name diff --git a/profiles/prod-config-profile.php b/profiles/prod-config-profile.php index dfd4d02..e185dfc 100644 --- a/profiles/prod-config-profile.php +++ b/profiles/prod-config-profile.php @@ -2,7 +2,7 @@ // This file only exists on production servers, and defines the available assets mappings // in an `ASSETS` array constant. -require "../views-mappings.php"; +require __DIR__ . "/../views-mappings.php"; const _SUPPORTS_FAST_REFRESH = false; $database_file = __DIR__ . "/../database.sqlite"; diff --git a/public/api/index.php b/public/api/index.php index c0a8f8f..e9fc0c3 100644 --- a/public/api/index.php +++ b/public/api/index.php @@ -3,6 +3,7 @@ require "../../config.php"; require "../../vendor/autoload.php"; require "../../sql/database.php"; +require "../utils.php"; use App\Api\TacticEndpoint; use App\Connexion; @@ -11,7 +12,7 @@ use App\Gateway\TacticInfoGateway; $con = new Connexion(get_database()); $router = new AltoRouter(); -$router->setBasePath("/api"); +$router->setBasePath(get_public_path() . "/api"); $tacticEndpoint = new TacticEndpoint(new TacticInfoGateway($con)); $router->map("POST", "/tactic/[i:id]/edit/name", fn(int $id) => $tacticEndpoint->update_name($id)); diff --git a/public/index.php b/public/index.php index 91e15f6..48b0637 100644 --- a/public/index.php +++ b/public/index.php @@ -3,6 +3,7 @@ require "../vendor/autoload.php"; require "../config.php"; require "../sql/database.php"; +require "utils.php"; use \Twig\Loader\FilesystemLoader; use App\Connexion; @@ -12,25 +13,11 @@ use App\Controller\EditorController; use App\Gateway\FormResultGateway; use App\Gateway\TacticInfoGateway; -/** - * relative path of the index.php's directory from the server's document root. - */ -function get_base_path() { - // find the server path of the index.php file - $basePath = dirname(substr(__FILE__, strlen($_SERVER['DOCUMENT_ROOT']))); - - $c = $basePath[strlen($basePath) - 1]; - - if ($c == "/" || $c == "\\") { - $basePath = substr($basePath, 0, strlen($basePath) - 1); - } - return $basePath; -} $loader = new FilesystemLoader('../src/Views/'); $twig = new \Twig\Environment($loader); -$basePath = get_base_path(); +$basePath = get_public_path(); $con = new Connexion(get_database()); // routes initialization diff --git a/public/utils.php b/public/utils.php new file mode 100644 index 0000000..ca9aa14 --- /dev/null +++ b/public/utils.php @@ -0,0 +1,16 @@ + Date: Mon, 6 Nov 2023 10:50:51 +0100 Subject: [PATCH 04/10] avoid use of 'RETURNING' sql clause --- .gitignore | 1 + public/utils.php | 8 ++++++-- src/Gateway/TacticInfoGateway.php | 7 ++++--- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 05806f7..9124809 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .vs .idea .code +.vite vendor diff --git a/public/utils.php b/public/utils.php index ca9aa14..a3566fe 100644 --- a/public/utils.php +++ b/public/utils.php @@ -7,10 +7,14 @@ function get_public_path() { // find the server path of the index.php file $basePath = substr(__DIR__, strlen($_SERVER['DOCUMENT_ROOT'])); - $c = $basePath[strlen($basePath) - 1]; + $basePathLen = strlen($basePath); + if ($basePathLen == 0) + return ""; + + $c = $basePath[$basePathLen - 1]; if ($c == "/" || $c == "\\") { - $basePath = substr($basePath, 0, strlen($basePath) - 1); + $basePath = substr($basePath, 0, $basePathLen - 1); } return $basePath; } \ No newline at end of file diff --git a/src/Gateway/TacticInfoGateway.php b/src/Gateway/TacticInfoGateway.php index 9cc39db..1c9a190 100644 --- a/src/Gateway/TacticInfoGateway.php +++ b/src/Gateway/TacticInfoGateway.php @@ -26,10 +26,11 @@ class TacticInfoGateway { } public function insert(string $name): TacticInfo { - $row = $this->con->fetch( - "INSERT INTO TacticInfo(name, creation_date) VALUES(:name, CURRENT_TIMESTAMP) RETURNING id, creation_date", + $this->con->exec( + "INSERT INTO TacticInfo(name, creation_date) VALUES(:name, CURRENT_TIMESTAMP)", [":name" => [$name, PDO::PARAM_STR]] - )[0]; + ); + $row = $this->con->fetch("SELECT id, creation_date FROM TacticInfo ORDER BY id DESC LIMIT 1", [])[0]; return new TacticInfo(intval($row["id"]), $name, strtotime($row["creation_date"])); } -- 2.36.3 From 29685562bb6269b56d66250a12e730b8b3b74214 Mon Sep 17 00:00:00 2001 From: "maxime.batista" Date: Fri, 10 Nov 2023 15:23:31 +0100 Subject: [PATCH 05/10] add TacticModel --- public/api/index.php | 6 +++--- public/index.php | 4 +++- src/Api/TacticEndpoint.php | 6 +++--- src/Controller/EditorController.php | 22 ++++++++++++++-------- src/Gateway/TacticInfoGateway.php | 12 +++++++++--- src/Model/TacticModel.php | 28 ++++++++++++++++++++++++++++ 6 files changed, 60 insertions(+), 18 deletions(-) create mode 100644 src/Model/TacticModel.php diff --git a/public/api/index.php b/public/api/index.php index e9fc0c3..52ecf2f 100644 --- a/public/api/index.php +++ b/public/api/index.php @@ -15,9 +15,9 @@ $router = new AltoRouter(); $router->setBasePath(get_public_path() . "/api"); $tacticEndpoint = new TacticEndpoint(new TacticInfoGateway($con)); -$router->map("POST", "/tactic/[i:id]/edit/name", fn(int $id) => $tacticEndpoint->update_name($id)); -$router->map("GET", "/tactic/[i:id]", fn(int $id) => $tacticEndpoint->get_tactic_info($id)); -$router->map("POST", "/tactic/new", fn() => $tacticEndpoint->new_tactic()); +$router->map("POST", "/tactic/[i:id]/edit/name", fn(int $id) => $tacticEndpoint->updateName($id)); +$router->map("GET", "/tactic/[i:id]", fn(int $id) => $tacticEndpoint->getTacticInfo($id)); +$router->map("POST", "/tactic/new", fn() => $tacticEndpoint->newTactic()); $match = $router->match(); diff --git a/public/index.php b/public/index.php index 48b0637..b4b3f9a 100644 --- a/public/index.php +++ b/public/index.php @@ -13,6 +13,8 @@ use App\Controller\EditorController; use App\Gateway\FormResultGateway; use App\Gateway\TacticInfoGateway; +use App\Model\TacticModel; + $loader = new FilesystemLoader('../src/Views/'); $twig = new \Twig\Environment($loader); @@ -25,7 +27,7 @@ $router = new AltoRouter(); $router->setBasePath($basePath); $sampleFormController = new SampleFormController(new FormResultGateway($con), $twig); -$editorController = new EditorController(new TacticInfoGateway($con)); +$editorController = new EditorController(new TacticModel(new TacticInfoGateway($con))); $router->map("GET", "/", fn() => $sampleFormController->displayForm()); $router->map("POST", "/submit", fn() => $sampleFormController->submitForm($_POST)); diff --git a/src/Api/TacticEndpoint.php b/src/Api/TacticEndpoint.php index 67af25b..1c35072 100644 --- a/src/Api/TacticEndpoint.php +++ b/src/Api/TacticEndpoint.php @@ -17,7 +17,7 @@ class TacticEndpoint { } - public function update_name(int $tactic_id) { + public function updateName(int $tactic_id) { $request_body = file_get_contents('php://input'); $data = json_decode($request_body); @@ -26,7 +26,7 @@ class TacticEndpoint { $this->tactics->update($tactic_id, $new_name); } - public function new_tactic() { + public function newTactic() { $request_body = file_get_contents('php://input'); $data = json_decode($request_body); @@ -36,7 +36,7 @@ class TacticEndpoint { echo "{id: $id}"; } - public function get_tactic_info(int $id) { + public function getTacticInfo(int $id) { $tactic_info = $this->tactics->get($id); echo json_encode($tactic_info); } diff --git a/src/Controller/EditorController.php b/src/Controller/EditorController.php index 1c0c2ed..e815ed1 100644 --- a/src/Controller/EditorController.php +++ b/src/Controller/EditorController.php @@ -4,18 +4,18 @@ namespace App\Controller; use App\Data\TacticInfo; use App\Gateway\TacticInfoGateway; +use App\Model\TacticModel; class EditorController { - const TACTIC_DEFAULT_NAME = "Nouvelle tactique"; - private TacticInfoGateway $tactics; + private TacticModel $model; /** - * @param TacticInfoGateway $tactics + * @param TacticModel $model */ - public function __construct(TacticInfoGateway $tactics) { - $this->tactics = $tactics; + public function __construct(TacticModel $model) { + $this->model = $model; } private function openEditor(TacticInfo $tactic) { @@ -23,12 +23,18 @@ class EditorController { } public function makeNew() { - $info = $this->tactics->insert(self::TACTIC_DEFAULT_NAME); - $this->openEditor($info); + $tactic = $this->model->makeNew(); + $this->openEditor($tactic); } public function edit(int $id) { - $tactic = $this->tactics->get($id); + $tactic = $this->model->get($id); + + if ($tactic == null) { + echo "la tactique " . $id . " n'existe pas"; + http_response_code(404); + return; + } $this->openEditor($tactic); } diff --git a/src/Gateway/TacticInfoGateway.php b/src/Gateway/TacticInfoGateway.php index 1c9a190..690d5bd 100644 --- a/src/Gateway/TacticInfoGateway.php +++ b/src/Gateway/TacticInfoGateway.php @@ -16,11 +16,17 @@ class TacticInfoGateway { $this->con = $con; } - public function get(int $id): TacticInfo { - $row = $this->con->fetch( + public function get(int $id): ?TacticInfo { + $res = $this->con->fetch( "SELECT * FROM TacticInfo WHERE id = :id", [":id" => [$id, PDO::PARAM_INT]] - )[0]; + ); + + if (!isset($res[0])) { + return null; + } + + $row = $res[0]; return new TacticInfo($id, $row["name"], strtotime($row["creation_date"])); } diff --git a/src/Model/TacticModel.php b/src/Model/TacticModel.php new file mode 100644 index 0000000..1a394f6 --- /dev/null +++ b/src/Model/TacticModel.php @@ -0,0 +1,28 @@ +tactics = $tactics; + } + + public function makeNew(): TacticInfo { + return $this->tactics->insert(self::TACTIC_DEFAULT_NAME); + } + + public function get(int $id): ?TacticInfo { + return $this->tactics->get($id); + } + +} \ No newline at end of file -- 2.36.3 From f8e8e642d3726dd3312e799c523c8083e0dbbc35 Mon Sep 17 00:00:00 2001 From: Override-6 Date: Sat, 11 Nov 2023 03:46:27 +0100 Subject: [PATCH 06/10] validate user inputs --- front/components/TitleInput.tsx | 12 +++-- front/views/Editor.tsx | 40 +++++++++------ public/api/index.php | 5 +- src/Api/TacticEndpoint.php | 58 +++++++++++++++++----- src/Controller/EditorController.php | 5 +- src/Gateway/TacticInfoGateway.php | 2 +- src/HttpCodes.php | 14 ++++++ src/Model/TacticModel.php | 37 +++++++++++++- src/Validation/FieldValidationFail.php | 38 ++++++++++++++ src/Validation/SimpleFunctionValidator.php | 28 +++++++++++ src/Validation/Validation.php | 30 +++++++++++ src/Validation/ValidationFail.php | 34 +++++++++++++ src/Validation/Validator.php | 15 ++++++ src/Validation/Validators.php | 34 +++++++++++++ 14 files changed, 315 insertions(+), 37 deletions(-) create mode 100644 src/HttpCodes.php create mode 100644 src/Validation/FieldValidationFail.php create mode 100644 src/Validation/SimpleFunctionValidator.php create mode 100644 src/Validation/Validation.php create mode 100644 src/Validation/ValidationFail.php create mode 100644 src/Validation/Validator.php create mode 100644 src/Validation/Validators.php diff --git a/front/components/TitleInput.tsx b/front/components/TitleInput.tsx index 655071d..eb162d1 100644 --- a/front/components/TitleInput.tsx +++ b/front/components/TitleInput.tsx @@ -1,16 +1,20 @@ -import React, {useRef, useState} from "react"; +import React, {CSSProperties, useRef, useState} from "react"; import "../style/title_input.css"; -export default function TitleInput({default_value, on_validated}: { +export interface TitleInputOptions { + style: CSSProperties, default_value: string, on_validated: (a: string) => void -}) { +} + +export default function TitleInput({style, default_value, on_validated}: TitleInputOptions) { const [value, setValue] = useState(default_value); const ref = useRef(null); - + return ( setValue(event.target.value)} diff --git a/front/views/Editor.tsx b/front/views/Editor.tsx index 3cbbf9b..84d24e6 100644 --- a/front/views/Editor.tsx +++ b/front/views/Editor.tsx @@ -1,29 +1,41 @@ -import React from "react"; +import React, {CSSProperties, useState} from "react"; import "../style/editor.css"; import TitleInput from "../components/TitleInput"; import {API} from "../Constants"; +const ERROR_STYLE: CSSProperties = { + borderColor: "red" +} + export default function Editor({id, name}: { id: number, name: string }) { + + const [style, setStyle] = useState({}); + return (
LEFT
- update_tactic_name(id, name)}/> + { + fetch(`${API}/tactic/${id}/edit/name`, { + method: "POST", + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + name: new_name, + }) + }).then(response => { + if (response.ok) { + setStyle({}) + } else { + setStyle(ERROR_STYLE) + } + }) + }}/>
RIGHT
) } -function update_tactic_name(id: number, new_name: string) { - //FIXME avoid absolute path as they would not work on staging server - fetch(`${API}/tactic/${id}/edit/name`, { - method: "POST", - body: JSON.stringify({ - name: new_name - }) - }).then(response => { - if (!response.ok) - alert("could not update tactic name!") - }) -} \ No newline at end of file diff --git a/public/api/index.php b/public/api/index.php index 52ecf2f..2032833 100644 --- a/public/api/index.php +++ b/public/api/index.php @@ -5,16 +5,17 @@ require "../../vendor/autoload.php"; require "../../sql/database.php"; require "../utils.php"; -use App\Api\TacticEndpoint; use App\Connexion; +use App\Api\TacticEndpoint; use App\Gateway\TacticInfoGateway; +use App\Model\TacticModel; $con = new Connexion(get_database()); $router = new AltoRouter(); $router->setBasePath(get_public_path() . "/api"); -$tacticEndpoint = new TacticEndpoint(new TacticInfoGateway($con)); +$tacticEndpoint = new TacticEndpoint(new TacticModel(new TacticInfoGateway($con))); $router->map("POST", "/tactic/[i:id]/edit/name", fn(int $id) => $tacticEndpoint->updateName($id)); $router->map("GET", "/tactic/[i:id]", fn(int $id) => $tacticEndpoint->getTacticInfo($id)); $router->map("POST", "/tactic/new", fn() => $tacticEndpoint->newTactic()); diff --git a/src/Api/TacticEndpoint.php b/src/Api/TacticEndpoint.php index 1c35072..9c1ee8c 100644 --- a/src/Api/TacticEndpoint.php +++ b/src/Api/TacticEndpoint.php @@ -1,43 +1,77 @@ tactics = $tactics; + public function __construct(TacticModel $model) { + $this->model = $model; } - public function updateName(int $tactic_id) { + public function updateName(int $tactic_id): void { $request_body = file_get_contents('php://input'); $data = json_decode($request_body); - $new_name = $data->name; + if (!isset($data->name)) { + http_response_code(HttpCodes::BAD_REQUEST); + echo "missing 'name'"; + return; + } - $this->tactics->update($tactic_id, $new_name); + $fails = []; + + $this->model->updateName($fails, $tactic_id, $data->name); + if (!empty($fails)) { + http_response_code(HttpCodes::PRECONDITION_FAILED); + echo json_encode($fails); + } } - public function newTactic() { + public function newTactic(): void { $request_body = file_get_contents('php://input'); $data = json_decode($request_body); $initial_name = $data->name; - $id = $this->tactics->insert($initial_name)->getId(); + if (!isset($data->name)) { + http_response_code(HttpCodes::BAD_REQUEST); + echo "missing 'name'"; + return; + } + + $fails = []; + $tactic = $this->model->makeNew($fails, $initial_name); + + if (!empty($fails)) { + http_response_code(HttpCodes::PRECONDITION_FAILED); + echo json_encode($fails); + return; + } + + $id = $tactic->getId(); echo "{id: $id}"; } - public function getTacticInfo(int $id) { - $tactic_info = $this->tactics->get($id); + public function getTacticInfo(int $id): void { + $tactic_info = $this->model->get($id); + + if ($tactic_info == null) { + http_response_code(HttpCodes::NOT_FOUND); + return; + } + echo json_encode($tactic_info); } diff --git a/src/Controller/EditorController.php b/src/Controller/EditorController.php index e815ed1..de3056a 100644 --- a/src/Controller/EditorController.php +++ b/src/Controller/EditorController.php @@ -4,6 +4,7 @@ namespace App\Controller; use App\Data\TacticInfo; use App\Gateway\TacticInfoGateway; +use App\HttpCodes; use App\Model\TacticModel; class EditorController { @@ -23,7 +24,7 @@ class EditorController { } public function makeNew() { - $tactic = $this->model->makeNew(); + $tactic = $this->model->makeNewDefault(); $this->openEditor($tactic); } @@ -31,8 +32,8 @@ class EditorController { $tactic = $this->model->get($id); if ($tactic == null) { - echo "la tactique " . $id . " n'existe pas"; http_response_code(404); + echo "la tactique " . $id . " n'existe pas"; return; } diff --git a/src/Gateway/TacticInfoGateway.php b/src/Gateway/TacticInfoGateway.php index 690d5bd..1f95d55 100644 --- a/src/Gateway/TacticInfoGateway.php +++ b/src/Gateway/TacticInfoGateway.php @@ -40,7 +40,7 @@ class TacticInfoGateway { return new TacticInfo(intval($row["id"]), $name, strtotime($row["creation_date"])); } - public function update(int $id, string $name) { + public function updateName(int $id, string $name) { $this->con->exec( "UPDATE TacticInfo SET name = :name WHERE id = :id", [ diff --git a/src/HttpCodes.php b/src/HttpCodes.php new file mode 100644 index 0000000..6bcfa5f --- /dev/null +++ b/src/HttpCodes.php @@ -0,0 +1,14 @@ +tactics = $tactics; } - public function makeNew(): TacticInfo { + public function makeNew(array &$fails, string $name): ?TacticInfo { + $failure = Validation::validate($name, "name", $fails, Validators::nonEmpty(), Validators::noInvalidChars()); + if ($failure) { + return null; + } + return $this->tactics->insert($name); + } + + public function makeNewDefault(): ?TacticInfo { return $this->tactics->insert(self::TACTIC_DEFAULT_NAME); } + /** + * Tries to retrieve information about a tactic + * @param int $id tactic identifier + * @return TacticInfo|null or null if the identifier did not match a tactic + */ public function get(int $id): ?TacticInfo { return $this->tactics->get($id); } + /** + * Update the name of a tactic + * @param int $id the tactic identifier + * @param string $name the new name to set + */ + public function updateName(array &$fails, int $id, string $name): void { + $failure = Validation::validate($name, "name", $fails, Validators::nonEmpty(), Validators::noInvalidChars()); + + if ($this->tactics->get($id) == null) { + $fails[] = ValidationFail::notFound("$id is an unknown tactic identifier"); + } else if (!$failure) { + $this->tactics->updateName($id, $name); + } + } + } \ No newline at end of file diff --git a/src/Validation/FieldValidationFail.php b/src/Validation/FieldValidationFail.php new file mode 100644 index 0000000..cae1496 --- /dev/null +++ b/src/Validation/FieldValidationFail.php @@ -0,0 +1,38 @@ +fieldName = $fieldName; + } + + + public function getFieldName(): string { + return $this->fieldName; + } + + + public static function invalidChars(string $fieldName): FieldValidationFail { + return new FieldValidationFail($fieldName, "field contains illegal chars"); + } + + public static function empty(string $fieldName): FieldValidationFail { + return new FieldValidationFail($fieldName, "field is empty"); + } + + public function jsonSerialize() { + return ["field" => $this->fieldName, "message" => $this->getMessage()]; + } +} \ No newline at end of file diff --git a/src/Validation/SimpleFunctionValidator.php b/src/Validation/SimpleFunctionValidator.php new file mode 100644 index 0000000..4282916 --- /dev/null +++ b/src/Validation/SimpleFunctionValidator.php @@ -0,0 +1,28 @@ + bool`, to validate the given string + * @param callable $error_factory a factory function with signature `(string) => Error)` to emit error when the predicate fails + */ + public function __construct(callable $predicate, callable $error_factory) { + $this->predicate = $predicate; + $this->error_factory = $error_factory; + } + + public function validate(string $name, $val): ?ValidationFail { + if (!call_user_func_array($this->predicate, [$val])) { + return call_user_func_array($this->error_factory, [$name]); + } + return null; + } +} \ No newline at end of file diff --git a/src/Validation/Validation.php b/src/Validation/Validation.php new file mode 100644 index 0000000..da5dadd --- /dev/null +++ b/src/Validation/Validation.php @@ -0,0 +1,30 @@ +validate($val_name, $val); + if ($error != null) { + $errors[] = $error; + $had_errors = true; + } + } + return $had_errors; + } + +} \ No newline at end of file diff --git a/src/Validation/ValidationFail.php b/src/Validation/ValidationFail.php new file mode 100644 index 0000000..9daded8 --- /dev/null +++ b/src/Validation/ValidationFail.php @@ -0,0 +1,34 @@ +message = $message; + $this->kind = $kind; + } + + public function getMessage(): string { + return $this->message; + } + + public function getKind(): string { + return $this->kind; + } + + public function jsonSerialize() { + return ["error" => $this->kind, "message" => $this->message]; + } + + public static function notFound(string $message): ValidationFail { + return new ValidationFail("not found", $message); + } +} \ No newline at end of file diff --git a/src/Validation/Validator.php b/src/Validation/Validator.php new file mode 100644 index 0000000..b7c77ce --- /dev/null +++ b/src/Validation/Validator.php @@ -0,0 +1,15 @@ +` + */ + public static function noInvalidChars(): Validator { + return new SimpleFunctionValidator( + fn($str) => !filter_var($str, FILTER_VALIDATE_REGEXP, ['options' => ["regexp" => "/[<>]/"]]), + fn(string $name) => FieldValidationFail::invalidChars($name) + ); + } + + /** + * @return Validator a validator that validates non-empty strings + */ + public static function nonEmpty(): Validator { + return new SimpleFunctionValidator( + fn($str) => !empty($str), + fn(string $name) => FieldValidationFail::empty($name) + ); + } + + +} + + -- 2.36.3 From cccc68e3ce8d3b4acf4ad384fffc49c9d4ef1374 Mon Sep 17 00:00:00 2001 From: Override-6 Date: Sat, 11 Nov 2023 04:05:28 +0100 Subject: [PATCH 07/10] apply suggestion --- package.json | 1 - public/api/index.php | 1 + sql/setup-tables.sql | 2 +- src/Connexion.php | 4 ++++ src/Data/TacticInfo.php | 2 +- src/Gateway/TacticInfoGateway.php | 7 +++++-- src/Model/TacticModel.php | 2 +- tsconfig.json | 1 + 8 files changed, 14 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 4399c64..0eb1e79 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,6 @@ "@types/node": "^16.18.59", "@types/react": "^18.2.31", "@types/react-dom": "^18.2.14", - "node-promises": "^0.2.1", "react": "^18.2.0", "react-dom": "^18.2.0", "typescript": "^5.2.2", diff --git a/public/api/index.php b/public/api/index.php index 2032833..52e1f97 100644 --- a/public/api/index.php +++ b/public/api/index.php @@ -28,4 +28,5 @@ if ($match == null) { exit(1); } +header('Content-type: application/json'); call_user_func_array($match['target'], $match['params']); \ No newline at end of file diff --git a/sql/setup-tables.sql b/sql/setup-tables.sql index 949e902..068c2e1 100644 --- a/sql/setup-tables.sql +++ b/sql/setup-tables.sql @@ -8,5 +8,5 @@ CREATE TABLE FormEntries(name varchar, description varchar); CREATE TABLE TacticInfo( id integer PRIMARY KEY AUTOINCREMENT, name varchar, - creation_date timestamp + creation_date timestamp DEFAULT CURRENT_TIMESTAMP ); \ No newline at end of file diff --git a/src/Connexion.php b/src/Connexion.php index ef9c909..cefb8fd 100644 --- a/src/Connexion.php +++ b/src/Connexion.php @@ -15,6 +15,10 @@ class Connexion { $this->pdo = $pdo; } + public function lastInsertId() { + return $this->pdo->lastInsertId(); + } + /** * execute a request * @param string $query diff --git a/src/Data/TacticInfo.php b/src/Data/TacticInfo.php index c8db912..901280d 100644 --- a/src/Data/TacticInfo.php +++ b/src/Data/TacticInfo.php @@ -26,7 +26,7 @@ class TacticInfo implements \JsonSerializable { return $this->name; } - public function getCreationDate(): int { + public function getCreationTimestamp(): int { return $this->creation_date; } diff --git a/src/Gateway/TacticInfoGateway.php b/src/Gateway/TacticInfoGateway.php index 1f95d55..20d2957 100644 --- a/src/Gateway/TacticInfoGateway.php +++ b/src/Gateway/TacticInfoGateway.php @@ -33,10 +33,13 @@ class TacticInfoGateway { public function insert(string $name): TacticInfo { $this->con->exec( - "INSERT INTO TacticInfo(name, creation_date) VALUES(:name, CURRENT_TIMESTAMP)", + "INSERT INTO TacticInfo(name) VALUES(:name)", [":name" => [$name, PDO::PARAM_STR]] ); - $row = $this->con->fetch("SELECT id, creation_date FROM TacticInfo ORDER BY id DESC LIMIT 1", [])[0]; + $row = $this->con->fetch( + "SELECT id, creation_date FROM TacticInfo WHERE :id = id", + [':id' => [$this->con->lastInsertId(), PDO::PARAM_INT]] + )[0]; return new TacticInfo(intval($row["id"]), $name, strtotime($row["creation_date"])); } diff --git a/src/Model/TacticModel.php b/src/Model/TacticModel.php index bc5af53..7f31572 100644 --- a/src/Model/TacticModel.php +++ b/src/Model/TacticModel.php @@ -27,7 +27,7 @@ class TacticModel { if ($failure) { return null; } - return $this->tactics->insert($name); + $this->tactics->insert($name); } public function makeNewDefault(): ?TacticInfo { diff --git a/tsconfig.json b/tsconfig.json index bd8a98e..9da1fb5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "dom.iterable", "esnext" ], + "types": ["vite/client"], "allowJs": true, "skipLibCheck": true, "esModuleInterop": true, -- 2.36.3 From a1910d1167144b61a0eef75266ea467b155edfd5 Mon Sep 17 00:00:00 2001 From: Override-6 Date: Sat, 11 Nov 2023 18:15:38 +0100 Subject: [PATCH 08/10] move validations in the controllers, make controllers return HttpResponses and use HttpRequests --- public/api/index.php | 18 +++-- public/index.php | 44 +++++++++--- src/Api/TacticEndpoint.php | 78 ---------------------- src/Controller/Api/APITacticController.php | 55 +++++++++++++++ src/Controller/Control.php | 31 +++++++++ src/Controller/EditorController.php | 26 +++++--- src/Controller/SampleFormController.php | 54 ++++++++------- src/Http/HttpRequest.php | 63 +++++++++++++++++ src/Http/HttpResponse.php | 24 +++++++ src/Http/JsonHttpResponse.php | 23 +++++++ src/Http/ViewHttpResponse.php | 49 ++++++++++++++ src/Model/TacticModel.php | 23 +++---- src/Validation/ComposedValidator.php | 24 +++++++ src/Validation/FieldValidationFail.php | 6 +- src/Validation/SimpleFunctionValidator.php | 12 ++-- src/Validation/Validation.php | 6 +- src/Validation/Validator.php | 15 ++++- src/Validation/Validators.php | 21 +++--- 18 files changed, 402 insertions(+), 170 deletions(-) delete mode 100644 src/Api/TacticEndpoint.php create mode 100644 src/Controller/Api/APITacticController.php create mode 100644 src/Controller/Control.php create mode 100644 src/Http/HttpRequest.php create mode 100644 src/Http/HttpResponse.php create mode 100644 src/Http/JsonHttpResponse.php create mode 100644 src/Http/ViewHttpResponse.php create mode 100644 src/Validation/ComposedValidator.php diff --git a/public/api/index.php b/public/api/index.php index 52e1f97..b6327e1 100644 --- a/public/api/index.php +++ b/public/api/index.php @@ -6,8 +6,10 @@ require "../../sql/database.php"; require "../utils.php"; use App\Connexion; -use App\Api\TacticEndpoint; +use App\Controller\Api\APITacticController; use App\Gateway\TacticInfoGateway; +use App\Http\JsonHttpResponse; +use App\Http\ViewHttpResponse; use App\Model\TacticModel; $con = new Connexion(get_database()); @@ -15,7 +17,7 @@ $con = new Connexion(get_database()); $router = new AltoRouter(); $router->setBasePath(get_public_path() . "/api"); -$tacticEndpoint = new TacticEndpoint(new TacticModel(new TacticInfoGateway($con))); +$tacticEndpoint = new APITacticController(new TacticModel(new TacticInfoGateway($con))); $router->map("POST", "/tactic/[i:id]/edit/name", fn(int $id) => $tacticEndpoint->updateName($id)); $router->map("GET", "/tactic/[i:id]", fn(int $id) => $tacticEndpoint->getTacticInfo($id)); $router->map("POST", "/tactic/new", fn() => $tacticEndpoint->newTactic()); @@ -28,5 +30,13 @@ if ($match == null) { exit(1); } -header('Content-type: application/json'); -call_user_func_array($match['target'], $match['params']); \ No newline at end of file +$response = call_user_func_array($match['target'], $match['params']); + +http_response_code($response->getCode()); + +if ($response instanceof JsonHttpResponse) { + header('Content-type: application/json'); + echo $response->getJson(); +} else if ($response instanceof ViewHttpResponse) { + throw new Exception("API returned a view http response."); +} \ No newline at end of file diff --git a/public/index.php b/public/index.php index b4b3f9a..caa8a2e 100644 --- a/public/index.php +++ b/public/index.php @@ -5,15 +5,15 @@ require "../config.php"; require "../sql/database.php"; require "utils.php"; -use \Twig\Loader\FilesystemLoader; use App\Connexion; -use App\Controller\SampleFormController; use App\Controller\EditorController; - +use App\Controller\SampleFormController; use App\Gateway\FormResultGateway; use App\Gateway\TacticInfoGateway; - +use App\Http\JsonHttpResponse; +use App\Http\ViewHttpResponse; use App\Model\TacticModel; +use Twig\Loader\FilesystemLoader; $loader = new FilesystemLoader('../src/Views/'); @@ -29,12 +29,12 @@ $router->setBasePath($basePath); $sampleFormController = new SampleFormController(new FormResultGateway($con), $twig); $editorController = new EditorController(new TacticModel(new TacticInfoGateway($con))); -$router->map("GET", "/", fn() => $sampleFormController->displayForm()); -$router->map("POST", "/submit", fn() => $sampleFormController->submitForm($_POST)); +$router->map("GET", "/", fn() => $sampleFormController->displayFormReact()); +$router->map("POST", "/submit", fn() => $sampleFormController->submitFormReact($_POST)); $router->map("GET", "/twig", fn() => $sampleFormController->displayFormTwig()); $router->map("POST", "/submit-twig", fn() => $sampleFormController->submitFormTwig($_POST)); $router->map("GET", "/tactic/new", fn() => $editorController->makeNew()); -$router->map("GET", "/tactic/[i:id]/edit", fn(int $id) => $editorController->edit($id)); +$router->map("GET", "/tactic/[i:id]/edit", fn(int $id) => $editorController->openEditorFor($id)); $match = $router->match(); @@ -42,7 +42,33 @@ if ($match == null) { // TODO redirect to a 404 not found page instead (issue #1) http_response_code(404); echo "Page non trouvée"; - exit(1); + return; } -call_user_func_array($match['target'], $match['params']); +$response = call_user_func_array($match['target'], $match['params']); + +http_response_code($response->getCode()); + +if ($response instanceof ViewHttpResponse) { + $file = $response->getFile(); + $args = $response->getArguments(); + + switch ($response->getViewKind()) { + case ViewHttpResponse::REACT_VIEW: + send_react_front($file, $args); + break; + case ViewHttpResponse::TWIG_VIEW: + try { + $twig->display($file, $args); + } catch (\Twig\Error\RuntimeError|\Twig\Error\SyntaxError $e) { + http_response_code(500); + echo "There was an error rendering your view, please refer to an administrator.\nlogs date: " . date("YYYD, d M Y H:i:s"); + throw e; + } + break; + } + +} else if ($response instanceof JsonHttpResponse) { + header('Content-type: application/json'); + echo $response->getJson(); +} \ No newline at end of file diff --git a/src/Api/TacticEndpoint.php b/src/Api/TacticEndpoint.php deleted file mode 100644 index 9c1ee8c..0000000 --- a/src/Api/TacticEndpoint.php +++ /dev/null @@ -1,78 +0,0 @@ -model = $model; - } - - - public function updateName(int $tactic_id): void { - $request_body = file_get_contents('php://input'); - $data = json_decode($request_body); - - if (!isset($data->name)) { - http_response_code(HttpCodes::BAD_REQUEST); - echo "missing 'name'"; - return; - } - - $fails = []; - - $this->model->updateName($fails, $tactic_id, $data->name); - if (!empty($fails)) { - http_response_code(HttpCodes::PRECONDITION_FAILED); - echo json_encode($fails); - } - } - - public function newTactic(): void { - $request_body = file_get_contents('php://input'); - $data = json_decode($request_body); - - $initial_name = $data->name; - - if (!isset($data->name)) { - http_response_code(HttpCodes::BAD_REQUEST); - echo "missing 'name'"; - return; - } - - $fails = []; - $tactic = $this->model->makeNew($fails, $initial_name); - - if (!empty($fails)) { - http_response_code(HttpCodes::PRECONDITION_FAILED); - echo json_encode($fails); - return; - } - - $id = $tactic->getId(); - echo "{id: $id}"; - } - - public function getTacticInfo(int $id): void { - $tactic_info = $this->model->get($id); - - if ($tactic_info == null) { - http_response_code(HttpCodes::NOT_FOUND); - return; - } - - echo json_encode($tactic_info); - } - -} \ No newline at end of file diff --git a/src/Controller/Api/APITacticController.php b/src/Controller/Api/APITacticController.php new file mode 100644 index 0000000..a1f7767 --- /dev/null +++ b/src/Controller/Api/APITacticController.php @@ -0,0 +1,55 @@ +model = $model; + } + + public function updateName(int $tactic_id): HttpResponse { + return Control::runChecked([ + "name" => [Validators::userString(32)] + ], function (HttpRequest $request) use ($tactic_id) { + $this->model->updateName($tactic_id, $request["name"]); + return HttpResponse::fromCode(HttpCodes::OK); + }); + } + + public function newTactic(): HttpResponse { + return Control::runChecked([ + "name" => [Validators::userString(32)] + ], function (HttpRequest $request) { + $tactic = $this->model->makeNew($request["name"]); + $id = $tactic->getId(); + return new JsonHttpResponse(["id" => $id]); + }); + } + + public function getTacticInfo(int $id): HttpResponse { + $tactic_info = $this->model->get($id); + + if ($tactic_info == null) { + return new JsonHttpResponse("could not find tactic #$id", HttpCodes::NOT_FOUND); + } + + return new JsonHttpResponse($tactic_info); + } + +} \ No newline at end of file diff --git a/src/Controller/Control.php b/src/Controller/Control.php new file mode 100644 index 0000000..af1a38b --- /dev/null +++ b/src/Controller/Control.php @@ -0,0 +1,31 @@ +model = $model; } - private function openEditor(TacticInfo $tactic) { - send_react_front("views/Editor.tsx", ["name" => $tactic->getName(), "id" => $tactic->getId()]); + private function openEditor(TacticInfo $tactic): HttpResponse { + return ViewHttpResponse::react("views/Editor.tsx", ["name" => $tactic->getName(), "id" => $tactic->getId()]); } - public function makeNew() { + public function makeNew(): HttpResponse { $tactic = $this->model->makeNewDefault(); - $this->openEditor($tactic); + return $this->openEditor($tactic); } - public function edit(int $id) { + /** + * returns an editor view for a given tactic + * @param int $id the targeted tactic identifier + * @return HttpResponse + */ + public function openEditorFor(int $id): HttpResponse { $tactic = $this->model->get($id); if ($tactic == null) { - http_response_code(404); - echo "la tactique " . $id . " n'existe pas"; - return; + return new JsonHttpResponse("la tactique " . $id . " n'existe pas", HttpCodes::NOT_FOUND); } - $this->openEditor($tactic); + return $this->openEditor($tactic); } } \ No newline at end of file diff --git a/src/Controller/SampleFormController.php b/src/Controller/SampleFormController.php index ad77d62..b65b265 100644 --- a/src/Controller/SampleFormController.php +++ b/src/Controller/SampleFormController.php @@ -3,52 +3,50 @@ namespace App\Controller; require_once __DIR__ . "/../react-display.php"; + use App\Gateway\FormResultGateway; -use \Twig\Environment; -use Twig\Error\LoaderError; -use Twig\Error\RuntimeError; -use Twig\Error\SyntaxError; +use App\Http\HttpRequest; +use App\Http\HttpResponse; +use App\Http\ViewHttpResponse; +use App\Validation\Validators; class SampleFormController { private FormResultGateway $gateway; - private Environment $twig; /** * @param FormResultGateway $gateway */ - public function __construct(FormResultGateway $gateway, Environment $twig) - { + public function __construct(FormResultGateway $gateway) { $this->gateway = $gateway; - $this->twig = $twig; } - public function displayForm() { - send_react_front("views/SampleForm.tsx", []); + public function displayFormReact(): HttpResponse { + return ViewHttpResponse::react("views/SampleForm.tsx", []); + } + + public function displayFormTwig(): HttpResponse { + return ViewHttpResponse::twig('sample_form.html.twig', []); } - public function submitForm(array $request) { - $this->gateway->insert($request["name"], $request["description"]); - $results = ["results" => $this->gateway->listResults()]; - send_react_front("views/DisplayResults.tsx", $results); + private function submitForm(array $form, callable $response): HttpResponse { + return Control::runCheckedFrom($form, [ + "name" => [Validators::userString(32)], + "description" => [Validators::userString(512)] + ], function (HttpRequest $req) use ($response) { + $this->gateway->insert($req["name"], $req["description"]); + $results = ["results" => $this->gateway->listResults()]; + + return call_user_func_array($response, [$results]); + }); } - public function displayFormTwig() { - try { - echo $this->twig->render('sample_form.html.twig', []); - } catch (LoaderError | RuntimeError | SyntaxError $e) { - echo "Twig error: $e"; - } + public function submitFormTwig(array $form): HttpResponse { + return $this->submitForm($form, fn(array $results) => ViewHttpResponse::twig('display_results.html.twig', $results)); } - public function submitFormTwig(array $request) { - $this->gateway->insert($request["name"], $request["description"]); - try { - $results = $this->gateway->listResults(); - echo $this->twig->render('display_results.html.twig', ['results' => $results]); - } catch (LoaderError | RuntimeError | SyntaxError $e) { - echo "Twig error: $e"; - } + public function submitFormReact(array $form): HttpResponse { + return $this->submitForm($form, fn(array $results) => ViewHttpResponse::react('views/DisplayResults.tsx', $results)); } } \ No newline at end of file diff --git a/src/Http/HttpRequest.php b/src/Http/HttpRequest.php new file mode 100644 index 0000000..ce75d76 --- /dev/null +++ b/src/Http/HttpRequest.php @@ -0,0 +1,63 @@ +data = $data; + } + + /** + * Creates a new HttpRequest instance, and ensures that the given request data validates the given schema. + * This is a simple function that only supports flat schemas (non-composed, the data must only be a k/v array pair.) + * @param array $request the request's data + * @param array $fails a reference to a failure array, that will contain the reported validation failures. + * @param array $schema the schema to satisfy. a schema is a simple array with a string key (which is the top-level field name), and a set of validators + * @return HttpRequest|null the built HttpRequest instance, or null if a field is missing, or if any of the schema validator failed + */ + public static function from(array $request, array &$fails, array $schema): ?HttpRequest { + $failure = false; + foreach ($schema as $fieldName => $fieldValidators) { + if (!isset($request[$fieldName])) { + $fails[] = FieldValidationFail::missing($fieldName); + $failure = true; + continue; + } + $failure |= Validation::validate($request[$fieldName], $fieldName, $fails, ...$fieldValidators); + } + + if ($failure) { + return null; + } + return new HttpRequest($request); + } + + public static function fromPayload(array &$fails, array $schema): ?HttpRequest { + $request_body = file_get_contents('php://input'); + $data = json_decode($request_body); + return self::from($data, $fails, $schema); + } + + public function offsetExists($offset): bool { + return isset($this->data[$offset]); + } + + public function offsetGet($offset) { + return $this->data[$offset]; + } + + public function offsetSet($offset, $value) { + throw new Exception("requests are immutable objects."); + } + + public function offsetUnset($offset) { + throw new Exception("requests are immutable objects."); + } +} \ No newline at end of file diff --git a/src/Http/HttpResponse.php b/src/Http/HttpResponse.php new file mode 100644 index 0000000..9f081a5 --- /dev/null +++ b/src/Http/HttpResponse.php @@ -0,0 +1,24 @@ +code = $code; + } + + public function getCode(): int { + return $this->code; + } + + public static function fromCode(int $code): HttpResponse { + return new HttpResponse($code); + } + +} \ No newline at end of file diff --git a/src/Http/JsonHttpResponse.php b/src/Http/JsonHttpResponse.php new file mode 100644 index 0000000..1c30017 --- /dev/null +++ b/src/Http/JsonHttpResponse.php @@ -0,0 +1,23 @@ +payload = $payload; + } + + public function getJson() { + return json_encode($this->payload); + } + +} \ No newline at end of file diff --git a/src/Http/ViewHttpResponse.php b/src/Http/ViewHttpResponse.php new file mode 100644 index 0000000..6950559 --- /dev/null +++ b/src/Http/ViewHttpResponse.php @@ -0,0 +1,49 @@ +kind = $kind; + $this->file = $file; + $this->arguments = $arguments; + } + + public function getViewKind(): int { + return $this->kind; + } + + public function getFile(): string { + return $this->file; + } + + public function getArguments(): array { + return $this->arguments; + } + + public static function twig(string $file, array $arguments, int $code = HttpCodes::OK): ViewHttpResponse { + return new ViewHttpResponse(self::TWIG_VIEW, $file, $arguments, $code); + } + + public static function react(string $file, array $arguments, int $code = HttpCodes::OK): ViewHttpResponse { + return new ViewHttpResponse(self::REACT_VIEW, $file, $arguments, $code); + } + +} \ No newline at end of file diff --git a/src/Model/TacticModel.php b/src/Model/TacticModel.php index 7f31572..c0b1ffe 100644 --- a/src/Model/TacticModel.php +++ b/src/Model/TacticModel.php @@ -4,9 +4,6 @@ namespace App\Model; use App\Data\TacticInfo; use App\Gateway\TacticInfoGateway; -use App\Validation\ValidationFail; -use App\Validation\Validation; -use App\Validation\Validators; class TacticModel { @@ -22,12 +19,8 @@ class TacticModel { $this->tactics = $tactics; } - public function makeNew(array &$fails, string $name): ?TacticInfo { - $failure = Validation::validate($name, "name", $fails, Validators::nonEmpty(), Validators::noInvalidChars()); - if ($failure) { - return null; - } - $this->tactics->insert($name); + public function makeNew(string $name): TacticInfo { + return $this->tactics->insert($name); } public function makeNewDefault(): ?TacticInfo { @@ -47,15 +40,15 @@ class TacticModel { * Update the name of a tactic * @param int $id the tactic identifier * @param string $name the new name to set + * @return true if the update was done successfully */ - public function updateName(array &$fails, int $id, string $name): void { - $failure = Validation::validate($name, "name", $fails, Validators::nonEmpty(), Validators::noInvalidChars()); - + public function updateName(int $id, string $name): bool { if ($this->tactics->get($id) == null) { - $fails[] = ValidationFail::notFound("$id is an unknown tactic identifier"); - } else if (!$failure) { - $this->tactics->updateName($id, $name); + return false; } + + $this->tactics->updateName($id, $name); + return true; } } \ No newline at end of file diff --git a/src/Validation/ComposedValidator.php b/src/Validation/ComposedValidator.php new file mode 100644 index 0000000..418b1ed --- /dev/null +++ b/src/Validation/ComposedValidator.php @@ -0,0 +1,24 @@ +first = $first; + $this->then = $then; + } + + public function validate(string $name, $val): array { + $firstFailures = $this->first->validate($name, $val); + $thenFailures = $this->then->validate($name, $val); + return array_merge($firstFailures, $thenFailures); + } +} \ No newline at end of file diff --git a/src/Validation/FieldValidationFail.php b/src/Validation/FieldValidationFail.php index cae1496..404a497 100644 --- a/src/Validation/FieldValidationFail.php +++ b/src/Validation/FieldValidationFail.php @@ -18,12 +18,10 @@ class FieldValidationFail extends ValidationFail { $this->fieldName = $fieldName; } - public function getFieldName(): string { return $this->fieldName; } - public static function invalidChars(string $fieldName): FieldValidationFail { return new FieldValidationFail($fieldName, "field contains illegal chars"); } @@ -32,6 +30,10 @@ class FieldValidationFail extends ValidationFail { return new FieldValidationFail($fieldName, "field is empty"); } + public static function missing(string $fieldName): FieldValidationFail { + return new FieldValidationFail($fieldName, "field is missing"); + } + public function jsonSerialize() { return ["field" => $this->fieldName, "message" => $this->getMessage()]; } diff --git a/src/Validation/SimpleFunctionValidator.php b/src/Validation/SimpleFunctionValidator.php index 4282916..101dfe6 100644 --- a/src/Validation/SimpleFunctionValidator.php +++ b/src/Validation/SimpleFunctionValidator.php @@ -5,24 +5,24 @@ namespace App\Validation; /** * A simple validator that takes a predicate and an error factory */ -class SimpleFunctionValidator implements Validator { +class SimpleFunctionValidator extends Validator { private $predicate; private $error_factory; /** * @param callable $predicate a function predicate with signature: `(string) => bool`, to validate the given string - * @param callable $error_factory a factory function with signature `(string) => Error)` to emit error when the predicate fails + * @param callable $errors_factory a factory function with signature `(string) => array` to emit failures when the predicate fails */ - public function __construct(callable $predicate, callable $error_factory) { + public function __construct(callable $predicate, callable $errors_factory) { $this->predicate = $predicate; - $this->error_factory = $error_factory; + $this->error_factory = $errors_factory; } - public function validate(string $name, $val): ?ValidationFail { + public function validate(string $name, $val): array { if (!call_user_func_array($this->predicate, [$val])) { return call_user_func_array($this->error_factory, [$name]); } - return null; + return []; } } \ No newline at end of file diff --git a/src/Validation/Validation.php b/src/Validation/Validation.php index da5dadd..659c8b0 100644 --- a/src/Validation/Validation.php +++ b/src/Validation/Validation.php @@ -11,16 +11,16 @@ class Validation { * Validate a value from validators, appending failures in the given errors array. * @param mixed $val the value to validate * @param string $val_name the name of the value - * @param array $errors array to push when a validator fails + * @param array $failures array to push when a validator fails * @param Validator ...$validators given validators * @return bool true if any of the given validators did fail */ - public static function validate($val, string $val_name, array &$errors, Validator...$validators): bool { + public static function validate($val, string $val_name, array &$failures, Validator...$validators): bool { $had_errors = false; foreach ($validators as $validator) { $error = $validator->validate($val_name, $val); if ($error != null) { - $errors[] = $error; + $failures[] = $error; $had_errors = true; } } diff --git a/src/Validation/Validator.php b/src/Validation/Validator.php index b7c77ce..6cdafb9 100644 --- a/src/Validation/Validator.php +++ b/src/Validation/Validator.php @@ -2,14 +2,23 @@ namespace App\Validation; -interface Validator { +abstract class Validator { /** * validates a variable string * @param string $name the name of the tested value * @param mixed $val the value to validate - * @return ValidationFail|null the error if the validator did fail, or null if it succeeded + * @return array the errors the validator has reported */ - public function validate(string $name, $val): ?ValidationFail; + public abstract function validate(string $name, $val): array; + + /** + * Creates a validator composed of this validator, and given validator + * @param Validator $other the second validator to chain with + * @return Validator a composed validator + */ + public function then(Validator $other): Validator { + return new ComposedValidator($this, $other); + } } \ No newline at end of file diff --git a/src/Validation/Validators.php b/src/Validation/Validators.php index 3bebb8c..ebf0f80 100644 --- a/src/Validation/Validators.php +++ b/src/Validation/Validators.php @@ -7,27 +7,26 @@ namespace App\Validation; */ class Validators { - /** - * @return Validator a validator that validates strings that does not contain invalid chars such as `<` and `>` + * @return Validator a validator that validates non-empty strings */ - public static function noInvalidChars(): Validator { + public static function nonEmpty(): Validator { return new SimpleFunctionValidator( - fn($str) => !filter_var($str, FILTER_VALIDATE_REGEXP, ['options' => ["regexp" => "/[<>]/"]]), - fn(string $name) => FieldValidationFail::invalidChars($name) + fn($str) => !empty($str), + fn(string $name) => [FieldValidationFail::empty($name)] ); } - /** - * @return Validator a validator that validates non-empty strings - */ - public static function nonEmpty(): Validator { + public static function shorterThan(int $limit): Validator { return new SimpleFunctionValidator( - fn($str) => !empty($str), - fn(string $name) => FieldValidationFail::empty($name) + fn(string $str) => strlen($str) <= $limit, + fn(string $name) => [new FieldValidationFail($name, "field is longer than $limit chars.")] ); } + public static function userString(int $maxLen): Validator { + return self::nonEmpty()->then(self::shorterThan($maxLen)); + } } -- 2.36.3 From b08e761abb9dffb01a5c42077b2114ee3068740e Mon Sep 17 00:00:00 2001 From: Override-6 Date: Sun, 12 Nov 2023 20:49:21 +0100 Subject: [PATCH 09/10] add diagrams, fix documentation --- Documentation/http.puml | 47 +++++++++++++++++ Documentation/validation.puml | 59 ++++++++++++++++++++++ src/Controller/Api/APITacticController.php | 2 +- src/Controller/Control.php | 17 ++++++- src/Controller/EditorController.php | 2 +- src/{ => Http}/HttpCodes.php | 3 +- src/Http/HttpRequest.php | 6 --- src/Http/JsonHttpResponse.php | 14 +++-- src/Http/ViewHttpResponse.php | 25 ++++++++- src/Validation/SimpleFunctionValidator.php | 10 ++-- src/Validation/Validation.php | 6 +-- src/Validation/Validators.php | 4 +- 12 files changed, 167 insertions(+), 28 deletions(-) create mode 100644 Documentation/http.puml create mode 100644 Documentation/validation.puml rename src/{ => Http}/HttpCodes.php (75%) diff --git a/Documentation/http.puml b/Documentation/http.puml new file mode 100644 index 0000000..b41135d --- /dev/null +++ b/Documentation/http.puml @@ -0,0 +1,47 @@ +@startuml + +class HttpRequest implements ArrayAccess { + - data: array + + __construct(data: array) + + + offsetExists(offset: mixed): bool + + offsetGet(offset: mixed): mixed + + offsetSet(offset: mixed, value: mixed) + + offsetUnset(offset: mixed) + + + from(request: array, fails: &array, schema: array): HttpRequest + + fromPayload(fails: &array, schema: array): HttpRequest +} + +class HttpResponse { + - code: int + + __construct(code: int) + + getCode(): int + + fromCode(code: int): HttpResponse +} + +class JsonHttpResponse extends HttpResponse { + - payload: mixed + + __construct(payload: mixed, code: int = HttpCodes::OK) + + getJson(): string +} + +class ViewHttpResponse extends HttpResponse { + + TWIG_VIEW: int {frozen} + + REACT_VIEW: int {frozen} + + - file: string + - arguments: array + - kind: int + + + __construct(kind: int, file: string, arguments: array, code: int = HttpCodes::OK) + + getViewKind(): int + + getFile(): string + + getArguments(): array + + + twig(file: string, arguments: array, code: int = HttpCodes::OK): ViewHttpResponse + + react(file: string, arguments: array, code: int = HttpCodes::OK): ViewHttpResponse +} + +@enduml \ No newline at end of file diff --git a/Documentation/validation.puml b/Documentation/validation.puml new file mode 100644 index 0000000..dd0cafe --- /dev/null +++ b/Documentation/validation.puml @@ -0,0 +1,59 @@ +@startuml + +abstract class Validator { + + validate(name: string, val: mixed): array + + then(other: Validator): Validator +} + +class ComposedValidator extends Validator { + - first: Validator + - then: Validator + + + __construct(first: Validator, then: Validator) + validate(name: string, val: mixed): array +} + +class SimpleFunctionValidator extends Validator { + - predicate: callable + - error_factory: callable + + + __construct(predicate: callable, errorsFactory: callable) + + validate(name: string, val: mixed): array +} + +class ValidationFail implements JsonSerialize { + - kind: string + - message: string + + + __construct(kind: string, message: string) + + getMessage(): string + + getKind(): string + + jsonSerialize() + + + notFound(message: string): ValidationFail +} + +class FieldValidationFail extends ValidationFail { + - fieldName: string + + __construct(fieldName: string, message: string) + + getFieldName(): string + + jsonSerialize() + + + invalidChars(fieldName: string): FieldValidationFail + + empty(fieldName: string): FieldValidationFail + + missing(fieldName: string): FieldValidationFail +} + + +class Validation { + + validate(val: mixed, valName: string, failures: &array, validators: Validator...): bool +} + +class Validators { + + nonEmpty(): Validator + + shorterThan(limit: int): Validator + + userString(maxLen: int): Validator +} + + +@enduml \ No newline at end of file diff --git a/src/Controller/Api/APITacticController.php b/src/Controller/Api/APITacticController.php index a1f7767..031c88e 100644 --- a/src/Controller/Api/APITacticController.php +++ b/src/Controller/Api/APITacticController.php @@ -3,10 +3,10 @@ namespace App\Controller\Api; use App\Controller\Control; +use App\Http\HttpCodes; use App\Http\HttpRequest; use App\Http\HttpResponse; use App\Http\JsonHttpResponse; -use App\HttpCodes; use App\Model\TacticModel; use App\Validation\Validators; diff --git a/src/Controller/Control.php b/src/Controller/Control.php index af1a38b..327a475 100644 --- a/src/Controller/Control.php +++ b/src/Controller/Control.php @@ -2,13 +2,20 @@ namespace App\Controller; +use App\Http\HttpCodes; use App\Http\HttpRequest; use App\Http\HttpResponse; use App\Http\JsonHttpResponse; -use App\HttpCodes; class Control { + /** + * Runs given callback, if the request's json validates the given schema. + * @param array $schema an array of `fieldName => Validators` which represents the request object schema + * @param callable $run the callback to run if the request is valid according to the given schema. + * THe callback must accept an HttpRequest, and return an HttpResponse object. + * @return HttpResponse + */ public static function runChecked(array $schema, callable $run): HttpResponse { $request_body = file_get_contents('php://input'); $payload = get_object_vars(json_decode($request_body)); @@ -16,6 +23,14 @@ class Control { return self::runCheckedFrom($payload, $schema, $run); } + /** + * Runs given callback, if the given request data array validates the given schema. + * @param array $data the request's data array. + * @param array $schema an array of `fieldName => Validators` which represents the request object schema + * @param callable $run the callback to run if the request is valid according to the given schema. + * THe callback must accept an HttpRequest, and return an HttpResponse object. + * @return HttpResponse + */ public static function runCheckedFrom(array $data, array $schema, callable $run): HttpResponse { $fails = []; $request = HttpRequest::from($data, $fails, $schema); diff --git a/src/Controller/EditorController.php b/src/Controller/EditorController.php index cf71d9d..96b8125 100644 --- a/src/Controller/EditorController.php +++ b/src/Controller/EditorController.php @@ -3,10 +3,10 @@ namespace App\Controller; use App\Data\TacticInfo; +use App\Http\HttpCodes; use App\Http\HttpResponse; use App\Http\JsonHttpResponse; use App\Http\ViewHttpResponse; -use App\HttpCodes; use App\Model\TacticModel; class EditorController { diff --git a/src/HttpCodes.php b/src/Http/HttpCodes.php similarity index 75% rename from src/HttpCodes.php rename to src/Http/HttpCodes.php index 6bcfa5f..b41af8a 100644 --- a/src/HttpCodes.php +++ b/src/Http/HttpCodes.php @@ -1,6 +1,6 @@ data[$offset]); } diff --git a/src/Http/JsonHttpResponse.php b/src/Http/JsonHttpResponse.php index 1c30017..9d7423f 100644 --- a/src/Http/JsonHttpResponse.php +++ b/src/Http/JsonHttpResponse.php @@ -2,10 +2,11 @@ namespace App\Http; -use App\HttpCodes; - class JsonHttpResponse extends HttpResponse { + /** + * @var mixed Any JSON serializable value + */ private $payload; /** @@ -16,8 +17,13 @@ class JsonHttpResponse extends HttpResponse { $this->payload = $payload; } - public function getJson() { - return json_encode($this->payload); + public function getJson(): string { + $result = json_encode($this->payload); + if (!$result) { + throw new \RuntimeException("Given payload is not json encodable"); + } + + return $result; } } \ No newline at end of file diff --git a/src/Http/ViewHttpResponse.php b/src/Http/ViewHttpResponse.php index 6950559..0e92054 100644 --- a/src/Http/ViewHttpResponse.php +++ b/src/Http/ViewHttpResponse.php @@ -2,15 +2,22 @@ namespace App\Http; -use App\HttpCodes; - class ViewHttpResponse extends HttpResponse { public const TWIG_VIEW = 0; public const REACT_VIEW = 1; + /** + * @var string File path of the responded view + */ private string $file; + /** + * @var array View arguments + */ private array $arguments; + /** + * @var int Kind of view, see {@link self::TWIG_VIEW} and {@link self::REACT_VIEW} + */ private int $kind; /** @@ -38,10 +45,24 @@ class ViewHttpResponse extends HttpResponse { return $this->arguments; } + /** + * Create a twig view response + * @param string $file + * @param array $arguments + * @param int $code + * @return ViewHttpResponse + */ public static function twig(string $file, array $arguments, int $code = HttpCodes::OK): ViewHttpResponse { return new ViewHttpResponse(self::TWIG_VIEW, $file, $arguments, $code); } + /** + * Create a react view response + * @param string $file + * @param array $arguments + * @param int $code + * @return ViewHttpResponse + */ public static function react(string $file, array $arguments, int $code = HttpCodes::OK): ViewHttpResponse { return new ViewHttpResponse(self::REACT_VIEW, $file, $arguments, $code); } diff --git a/src/Validation/SimpleFunctionValidator.php b/src/Validation/SimpleFunctionValidator.php index 101dfe6..079452d 100644 --- a/src/Validation/SimpleFunctionValidator.php +++ b/src/Validation/SimpleFunctionValidator.php @@ -8,20 +8,20 @@ namespace App\Validation; class SimpleFunctionValidator extends Validator { private $predicate; - private $error_factory; + private $errorFactory; /** * @param callable $predicate a function predicate with signature: `(string) => bool`, to validate the given string - * @param callable $errors_factory a factory function with signature `(string) => array` to emit failures when the predicate fails + * @param callable $errorsFactory a factory function with signature `(string) => array` to emit failures when the predicate fails */ - public function __construct(callable $predicate, callable $errors_factory) { + public function __construct(callable $predicate, callable $errorsFactory) { $this->predicate = $predicate; - $this->error_factory = $errors_factory; + $this->errorFactory = $errorsFactory; } public function validate(string $name, $val): array { if (!call_user_func_array($this->predicate, [$val])) { - return call_user_func_array($this->error_factory, [$name]); + return call_user_func_array($this->errorFactory, [$name]); } return []; } diff --git a/src/Validation/Validation.php b/src/Validation/Validation.php index 659c8b0..b797edc 100644 --- a/src/Validation/Validation.php +++ b/src/Validation/Validation.php @@ -10,15 +10,15 @@ class Validation { /** * Validate a value from validators, appending failures in the given errors array. * @param mixed $val the value to validate - * @param string $val_name the name of the value + * @param string $valName the name of the value * @param array $failures array to push when a validator fails * @param Validator ...$validators given validators * @return bool true if any of the given validators did fail */ - public static function validate($val, string $val_name, array &$failures, Validator...$validators): bool { + public static function validate($val, string $valName, array &$failures, Validator...$validators): bool { $had_errors = false; foreach ($validators as $validator) { - $error = $validator->validate($val_name, $val); + $error = $validator->validate($valName, $val); if ($error != null) { $failures[] = $error; $had_errors = true; diff --git a/src/Validation/Validators.php b/src/Validation/Validators.php index ebf0f80..11d8fbd 100644 --- a/src/Validation/Validators.php +++ b/src/Validation/Validators.php @@ -28,6 +28,4 @@ class Validators { return self::nonEmpty()->then(self::shorterThan($maxLen)); } -} - - +} \ No newline at end of file -- 2.36.3 From 6ee05c7916b36bd619ef5650f2b95b7940a4d1b3 Mon Sep 17 00:00:00 2001 From: Override-6 Date: Mon, 13 Nov 2023 23:11:39 +0100 Subject: [PATCH 10/10] add more validations, handle invalid json payloads --- src/Controller/Api/APITacticController.php | 4 +- src/Controller/Control.php | 8 +++- src/Controller/EditorController.php | 1 + src/Controller/SampleFormController.php | 8 ++-- src/Validation/FunctionValidator.php | 19 +++++++++ src/Validation/ValidationFail.php | 3 +- src/Validation/Validators.php | 45 ++++++++++++++++------ 7 files changed, 68 insertions(+), 20 deletions(-) create mode 100644 src/Validation/FunctionValidator.php diff --git a/src/Controller/Api/APITacticController.php b/src/Controller/Api/APITacticController.php index 031c88e..f775ecf 100644 --- a/src/Controller/Api/APITacticController.php +++ b/src/Controller/Api/APITacticController.php @@ -25,7 +25,7 @@ class APITacticController { public function updateName(int $tactic_id): HttpResponse { return Control::runChecked([ - "name" => [Validators::userString(32)] + "name" => [Validators::lenBetween(1, 50), Validators::nameWithSpaces()] ], function (HttpRequest $request) use ($tactic_id) { $this->model->updateName($tactic_id, $request["name"]); return HttpResponse::fromCode(HttpCodes::OK); @@ -34,7 +34,7 @@ class APITacticController { public function newTactic(): HttpResponse { return Control::runChecked([ - "name" => [Validators::userString(32)] + "name" => [Validators::lenBetween(1, 50), Validators::nameWithSpaces()] ], function (HttpRequest $request) { $tactic = $this->model->makeNew($request["name"]); $id = $tactic->getId(); diff --git a/src/Controller/Control.php b/src/Controller/Control.php index 327a475..2aacb19 100644 --- a/src/Controller/Control.php +++ b/src/Controller/Control.php @@ -6,6 +6,7 @@ use App\Http\HttpCodes; use App\Http\HttpRequest; use App\Http\HttpResponse; use App\Http\JsonHttpResponse; +use App\Validation\ValidationFail; class Control { @@ -18,8 +19,11 @@ class Control { */ public static function runChecked(array $schema, callable $run): HttpResponse { $request_body = file_get_contents('php://input'); - $payload = get_object_vars(json_decode($request_body)); - + $payload_obj = json_decode($request_body); + if (!$payload_obj instanceof \stdClass) { + return new JsonHttpResponse([new ValidationFail("bad-payload", "request body is not a valid json object"), HttpCodes::BAD_REQUEST]); + } + $payload = get_object_vars($payload_obj); return self::runCheckedFrom($payload, $schema, $run); } diff --git a/src/Controller/EditorController.php b/src/Controller/EditorController.php index 96b8125..bf5dccc 100644 --- a/src/Controller/EditorController.php +++ b/src/Controller/EditorController.php @@ -4,6 +4,7 @@ namespace App\Controller; use App\Data\TacticInfo; use App\Http\HttpCodes; +use App\Http\HttpRequest; use App\Http\HttpResponse; use App\Http\JsonHttpResponse; use App\Http\ViewHttpResponse; diff --git a/src/Controller/SampleFormController.php b/src/Controller/SampleFormController.php index b65b265..bbf1f59 100644 --- a/src/Controller/SampleFormController.php +++ b/src/Controller/SampleFormController.php @@ -32,12 +32,12 @@ class SampleFormController { private function submitForm(array $form, callable $response): HttpResponse { return Control::runCheckedFrom($form, [ - "name" => [Validators::userString(32)], - "description" => [Validators::userString(512)] + "name" => [Validators::lenBetween(0, 32), Validators::name()], + "description" => [Validators::lenBetween(0, 512)] ], function (HttpRequest $req) use ($response) { - $this->gateway->insert($req["name"], $req["description"]); + $description = htmlspecialchars($req["description"]); + $this->gateway->insert($req["name"], $description); $results = ["results" => $this->gateway->listResults()]; - return call_user_func_array($response, [$results]); }); } diff --git a/src/Validation/FunctionValidator.php b/src/Validation/FunctionValidator.php new file mode 100644 index 0000000..6874d63 --- /dev/null +++ b/src/Validation/FunctionValidator.php @@ -0,0 +1,19 @@ +validate_fn = $validate_fn; + } + + public function validate(string $name, $val): array { + return call_user_func_array($this->validate_fn, [$name, $val]); + } +} \ No newline at end of file diff --git a/src/Validation/ValidationFail.php b/src/Validation/ValidationFail.php index 9daded8..fa5139c 100644 --- a/src/Validation/ValidationFail.php +++ b/src/Validation/ValidationFail.php @@ -11,7 +11,7 @@ class ValidationFail implements \JsonSerializable { * @param string $message * @param string $kind */ - protected function __construct(string $kind, string $message) { + public function __construct(string $kind, string $message) { $this->message = $message; $this->kind = $kind; } @@ -31,4 +31,5 @@ class ValidationFail implements \JsonSerializable { public static function notFound(string $message): ValidationFail { return new ValidationFail("not found", $message); } + } \ No newline at end of file diff --git a/src/Validation/Validators.php b/src/Validation/Validators.php index 11d8fbd..c9172b1 100644 --- a/src/Validation/Validators.php +++ b/src/Validation/Validators.php @@ -8,24 +8,47 @@ namespace App\Validation; class Validators { /** - * @return Validator a validator that validates non-empty strings + * @return Validator a validator that validates a given regex */ - public static function nonEmpty(): Validator { + public static function regex(string $regex): Validator { return new SimpleFunctionValidator( - fn($str) => !empty($str), - fn(string $name) => [FieldValidationFail::empty($name)] + fn(string $str) => preg_match($regex, $str), + fn(string $name) => [new FieldValidationFail($name, "field does not validates pattern $regex")] ); } - public static function shorterThan(int $limit): Validator { - return new SimpleFunctionValidator( - fn(string $str) => strlen($str) <= $limit, - fn(string $name) => [new FieldValidationFail($name, "field is longer than $limit chars.")] - ); + /** + * @return Validator a validator that validates strings that only contains numbers, letters, accents letters, `-` and `_`. + */ + public static function name(): Validator { + return self::regex("/^[0-9a-zA-Zà-üÀ-Ü_-]*$/"); } - public static function userString(int $maxLen): Validator { - return self::nonEmpty()->then(self::shorterThan($maxLen)); + /** + * @return Validator a validator that validates strings that only contains numbers, letters, accents letters, `-`, `_` and spaces. + */ + public static function nameWithSpaces(): Validator { + return self::regex("/^[0-9a-zA-Zà-üÀ-Ü _-]*$/"); } + /** + * Validate string if its length is between given range + * @param int $min minimum accepted length, inclusive + * @param int $max maximum accepted length, exclusive + * @return Validator + */ + public static function lenBetween(int $min, int $max): Validator { + return new FunctionValidator( + function (string $fieldName, string $str) use ($min, $max) { + $len = strlen($str); + if ($len >= $max) { + return [new FieldValidationFail($fieldName, "field is longer than $max chars.")]; + } + if ($len < $min) { + return [new FieldValidationFail($fieldName, "field is shorted than $min chars.")]; + } + return []; + } + ); + } } \ No newline at end of file -- 2.36.3