From c80da13690dc7b9654245d7b01f0ac40efcbf6c2 Mon Sep 17 00:00:00 2001 From: Samuel Shuert Date: Wed, 10 Dec 2025 20:19:48 +0000 Subject: [PATCH] feat: Dec10 Change-Id: vpwzwsyztzpmuxqxrprlmklvormwtqwv --- server/books.json | 1 + server/books.ts | 288 ++++++++++++++++++++++++++++++++++++++++++++++ server/index.ts | 144 +---------------------- server/tasks.json | 1 - server/tasks.ts | 146 +++++++++++++++++++++++ 5 files changed, 439 insertions(+), 141 deletions(-) create mode 100644 server/books.json create mode 100644 server/books.ts create mode 100644 server/tasks.ts diff --git a/server/books.json b/server/books.json new file mode 100644 index 0000000..30d0cb4 --- /dev/null +++ b/server/books.json @@ -0,0 +1 @@ +[{"id":"9780553212471","title":"Frankenstein","author":"Mary Shelley"},{"id":"9780060935467","title":"To Kill a Mockingbird","author":"Harper Lee"},{"id":"9780141439518","title":"Pride and Prejudice","author":"Jane Austen"}] \ No newline at end of file diff --git a/server/books.ts b/server/books.ts new file mode 100644 index 0000000..27c0729 --- /dev/null +++ b/server/books.ts @@ -0,0 +1,288 @@ +import express, { + type NextFunction, + type Request, + type Response, +} from "express"; +import { writeFile, readFile, exists } from "fs/promises"; + +const ISBN13 = + /^(?:ISBN(?:-13)?:? )?(?=[0-9]{13}$|(?=(?:[0-9]+[- ]){4})[- 0-9]{17}$)[\d-]+$/; + +interface Book { + id: string; + title: string; + author: string; +} + +const initBooks: () => Promise = async () => { + await writeFile( + "books.json", + JSON.stringify([ + { + id: "9780553212471", + title: "Frankenstein", + author: "Mary Shelley", + }, + { + id: "9780060935467", + title: "To Kill a Mockingbird", + author: "Harper Lee", + }, + { + id: "9780141439518", + title: "Pride and Prejudice", + author: "Jane Austen", + }, + ]), + ); +}; + +enum ErrorType { + NotFound, + InvalidId, + BadData, + AlreadyExists, +} + +class BookError extends Error { + public readonly status: number; + constructor(err: ErrorType) { + let msg: string; + let st: number; + switch (err) { + case ErrorType.NotFound: + msg = "Book {{id}} not found"; + st = 404; + break; + case ErrorType.InvalidId: + msg = "Invalid book id ({{id}}) [must be ISBN-13 formatted]"; + st = 400; + break; + case ErrorType.BadData: + msg = "Invalid book data"; + st = 400; + break; + case ErrorType.AlreadyExists: + msg = "Book with id {{id}} already exists"; + st = 409; + break; + } + super(msg); + this.name = "BookError"; + this.status = st; + } +} + +const getBooks: () => Promise = async () => { + if (!(await exists("books.json"))) { + await initBooks(); + } + const file = await readFile("books.json", "utf-8"); + if (file.length < 4) { + await initBooks(); + return await getBooks(); + } + return JSON.parse(file); +}; + +const updateBook = async (task: Book): Promise => { + const books = await getBooks(); + const index = books.findIndex((b) => b.id === task.id); + if (index !== -1) { + books[index] = task; + } else { + books.push(task); + } + await writeFile("books.json", JSON.stringify(books)); +}; + +const removeBook = async (id: string): Promise => { + const books = await getBooks(); + const index = books.findIndex((b) => b.id === id); + if (index !== -1) { + books.splice(index, 1); + await writeFile("books.json", JSON.stringify(books)); + } +}; + +class BadDataIssues extends Error { + missingKeys: string[]; + extraKeys: string[]; + badValues: [string, string][]; + + constructor( + missingKeys: string[], + extraKeys: string[], + badValues: [string, string][], + ) { + super("Bad data issues"); + this.missingKeys = missingKeys; + this.extraKeys = extraKeys; + this.badValues = badValues; + } +} + +const keyTypes = { + id: "ISBN13 code", + title: "string", + author: "string", +}; + +const validateBook = (task: { [key: string]: any }): Book => { + let missingKeys = ["id", "title", "author"].filter( + (key) => !Object.keys(task).includes(key), + ); + let extraKeys = Object.keys(task).filter( + (key) => !["id", "title", "author"].includes(key), + ); + let badValues = Object.entries(task) + .filter(([key, value]) => { + if (key === "id") return typeof value !== "string" || !ISBN13.test(value); + if (key === "title") return typeof value !== "string"; + if (key === "author") return typeof value !== "string"; + return false; + }) + .map( + ([key, _value]) => + [key, keyTypes[key as keyof typeof keyTypes]] as [string, string], + ); + if (missingKeys.length > 0 || extraKeys.length > 0 || badValues.length > 0) { + throw new BadDataIssues(missingKeys, extraKeys, badValues); + } + return task as Book; +}; + +const auth = async (req: Request, res: Response, next: NextFunction) => { + if (req.method === "GET") { + next(); + return; + } + if (!req.headers.authorization) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + let token = req.headers.authorization.split(" ")[1]; + if (token !== "password1!") { + res.status(401).json({ error: "Unauthorized" }); + return; + } + next(); +}; + +const errorHandler = ( + err: Error, + _req: Request, + res: Response, + _next: NextFunction, +) => { + if (err instanceof BookError) { + let msg = err.message.replace("{{id}}", res.locals.id ?? ""); + + let obj: Map = new Map([ + ["error", `${err.name}: ${msg}`], + ]); + + if (res.locals.bdi) { + if (res.locals.bdi.missingKeys.length > 0) { + obj.set("missingKeys", res.locals.bdi.missingKeys); + } + if (res.locals.bdi.extraKeys.length > 0) { + obj.set("extraKeys", res.locals.bdi.extraKeys); + } + if (res.locals.bdi.badValues.length > 0) { + obj.set("badValues", res.locals.bdi.badValues); + } + } + + res.status(err.status).json(Object.fromEntries(obj.entries())); + } else { + console.error(err.stack); + res.status(500).json({ error: "Internal Server Error" }); + } +}; + +const router = express.Router(); + +router.use(express.json()); +router.use((req, res, next) => { + console.log(`Recieved a ${req.method} request to ${req.url}`); + next(); +}); +router.use(auth); + +router.get("/", async (_req, res) => { + res.json(await getBooks()); +}); + +router.post("/", async (req, res) => { + const books = await getBooks(); + try { + const bookData = validateBook(req.body); + res.locals.id = bookData.id; + if (books.filter((b) => b.id === bookData.id).length > 0) { + throw new BookError(ErrorType.AlreadyExists); + } + await updateBook(bookData); + res.status(201).json(bookData); + } catch (err) { + if (err instanceof BookError) { + throw err; + } else if (err instanceof BadDataIssues) { + res.locals.bdi = err; + throw new BookError(ErrorType.BadData); + } else { + res.status(500).json({ error: "Internal Server Error" }); + } + } +}); + +router.get("/:id", async (req, res) => { + res.locals.id = req.params.id; + if (!ISBN13.test(req.params.id)) { + throw new BookError(ErrorType.InvalidId); + } + const books = await getBooks(); + const book = books.find((b) => b.id == req.params.id); + if (!book) throw new BookError(ErrorType.NotFound); + res.json(book); +}); + +router.put("/:id", async (req, res) => { + res.locals.id = req.params.id; + if (!ISBN13.test(req.params.id)) { + throw new BookError(ErrorType.InvalidId); + } + const books = await getBooks(); + const book = books.find((b) => b.id == req.params.id); + if (!book) throw new BookError(ErrorType.NotFound); + const bookData = validateBook(req.body); + await updateBook(bookData); + res.sendStatus(204); +}); + +router.delete("/reset", async (_req, res) => { + await initBooks(); + res.sendStatus(204); +}); + +router.delete("/:id", async (req, res) => { + res.locals.id = req.params.id; + if (!ISBN13.test(req.params.id)) { + throw new BookError(ErrorType.InvalidId); + } + const books = await getBooks(); + const book = books.find((b) => b.id == req.params.id); + if (!book) throw new BookError(ErrorType.NotFound); + await removeBook(book.id); + res.sendStatus(204); +}); + +router.all("/{*splat}", async (req, res) => { + res + .status(404) + .json({ error: `path: ${req.method} at /${req.params.splat} Not Found` }); +}); + +router.use(errorHandler); + +export default router; diff --git a/server/index.ts b/server/index.ts index 9b71bde..6dbf1a2 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,148 +1,12 @@ import express from "express"; -import { readFile, writeFile, exists } from "fs/promises"; +import tasks from "./tasks.ts"; +import books from "./books.ts"; const app = express(); const port = process.env["NODE_PORT"] ?? 5173; -interface Task { - id: string; - title: string; - completed: boolean; -} - -const getTasks: () => Promise = async () => { - if (!(await exists("tasks.json"))) { - await writeFile("tasks.json", JSON.stringify([])); - } - const file = await readFile("tasks.json", "utf-8"); - if (file.length === 0) { - return []; - } - return JSON.parse(file); -}; - -const updateTask = async (task: Task): Promise => { - const tasks = await getTasks(); - const index = tasks.findIndex((t) => t.id === task.id); - if (index !== -1) { - tasks[index] = task; - } else { - tasks.push(task); - } - await writeFile("tasks.json", JSON.stringify(tasks)); -}; - -const removeTask = async (id: string): Promise => { - const tasks = await getTasks(); - const index = tasks.findIndex((t) => t.id === id); - if (index !== -1) { - tasks.splice(index, 1); - await writeFile("tasks.json", JSON.stringify(tasks)); - } -}; - -app.use(express.json()); - -app.get("/tasks", async (_req, res) => { - res.json(await getTasks()); -}); - -app.post("/tasks", async (req, res) => { - if (!(typeof req.body.title === "string")) { - res.status(400).send("Invalid title"); - return; - } - const newTask = { - id: Math.random().toString(16).substring(2, 8), - title: req.body.title, - completed: false, - }; - await updateTask(newTask); - res.json(newTask).status(201); -}); - -app.get("/tasks/:id", async (req, res) => { - const task = (await getTasks()).find((t) => t.id === req.params.id); - if (!task) { - res.status(404).send("Task not found"); - } else { - res.json(task); - } -}); - -app.put("/tasks/:id", async (req, res) => { - const task = (await getTasks()).find((t) => t.id === req.params.id); - if (!task) { - res.status(404).send("Task not found"); - } else { - const missing = []; - if (req.body.title === undefined) missing.push("title"); - if (req.body.completed === undefined) missing.push("completed"); - if (missing.length > 0) { - res - .status(400) - .send( - `Missing field${missing.length > 1 ? "s" : ""}: ${missing.join(", ")}`, - ); - } - const badTypes = []; - if (!(typeof req.body.title === "string")) badTypes.push("title"); - if (!(typeof req.body.completed === "boolean")) badTypes.push("completed"); - if (badTypes.length > 0) { - res - .status(400) - .send( - `Invalid type${badTypes.length > 1 ? "s" : ""}: ${badTypes.join(", ")}`, - ); - return; - } - task.title = req.body.title ?? task.title; - task.completed = req.body.completed ?? task.completed; - await updateTask(task); - res.json(task); - } -}); - -app.patch("/tasks/:id", async (req, res) => { - const task = (await getTasks()).find((t) => t.id === req.params.id); - if (!task) { - res.status(404).send("Task not found"); - } else { - const badTypes = []; - if ( - Object.keys(req.body).includes("title") && - !(typeof req.body.title === "string") - ) - badTypes.push("title"); - if ( - Object.keys(req.body).includes("completed") && - !(typeof req.body.completed === "boolean") - ) - badTypes.push("completed"); - if (badTypes.length > 0) { - res - .status(400) - .send( - `Invalid type${badTypes.length > 1 ? "s" : ""}: ${badTypes.join(", ")}`, - ); - return; - } - task.title = req.body.title ?? task.title; - task.completed = req.body.completed ?? task.completed; - await updateTask(task); - res.json(task); - } -}); - -app.delete("/tasks/:id", async (req, res) => { - const task = (await getTasks()).find((t) => t.id === req.params.id); - if (!task) { - res.status(404).send("Task not found"); - } else { - await removeTask(task.id); - res.status(204).send(); - } -}); +app.use("/tasks", tasks); +app.use("/books", books); app.listen(port, () => { console.log(`Server listening on port ${port}`); diff --git a/server/tasks.json b/server/tasks.json index 9ac863f..e69de29 100644 --- a/server/tasks.json +++ b/server/tasks.json @@ -1 +0,0 @@ -[{"id":"2d516f","title":"","completed":4},{"id":"a4ec84","title":"","completed":4},{"id":"a667ea","title":"","completed":4},{"id":"b4d420","title":"4e4tta475aj","completed":false}] \ No newline at end of file diff --git a/server/tasks.ts b/server/tasks.ts new file mode 100644 index 0000000..c3615fd --- /dev/null +++ b/server/tasks.ts @@ -0,0 +1,146 @@ +import express from "express"; +import { readFile, writeFile, exists } from "fs/promises"; + +const router = express.Router(); + +interface Task { + id: string; + title: string; + completed: boolean; +} + +const getTasks: () => Promise = async () => { + if (!(await exists("tasks.json"))) { + await writeFile("tasks.json", JSON.stringify([])); + } + const file = await readFile("tasks.json", "utf-8"); + if (file.length === 0) { + return []; + } + return JSON.parse(file); +}; + +const updateTask = async (task: Task): Promise => { + const tasks = await getTasks(); + const index = tasks.findIndex((t) => t.id === task.id); + if (index !== -1) { + tasks[index] = task; + } else { + tasks.push(task); + } + await writeFile("tasks.json", JSON.stringify(tasks)); +}; + +const removeTask = async (id: string): Promise => { + const tasks = await getTasks(); + const index = tasks.findIndex((t) => t.id === id); + if (index !== -1) { + tasks.splice(index, 1); + await writeFile("tasks.json", JSON.stringify(tasks)); + } +}; + +router.use(express.json()); + +router.get("/", async (_req, res) => { + res.json(await getTasks()); +}); + +router.post("/", async (req, res) => { + if (!(typeof req.body.title === "string")) { + res.status(400).send("Invalid title"); + return; + } + const newTask = { + id: Math.random().toString(16).substring(2, 8), + title: req.body.title, + completed: false, + }; + await updateTask(newTask); + res.json(newTask).status(201); +}); + +router.get("/:id", async (req, res) => { + const task = (await getTasks()).find((t) => t.id === req.params.id); + if (!task) { + res.status(404).send("Task not found"); + } else { + res.json(task); + } +}); + +router.put("/:id", async (req, res) => { + const task = (await getTasks()).find((t) => t.id === req.params.id); + if (!task) { + res.status(404).send("Task not found"); + } else { + const missing = []; + if (req.body.title === undefined) missing.push("title"); + if (req.body.completed === undefined) missing.push("completed"); + if (missing.length > 0) { + res + .status(400) + .send( + `Missing field${missing.length > 1 ? "s" : ""}: ${missing.join(", ")}`, + ); + } + const badTypes = []; + if (!(typeof req.body.title === "string")) badTypes.push("title"); + if (!(typeof req.body.completed === "boolean")) badTypes.push("completed"); + if (badTypes.length > 0) { + res + .status(400) + .send( + `Invalid type${badTypes.length > 1 ? "s" : ""}: ${badTypes.join(", ")}`, + ); + return; + } + task.title = req.body.title ?? task.title; + task.completed = req.body.completed ?? task.completed; + await updateTask(task); + res.json(task); + } +}); + +router.patch("/:id", async (req, res) => { + const task = (await getTasks()).find((t) => t.id === req.params.id); + if (!task) { + res.status(404).send("Task not found"); + } else { + const badTypes = []; + if ( + Object.keys(req.body).includes("title") && + !(typeof req.body.title === "string") + ) + badTypes.push("title"); + if ( + Object.keys(req.body).includes("completed") && + !(typeof req.body.completed === "boolean") + ) + badTypes.push("completed"); + if (badTypes.length > 0) { + res + .status(400) + .send( + `Invalid type${badTypes.length > 1 ? "s" : ""}: ${badTypes.join(", ")}`, + ); + return; + } + task.title = req.body.title ?? task.title; + task.completed = req.body.completed ?? task.completed; + await updateTask(task); + res.json(task); + } +}); + +router.delete("/:id", async (req, res) => { + const task = (await getTasks()).find((t) => t.id === req.params.id); + if (!task) { + res.status(404).send("Task not found"); + } else { + await removeTask(task.id); + res.status(204).send(); + } +}); + +export default router; -- 2.43.0