Step 2 migration: add PostgreSQL schema and read endpoints
This commit is contained in:
179
api/server.js
179
api/server.js
@@ -1,22 +1,167 @@
|
||||
const http = require("http");
|
||||
const { Pool } = require("pg");
|
||||
|
||||
const port = Number(process.env.API_INTERNAL_PORT || 3001);
|
||||
const serviceName = process.env.SERVICE_NAME || "video-games-api";
|
||||
const databaseUrl =
|
||||
process.env.DATABASE_URL ||
|
||||
"postgres://video_games_user:change_me@video-games-db:5432/video_games";
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: databaseUrl,
|
||||
});
|
||||
|
||||
function sendJson(response, statusCode, payload) {
|
||||
response.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" });
|
||||
response.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
const server = http.createServer((request, response) => {
|
||||
async function runMigrations() {
|
||||
await pool.query("CREATE EXTENSION IF NOT EXISTS pgcrypto;");
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS brands (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS consoles (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
brand_id BIGINT NOT NULL REFERENCES brands(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (brand_id, name)
|
||||
);
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS games (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
console_id BIGINT NOT NULL REFERENCES consoles(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
genre TEXT,
|
||||
publisher TEXT,
|
||||
release_year INTEGER CHECK (release_year IS NULL OR (release_year >= 1970 AND release_year <= 2100)),
|
||||
estimated_value NUMERIC(10,2) CHECK (estimated_value IS NULL OR estimated_value >= 0),
|
||||
loaned_to TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE OR REPLACE FUNCTION set_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
DROP TRIGGER IF EXISTS trg_games_updated_at ON games;
|
||||
CREATE TRIGGER trg_games_updated_at
|
||||
BEFORE UPDATE ON games
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_updated_at();
|
||||
`);
|
||||
}
|
||||
|
||||
async function querySingleValue(sqlText) {
|
||||
const result = await pool.query(sqlText);
|
||||
return Number(result.rows[0].count || 0);
|
||||
}
|
||||
|
||||
async function getCatalogSummary() {
|
||||
const [brands, consoles, games] = await Promise.all([
|
||||
querySingleValue("SELECT COUNT(*)::int AS count FROM brands;"),
|
||||
querySingleValue("SELECT COUNT(*)::int AS count FROM consoles;"),
|
||||
querySingleValue("SELECT COUNT(*)::int AS count FROM games;"),
|
||||
]);
|
||||
|
||||
return { brands, consoles, games };
|
||||
}
|
||||
|
||||
async function getCatalogTree() {
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
b.id::int AS brand_id,
|
||||
b.name AS brand_name,
|
||||
c.id::int AS console_id,
|
||||
c.name AS console_name,
|
||||
COUNT(g.id)::int AS games_count
|
||||
FROM brands b
|
||||
LEFT JOIN consoles c ON c.brand_id = b.id
|
||||
LEFT JOIN games g ON g.console_id = c.id
|
||||
GROUP BY b.id, b.name, c.id, c.name
|
||||
ORDER BY b.name ASC, c.name ASC;
|
||||
`);
|
||||
|
||||
const brandMap = new Map();
|
||||
for (const row of result.rows) {
|
||||
if (!brandMap.has(row.brand_id)) {
|
||||
brandMap.set(row.brand_id, {
|
||||
id: row.brand_id,
|
||||
name: row.brand_name,
|
||||
consoles: [],
|
||||
});
|
||||
}
|
||||
|
||||
if (row.console_id) {
|
||||
brandMap.get(row.brand_id).consoles.push({
|
||||
id: row.console_id,
|
||||
name: row.console_name,
|
||||
gamesCount: row.games_count,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(brandMap.values());
|
||||
}
|
||||
|
||||
async function handleRequest(request, response) {
|
||||
const url = new URL(request.url || "/", `http://${request.headers.host || "localhost"}`);
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/health") {
|
||||
sendJson(response, 200, {
|
||||
status: "ok",
|
||||
service: serviceName,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
try {
|
||||
await pool.query("SELECT 1;");
|
||||
sendJson(response, 200, {
|
||||
status: "ok",
|
||||
service: serviceName,
|
||||
db: "up",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
sendJson(response, 503, {
|
||||
status: "degraded",
|
||||
service: serviceName,
|
||||
db: "down",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/api/catalog/summary") {
|
||||
try {
|
||||
const summary = await getCatalogSummary();
|
||||
sendJson(response, 200, summary);
|
||||
} catch (error) {
|
||||
sendJson(response, 500, { status: "error", message: error.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/api/catalog/tree") {
|
||||
try {
|
||||
const tree = await getCatalogTree();
|
||||
sendJson(response, 200, { brands: tree });
|
||||
} catch (error) {
|
||||
sendJson(response, 500, { status: "error", message: error.message });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -24,9 +169,23 @@ const server = http.createServer((request, response) => {
|
||||
status: "not_found",
|
||||
message: "Route not found",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
server.listen(port, "0.0.0.0", () => {
|
||||
// Keep startup logs minimal and readable in docker compose logs.
|
||||
console.log(`${serviceName} listening on port ${port}`);
|
||||
async function start() {
|
||||
await runMigrations();
|
||||
|
||||
const server = http.createServer((request, response) => {
|
||||
handleRequest(request, response).catch((error) => {
|
||||
sendJson(response, 500, { status: "error", message: error.message });
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(port, "0.0.0.0", () => {
|
||||
console.log(`${serviceName} listening on port ${port}`);
|
||||
});
|
||||
}
|
||||
|
||||
start().catch((error) => {
|
||||
console.error("Failed to start API:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user