Compare commits

...

4 Commits

18 changed files with 2238 additions and 1823 deletions

View File

@ -1,5 +0,0 @@
{
"endOfLine": "lf",
"useTabs": true,
"printWidth": 120
}

View File

@ -0,0 +1,6 @@
PGHOST="localhost"
PGPORT="5432"
PGDATABASE="rg_academy_dev"
PGUSER="rg_academy"
PGPASSWORD="rg_academy"
PORT="3000"

View File

@ -0,0 +1,5 @@
{
"endOfLine": "lf",
"useTabs": true,
"printWidth": 120
}

View File

@ -0,0 +1,37 @@
### List all dinosaurs
GET http://localhost:3000/dinosaurs
### Get one dinosaur
GET http://localhost:3000/dinosaurs/1
### Invalid dinosaur ID
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

View File

@ -15,6 +15,7 @@ export default defineConfig([
rules: { rules: {
indent: ["error", "tab"], indent: ["error", "tab"],
"linebreak-style": ["error", "unix"], "linebreak-style": ["error", "unix"],
"no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
quotes: ["error", "double"], quotes: ["error", "double"],
semi: ["error", "always"], semi: ["error", "always"],
}, },

View File

@ -0,0 +1,39 @@
import "dotenv/config";
import express from "express";
import { Client } from "pg";
// Legacy single-file implementation kept for comparison with the layered src/ structure.
const client = new Client({
host: process.env.PGHOST,
port: Number(process.env.PGPORT),
database: process.env.PGDATABASE,
user: process.env.PGUSER,
password: process.env.PGPASSWORD,
});
const app = express();
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,
name: row.name,
description: row.description,
period: row.period,
wikipediaAddress: row.wikipedia_address,
}));
response.json(dinosaurs);
});
await client.connect();
console.log("Connected to database");
app.listen(3000, () => {
console.log("Server running on port 3000");
});
process.on("SIGINT", async () => {
console.log("Shutting down...");
await client.end();
process.exit(0);
});

1830
lecture_5/dinosaurs/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,25 @@
{
"name": "dinosaurs",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node src/index.js",
"lint": "eslint .",
"format": "prettier --write .",
"check": "npm run lint && prettier --check ."
},
"dependencies": {
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.1.0",
"pg": "^8.11.3",
"zod": "^4.4.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@eslint/json": "^1.2.0",
"eslint": "^10.0.0",
"globals": "^17.4.0",
"prettier": "^3.0.0"
}
}

View File

@ -1,13 +1,13 @@
-- Dinosaur table -- Dinosaur table
CREATE TABLE dinosaur ( CREATE TABLE dinosaur (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name VARCHAR(256) NOT NULL, name VARCHAR(256) NOT NULL UNIQUE,
description VARCHAR(4096) NOT NULL, description VARCHAR(4096) NOT NULL,
period VARCHAR(32) NOT NULL, period VARCHAR(32) NOT NULL,
wikipedia_address VARCHAR(4096) NOT NULL wikipedia_address VARCHAR(4096) NOT NULL
); );
-- Insert sample data into dinosaur table -- Seed records provide predictable data for the API exercises.
INSERT INTO dinosaur (name, description, period, wikipedia_address) INSERT INTO dinosaur (name, description, period, wikipedia_address)
VALUES ( VALUES (
'Tyrannosaurus', 'Tyrannosaurus',

View File

@ -0,0 +1,14 @@
import express from "express";
import cors from "cors";
import { dinosaurRouter } from "./routes/dinosaurRoutes.js";
import { errorHandler } from "./validation/errorHandler.js";
const app = express();
app.use(cors());
app.use(express.json());
app.use(dinosaurRouter);
// Error middleware must be registered after routes so it can handle rejected route handlers.
app.use(errorHandler);
export { app };

View File

@ -0,0 +1,5 @@
import "dotenv/config";
import { Client } from "pg";
// The pg client reads the standard PGHOST, PGPORT, PGDATABASE, PGUSER, and PGPASSWORD variables.
export const client = new Client();

View File

@ -0,0 +1,23 @@
import { client } from "./db/client.js";
import { app } from "./app.js";
const port = Number(process.env.PORT ?? 3000);
try {
await client.connect();
console.log("Connected to database");
app.listen(port, () => {
console.log(`Server running on http://localhost:${port}`);
});
} catch (error) {
console.error("Failed to start the server:", error.message);
process.exitCode = 1;
}
// Close the database connection when the process is stopped from the terminal.
process.on("SIGINT", async () => {
console.log("Shutting down...");
await client.end();
process.exit(0);
});

View File

@ -0,0 +1,108 @@
import { client } from "../db/client.js";
/**
* @typedef {Object} DinosaurWithoutId
* @property {string} name
* @property {string} description
* @property {string} period
* @property {string} wikipediaAddress
*/
/**
* @typedef {DinosaurWithoutId} Dinosaur
* @property {number} id
*/
/**
* Converts a database row representing a dinosaur into a `Dinosaur` object.
*
* @param {Object} row
* @returns {Dinosaur}
*/
const mapRowToDinosaurDto = (row) => {
return {
id: row.id,
name: row.name,
description: row.description,
period: row.period,
wikipediaAddress: row.wikipedia_address,
};
};
export const dinosaurRepository = {
/**
* Obtains all dinosaurs, ordered by ID.
*
* @returns {Promise<Dinosaur[]>}
*/
async listDinosaurs() {
const result = await client.query("SELECT * FROM dinosaur ORDER BY id");
return result.rows.map(mapRowToDinosaurDto);
},
/**
* Obtains one dinosaur by its ID.
*
* @param {number} id
* @returns {Promise<Dinosaur | null>}
*/
async getDinosaurById(id) {
const result = await client.query("SELECT * FROM dinosaur WHERE id = $1", [id]);
if (result.rowCount === 0) {
return null;
}
return mapRowToDinosaurDto(result.rows[0]);
},
/**
* Creates a dinosaur and returns its generated ID.
*
* @param {DinosaurWithoutId} dinosaur
* @returns {Promise<number>}
*/
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<boolean>}
*/
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<boolean>}
*/
async deleteDinosaurById(id) {
const result = await client.query("DELETE FROM dinosaur WHERE id = $1", [id]);
return result.rowCount > 0;
},
};

View File

@ -0,0 +1,109 @@
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();
response.json(dinosaurs);
});
// Return one dinosaur identified by a positive integer ID.
dinosaurRouter.get("/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 { id } = parsedParams.data;
const dinosaur = await dinosaurRepository.getDinosaurById(id);
if (dinosaur === null) {
return response.status(404).json({
message: "Dinosaur not found",
});
}
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);
});

View File

@ -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();

View File

@ -0,0 +1,17 @@
import { ZodError } from "zod";
// Centralized Express error middleware for validation and unexpected runtime errors.
export const errorHandler = (error, _request, response, _next) => {
console.error(error);
if (error instanceof ZodError) {
return response.status(400).json({
message: "Validation error",
errors: error.issues,
});
}
return response.status(500).json({
message: "Internal server error",
});
};

File diff suppressed because it is too large Load Diff

View File

@ -1,20 +0,0 @@
{
"name": "dinosaurs",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"dotenv": "^17.4.2",
"express": "^5.1.0",
"pg": "^8.11.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@eslint/json": "^1.2.0",
"eslint": "^10.0.0",
"globals": "^17.4.0",
"prettier": "^3.0.0"
}
}