diff --git a/lecture_5/dinosaurs/dinosaurs.http b/lecture_5/dinosaurs/dinosaurs.http index ed7f7b2..473a703 100644 --- a/lecture_5/dinosaurs/dinosaurs.http +++ b/lecture_5/dinosaurs/dinosaurs.http @@ -9,3 +9,29 @@ GET http://localhost:3000/dinosaurs/not-a-number ### Missing dinosaur GET http://localhost:3000/dinosaurs/999999 + + +### Create a dinosaur +POST http://localhost:3000/dinosaurs +Content-Type: application/json + +{ + "name": "Triceratops", + "description" : "Triceratops (neboli „třírohá tvář“) …", + "period": "křída", + "wikipediaAddress": "https://cs.wikipedia.org/wiki/Triceratops" +} + +### Replace a dinosaur +PUT http://localhost:3000/dinosaurs/16 +Content-Type: application/json + +{ + "name": "Tyrannosaurus", + "description": "Tyrannosaurus …", + "period": "křída", + "wikipediaAddress": "https://en.wikipedia.org/wiki/Tyrannosaurus" +} + +### Delete a dinosaur +DELETE http://localhost:3000/dinosaurs/2 diff --git a/lecture_5/dinosaurs/old-index.js b/lecture_5/dinosaurs/old-index.js index 02c4a1f..fbf8ab7 100644 --- a/lecture_5/dinosaurs/old-index.js +++ b/lecture_5/dinosaurs/old-index.js @@ -13,7 +13,7 @@ const client = new Client({ const app = express(); -app.get("/dinosaurs", async (request, response) => { +app.get("/dinosaurs", async (_request, response) => { const result = await client.query("SELECT * FROM dinosaur ORDER BY id"); const dinosaurs = result.rows.map((row) => ({ id: row.id, diff --git a/lecture_5/dinosaurs/package-lock.json b/lecture_5/dinosaurs/package-lock.json index 6963366..98077cc 100644 --- a/lecture_5/dinosaurs/package-lock.json +++ b/lecture_5/dinosaurs/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "dotenv": "^17.4.2", "express": "^5.1.0", - "pg": "^8.11.3" + "pg": "^8.11.3", + "zod": "^4.4.3" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -1791,6 +1792,14 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/lecture_5/dinosaurs/package.json b/lecture_5/dinosaurs/package.json index 2d9327f..a6c7a95 100644 --- a/lecture_5/dinosaurs/package.json +++ b/lecture_5/dinosaurs/package.json @@ -11,7 +11,8 @@ "dependencies": { "dotenv": "^17.4.2", "express": "^5.1.0", - "pg": "^8.11.3" + "pg": "^8.11.3", + "zod": "^4.4.3" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/lecture_5/dinosaurs/src/repositories/dinosaurRepository.js b/lecture_5/dinosaurs/src/repositories/dinosaurRepository.js index c2819f8..ba826a5 100644 --- a/lecture_5/dinosaurs/src/repositories/dinosaurRepository.js +++ b/lecture_5/dinosaurs/src/repositories/dinosaurRepository.js @@ -55,4 +55,54 @@ export const dinosaurRepository = { return mapRowToDinosaurDto(result.rows[0]); }, + + /** + * Creates a dinosaur and returns its generated ID. + * + * @param {DinosaurWithoutId} dinosaur + * @returns {Promise} + */ + async createDinosaur(dinosaur) { + const result = await client.query( + `INSERT INTO dinosaur (name, description, period, wikipedia_address) + VALUES ($1, $2, $3, $4) + RETURNING id`, + [dinosaur.name, dinosaur.description, dinosaur.period, dinosaur.wikipediaAddress], + ); + + return result.rows[0].id; + }, + + /** + * Updates a dinosaur by its ID. + * + * @param {number} id + * @param {DinosaurWithoutId} dinosaur + * @returns {Promise} + */ + async updateDinosaurById(id, dinosaur) { + const result = await client.query( + `UPDATE dinosaur + SET name = $2, + description = $3, + period = $4, + wikipedia_address = $5 + WHERE id = $1`, + [id, dinosaur.name, dinosaur.description, dinosaur.period, dinosaur.wikipediaAddress], + ); + + return result.rowCount > 0; + }, + + /** + * Deletes a dinosaur by its ID. + * + * @param {number} id + * @returns {Promise} + */ + async deleteDinosaurById(id) { + const result = await client.query("DELETE FROM dinosaur WHERE id = $1", [id]); + + return result.rowCount > 0; + }, }; diff --git a/lecture_5/dinosaurs/src/routes/dinosaurRoutes.js b/lecture_5/dinosaurs/src/routes/dinosaurRoutes.js index 8c4bada..ec5eb9a 100644 --- a/lecture_5/dinosaurs/src/routes/dinosaurRoutes.js +++ b/lecture_5/dinosaurs/src/routes/dinosaurRoutes.js @@ -1,8 +1,16 @@ import express from "express"; +import { dinosaurIdParamSchema, dinosaurBodySchema } from "../validation/dinosaurSchemas.js"; import { dinosaurRepository } from "../repositories/dinosaurRepository.js"; export const dinosaurRouter = express.Router(); +const formatValidationErrors = (issues) => { + return issues.map((issue) => ({ + field: issue.path.join("."), + message: issue.message, + })); +}; + // Return all dinosaurs through the repository abstraction. dinosaurRouter.get("/dinosaurs", async (_request, response) => { const dinosaurs = await dinosaurRepository.listDinosaurs(); @@ -11,14 +19,16 @@ dinosaurRouter.get("/dinosaurs", async (_request, response) => { // Return one dinosaur identified by a positive integer ID. dinosaurRouter.get("/dinosaurs/:id", async (request, response) => { - const id = Number(request.params.id); + const parsedParams = dinosaurIdParamSchema.safeParse(request.params); - if (!Number.isInteger(id) || id <= 0) { + if (!parsedParams.success) { return response.status(400).json({ message: "Dinosaur ID must be a positive integer", + errors: formatValidationErrors(parsedParams.error.issues), }); } + const { id } = parsedParams.data; const dinosaur = await dinosaurRepository.getDinosaurById(id); if (dinosaur === null) { @@ -29,3 +39,71 @@ dinosaurRouter.get("/dinosaurs/:id", async (request, response) => { response.json(dinosaur); }); + +// Insert one dinosaur into the database. +dinosaurRouter.post("/dinosaurs", async (request, response) => { + const parsedBody = dinosaurBodySchema.safeParse(request.body); + + if (!parsedBody.success) { + return response.status(400).json({ + message: "Invalid dinosaur payload", + errors: formatValidationErrors(parsedBody.error.issues), + }); + } + + const id = await dinosaurRepository.createDinosaur(parsedBody.data); + response.status(201).json({ id }); +}); + +// Replace one dinosaur identified by a positive integer ID. +dinosaurRouter.put("/dinosaurs/:id", async (request, response) => { + const parsedParams = dinosaurIdParamSchema.safeParse(request.params); + + if (!parsedParams.success) { + return response.status(400).json({ + message: "Dinosaur ID must be a positive integer", + errors: formatValidationErrors(parsedParams.error.issues), + }); + } + + const parsedBody = dinosaurBodySchema.safeParse(request.body); + + if (!parsedBody.success) { + return response.status(400).json({ + message: "Invalid dinosaur payload", + errors: formatValidationErrors(parsedBody.error.issues), + }); + } + + const updated = await dinosaurRepository.updateDinosaurById(parsedParams.data.id, parsedBody.data); + + if (!updated) { + return response.status(404).json({ + message: "Dinosaur not found", + }); + } + + response.sendStatus(204); +}); + +// Delete one dinosaur identified by a positive integer ID. +dinosaurRouter.delete("/dinosaurs/:id", async (request, response) => { + const parsedParams = dinosaurIdParamSchema.safeParse(request.params); + + if (!parsedParams.success) { + return response.status(400).json({ + message: "Dinosaur ID must be a positive integer", + errors: formatValidationErrors(parsedParams.error.issues), + }); + } + + const deleted = await dinosaurRepository.deleteDinosaurById(parsedParams.data.id); + + if (!deleted) { + return response.status(404).json({ + message: "Dinosaur not found", + }); + } + + response.sendStatus(204); +}); diff --git a/lecture_5/dinosaurs/src/validation/dinosaurSchemas.js b/lecture_5/dinosaurs/src/validation/dinosaurSchemas.js new file mode 100644 index 0000000..3615d2b --- /dev/null +++ b/lecture_5/dinosaurs/src/validation/dinosaurSchemas.js @@ -0,0 +1,17 @@ +import { z } from "zod"; + +// Route schemas keep HTTP input aligned with the database constraints in schema.sql. +export const dinosaurIdParamSchema = z + .object({ + id: z.coerce.number().int("id must be an integer").min(1, { message: "id must be greater than or equal to 1" }), + }) + .strict(); + +export const dinosaurBodySchema = z + .object({ + name: z.string().min(1, { message: "name must not be an empty string" }).max(256), + description: z.string().min(1, { message: "description must not be an empty string" }).max(4096), + period: z.string().min(1, { message: "period must not be an empty string" }).max(32), + wikipediaAddress: z.url().max(4096), + }) + .strict();