better documentation
This commit is contained in:
95
backend/package-lock.json
generated
95
backend/package-lock.json
generated
@@ -12,6 +12,8 @@
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.2",
|
||||
"express": "^5.1.0",
|
||||
"joi": "^18.0.1",
|
||||
"joi-to-swagger": "^6.2.0",
|
||||
"mysql2": "^3.14.5",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
@@ -64,6 +66,54 @@
|
||||
"openapi-types": ">=7"
|
||||
}
|
||||
},
|
||||
"node_modules/@hapi/address": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz",
|
||||
"integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@hapi/hoek": "^11.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hapi/formula": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz",
|
||||
"integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@hapi/hoek": {
|
||||
"version": "11.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz",
|
||||
"integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@hapi/pinpoint": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz",
|
||||
"integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@hapi/tlds": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.3.tgz",
|
||||
"integrity": "sha512-QIvUMB5VZ8HMLZF9A2oWr3AFM430QC8oGd0L35y2jHpuW6bIIca6x/xL7zUf4J7L9WJ3qjz+iJII8ncaeMbpSg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@hapi/topo": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz",
|
||||
"integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@hapi/hoek": "^11.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@jsdevtools/ono": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz",
|
||||
@@ -77,6 +127,12 @@
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
||||
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -855,6 +911,39 @@
|
||||
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/joi": {
|
||||
"version": "18.0.1",
|
||||
"resolved": "https://registry.npmjs.org/joi/-/joi-18.0.1.tgz",
|
||||
"integrity": "sha512-IiQpRyypSnLisQf3PwuN2eIHAsAIGZIrLZkd4zdvIar2bDyhM91ubRjy8a3eYablXsh9BeI/c7dmPYHca5qtoA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@hapi/address": "^5.1.1",
|
||||
"@hapi/formula": "^3.0.2",
|
||||
"@hapi/hoek": "^11.0.7",
|
||||
"@hapi/pinpoint": "^2.0.1",
|
||||
"@hapi/tlds": "^1.1.1",
|
||||
"@hapi/topo": "^6.0.2",
|
||||
"@standard-schema/spec": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/joi-to-swagger": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/joi-to-swagger/-/joi-to-swagger-6.2.0.tgz",
|
||||
"integrity": "sha512-gwfIr1TsbbvZWozB/sFqiD7POFcXeaLKp6QJKGFkVgdom2ie/4f75QQAanZc/Wlbnyk66e6kTZXO28i6pN3oQA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"joi": ">=17.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
|
||||
@@ -867,6 +956,12 @@
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.get": {
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.2",
|
||||
"express": "^5.1.0",
|
||||
"joi": "^18.0.1",
|
||||
"joi-to-swagger": "^6.2.0",
|
||||
"mysql2": "^3.14.5",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
|
||||
@@ -1,7 +1,69 @@
|
||||
import { Router } from "express";
|
||||
import { pool } from './db.js';
|
||||
import Joi from 'joi';
|
||||
import j2s from "joi-to-swagger";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(status, message) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
const router = Router();
|
||||
const asyncHandler = fn => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
|
||||
const userSchema = Joi.object({
|
||||
username: Joi.string().min(3).max(32).required()
|
||||
});
|
||||
|
||||
const createGameSchema = Joi.object({
|
||||
user: Joi.number().integer().positive().required(),
|
||||
is_local: Joi.boolean().default(false)
|
||||
});
|
||||
|
||||
const addPlayerSchema = Joi.object({
|
||||
user: Joi.number().integer().positive().required()
|
||||
});
|
||||
|
||||
const lockGameSchema = Joi.object({
|
||||
user: Joi.number().integer().positive().required()
|
||||
});
|
||||
|
||||
const turnSchema = Joi.object({
|
||||
user: Joi.number().integer().positive().required(),
|
||||
round_number: Joi.number().integer().min(1).required(),
|
||||
start_points: Joi.number().integer().min(0).required(),
|
||||
first_throw: Joi.number().integer().min(0).required(),
|
||||
second_throw: Joi.number().integer().min(0).required(),
|
||||
third_throw: Joi.number().integer().min(0).required(),
|
||||
end_points: Joi.number().integer().min(0).required()
|
||||
});
|
||||
|
||||
const schemasByPath = {
|
||||
"/api/users": userSchema,
|
||||
"/api/games": createGameSchema,
|
||||
"/api/games/:id/players": addPlayerSchema,
|
||||
"/api/games/:id/lock": lockGameSchema,
|
||||
"/api/games/:id/turns": turnSchema
|
||||
};
|
||||
|
||||
export function generateRequestBodies() {
|
||||
const requestBodies = {};
|
||||
|
||||
for (const path in schemasByPath) {
|
||||
const { swagger } = j2s(schemasByPath[path]);
|
||||
requestBodies[path] = {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: swagger
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return requestBodies;
|
||||
}
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
@@ -24,32 +86,25 @@ router.get("/healthcheck", (req, res) => {
|
||||
* @swagger
|
||||
* /api/users:
|
||||
* post:
|
||||
* summary: Create a new user or return old user if username already exists
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* username:
|
||||
* type: string
|
||||
* example:
|
||||
* username: "john_doe"
|
||||
* summary: Create a new user or return existing user if username already exists
|
||||
* responses:
|
||||
* 201:
|
||||
* description: User created
|
||||
* 200:
|
||||
* description: User created or existing user returned
|
||||
* content:
|
||||
* application/json:
|
||||
* example:
|
||||
* id: 1
|
||||
* username: "john_doe"
|
||||
* 400:
|
||||
* description: Validation error
|
||||
*/
|
||||
router.post("/users", async (req, res) => {
|
||||
const { username} = req.body;
|
||||
router.post("/users", asyncHandler(async (req, res) => {
|
||||
const { error, value } = userSchema.validate(req.body);
|
||||
if (error) throw new ApiError(400, error.details[0].message);
|
||||
|
||||
const { username } = value;
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO users (username)
|
||||
`INSERT INTO users (username)
|
||||
VALUES (?)
|
||||
ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id)`,
|
||||
[username]
|
||||
@@ -57,7 +112,7 @@ router.post("/users", async (req, res) => {
|
||||
|
||||
const [userRow] = await pool.query("SELECT * FROM users WHERE id = ?", [result.insertId]);
|
||||
res.status(200).json(userRow[0]);
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
@@ -104,31 +159,20 @@ router.get("/games", async (req, res) => {
|
||||
* current_playing_user: 1
|
||||
* turn_order: null
|
||||
*/
|
||||
router.get("/games/:id", async (req, res) => {
|
||||
const [rows] = await pool.query("SELECT * FROM current_games WHERE id = ?", [req.params.id]);
|
||||
res.json(rows[0] || null);
|
||||
});
|
||||
router.get("/games/:id", asyncHandler(async (req, res) => {
|
||||
const gameId = parseInt(req.params.id, 10);
|
||||
if (isNaN(gameId)) throw new ApiError(400, "Invalid game ID");
|
||||
|
||||
const [rows] = await pool.query("SELECT * FROM current_games WHERE id = ?", [gameId]);
|
||||
if (!rows.length) throw new ApiError(404, "Game not found");
|
||||
res.json(rows[0]);
|
||||
}));
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/games:
|
||||
* post:
|
||||
* summary: Create a new game
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* user:
|
||||
* type: integer
|
||||
* is_local:
|
||||
* type: boolean
|
||||
* default: false
|
||||
* example:
|
||||
* user: 1
|
||||
* is_local: false
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Game created
|
||||
@@ -145,13 +189,15 @@ router.get("/games/:id", async (req, res) => {
|
||||
* - id: 1
|
||||
* username: "john_doe"
|
||||
*/
|
||||
router.post("/games", async (req, res) => {
|
||||
const { user, is_local = false } = req.body;
|
||||
router.post("/games", asyncHandler(async (req, res) => {
|
||||
const { error, value } = createGameSchema.validate(req.body);
|
||||
if (error) throw new ApiError(400, error.details[0].message);
|
||||
|
||||
const { user, is_local } = value;
|
||||
const conn = await pool.getConnection();
|
||||
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
|
||||
const [gameResult] = await conn.query(
|
||||
"INSERT INTO current_games (is_open, is_local, current_playing_user) VALUES (?, ?, ?)",
|
||||
[true, is_local, user]
|
||||
@@ -159,7 +205,10 @@ router.post("/games", async (req, res) => {
|
||||
|
||||
const gameId = gameResult.insertId;
|
||||
|
||||
await conn.query("INSERT INTO game_players (game, user, is_creator) VALUES (?, ?, ?)", [gameId, user, true]);
|
||||
await conn.query(
|
||||
"INSERT INTO game_players (game, user, is_creator) VALUES (?, ?, ?)",
|
||||
[gameId, user, true]
|
||||
);
|
||||
|
||||
const [gameRow] = await conn.query("SELECT * FROM current_games WHERE id = ?", [gameId]);
|
||||
const [players] = await conn.query(
|
||||
@@ -173,11 +222,11 @@ router.post("/games", async (req, res) => {
|
||||
res.status(201).json({ game: gameRow[0], players });
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
throw new ApiError(500, "Failed to create game");
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
@@ -190,17 +239,6 @@ router.post("/games", async (req, res) => {
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* user:
|
||||
* type: integer
|
||||
* example:
|
||||
* user: 2
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Player added
|
||||
@@ -214,22 +252,24 @@ router.post("/games", async (req, res) => {
|
||||
* - id: 2
|
||||
* username: "jane_doe"
|
||||
*/
|
||||
router.post("/games/:id/players", async (req, res) => {
|
||||
const { user } = req.body;
|
||||
const gameId = req.params.id;
|
||||
router.post("/games/:id/players", asyncHandler(async (req, res) => {
|
||||
const gameId = parseInt(req.params.id, 10);
|
||||
if (isNaN(gameId)) throw new ApiError(400, "Invalid game ID");
|
||||
|
||||
const { error, value } = addPlayerSchema.validate(req.body);
|
||||
if (error) throw new ApiError(400, error.details[0].message);
|
||||
|
||||
const { user } = value;
|
||||
const conn = await pool.getConnection();
|
||||
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
|
||||
const [gameRows] = await conn.query(
|
||||
"SELECT * FROM current_games WHERE id = ? AND is_open = TRUE AND is_local = FALSE",
|
||||
[gameId]
|
||||
);
|
||||
|
||||
if (gameRows.length === 0) {
|
||||
return res.status(400).json({ error: "Game is not open or is local" });
|
||||
}
|
||||
if (!gameRows.length) throw new ApiError(400, "Game is not open or is local");
|
||||
|
||||
await conn.query(
|
||||
"INSERT IGNORE INTO game_players (game, user, is_creator) VALUES (?, ?, ?)",
|
||||
@@ -247,11 +287,11 @@ router.post("/games/:id/players", async (req, res) => {
|
||||
res.status(201).json({ game: gameId, players });
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
throw new ApiError(500, "Failed to add player");
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
@@ -264,17 +304,6 @@ router.post("/games/:id/players", async (req, res) => {
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* user:
|
||||
* type: integer
|
||||
* example:
|
||||
* user: 1
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Game locked with turn order
|
||||
@@ -283,26 +312,35 @@ router.post("/games/:id/players", async (req, res) => {
|
||||
* example:
|
||||
* message: "Game locked"
|
||||
* turnOrder: [1,2,3]
|
||||
* 400:
|
||||
* description: Invalid game ID / Validation error
|
||||
* 403:
|
||||
* description: Only the creator can lock the game
|
||||
* 500:
|
||||
* description: Failed to lock game
|
||||
*/
|
||||
router.patch("/games/:id/lock", async (req, res) => {
|
||||
const { user } = req.body;
|
||||
const gameId = req.params.id;
|
||||
router.patch("/games/:id/lock", asyncHandler(async (req, res) => {
|
||||
const gameId = parseInt(req.params.id, 10);
|
||||
if (isNaN(gameId)) throw new ApiError(400, "Invalid game ID");
|
||||
|
||||
const { error, value } = lockGameSchema.validate(req.body);
|
||||
if (error) throw new ApiError(400, error.details[0].message);
|
||||
|
||||
const { user } = value;
|
||||
const conn = await pool.getConnection();
|
||||
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
|
||||
const [creatorRows] = await pool.query(
|
||||
const [creatorRows] = await conn.query(
|
||||
"SELECT * FROM game_players WHERE game = ? AND user = ? AND is_creator = TRUE",
|
||||
[gameId, user]
|
||||
);
|
||||
|
||||
if (creatorRows.length === 0) {
|
||||
return res.status(403).json({ error: "Only the creator can lock the game" });
|
||||
}
|
||||
if (!creatorRows.length) throw new ApiError(403, "Only the creator can lock the game");
|
||||
|
||||
const [players] = await conn.query(
|
||||
`SELECT user FROM game_players WHERE game = ?`,
|
||||
"SELECT user FROM game_players WHERE game = ?",
|
||||
[gameId]
|
||||
);
|
||||
|
||||
@@ -313,7 +351,7 @@ router.patch("/games/:id/lock", async (req, res) => {
|
||||
}
|
||||
|
||||
await conn.query(
|
||||
`UPDATE current_games SET is_open = FALSE, turn_order = ? WHERE id = ?`,
|
||||
"UPDATE current_games SET is_open = FALSE, turn_order = ? WHERE id = ?",
|
||||
[JSON.stringify(turnOrder), gameId]
|
||||
);
|
||||
|
||||
@@ -321,11 +359,11 @@ router.patch("/games/:id/lock", async (req, res) => {
|
||||
res.json({ message: "Game locked", turnOrder });
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
throw new ApiError(500, "Failed to lock game");
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
@@ -349,50 +387,18 @@ router.patch("/games/:id/lock", async (req, res) => {
|
||||
* - id: 2
|
||||
* username: "jane_doe"
|
||||
*/
|
||||
router.get("/games/:id/players", async (req, res) => {
|
||||
router.get("/games/:id/players", asyncHandler(async (req, res) => {
|
||||
const gameId = parseInt(req.params.id, 10);
|
||||
if (isNaN(gameId)) throw new ApiError(400, "Invalid game ID");
|
||||
|
||||
const [players] = await pool.query(
|
||||
`SELECT u.id, u.username FROM game_players gp
|
||||
JOIN users u ON gp.user = u.id
|
||||
WHERE gp.game = ?`,
|
||||
[req.params.id]
|
||||
[gameId]
|
||||
);
|
||||
res.json(players);
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/games/{id}/turns:
|
||||
* get:
|
||||
* summary: Get all turns for a game
|
||||
* parameters:
|
||||
* - name: id
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of turns
|
||||
* content:
|
||||
* application/json:
|
||||
* example:
|
||||
* - id: 1
|
||||
* game: 1
|
||||
* user: 1
|
||||
* round_number: 1
|
||||
* start_points: 501
|
||||
* first_throw: 60
|
||||
* second_throw: 45
|
||||
* third_throw: 36
|
||||
* end_points: 360
|
||||
*/
|
||||
router.get("/games/:id/turns", async (req, res) => {
|
||||
const [rows] = await pool.query(
|
||||
"SELECT * FROM turns WHERE game = ? ORDER BY round_number",
|
||||
[req.params.id]
|
||||
);
|
||||
res.json(rows);
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
@@ -405,35 +411,6 @@ router.get("/games/:id/turns", async (req, res) => {
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* user:
|
||||
* type: integer
|
||||
* round_number:
|
||||
* type: integer
|
||||
* start_points:
|
||||
* type: integer
|
||||
* first_throw:
|
||||
* type: integer
|
||||
* second_throw:
|
||||
* type: integer
|
||||
* third_throw:
|
||||
* type: integer
|
||||
* end_points:
|
||||
* type: integer
|
||||
* example:
|
||||
* user: 1
|
||||
* round_number: 1
|
||||
* start_points: 501
|
||||
* first_throw: 60
|
||||
* second_throw: 45
|
||||
* third_throw: 36
|
||||
* end_points: 360
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Turn recorded
|
||||
@@ -442,10 +419,21 @@ router.get("/games/:id/turns", async (req, res) => {
|
||||
* example:
|
||||
* turnId: 1
|
||||
* nextPlayer: 2
|
||||
* 400:
|
||||
* description: Validation error / Invalid game ID
|
||||
* 403:
|
||||
* description: Not your turn
|
||||
* 500:
|
||||
* description: Failed to record turn
|
||||
*/
|
||||
router.post("/games/:id/turns", async (req, res) => {
|
||||
const { user, round_number, start_points, first_throw, second_throw, third_throw, end_points } = req.body;
|
||||
const gameId = req.params.id;
|
||||
router.post("/games/:id/turns", asyncHandler(async (req, res) => {
|
||||
const gameId = parseInt(req.params.id, 10);
|
||||
if (isNaN(gameId)) throw new ApiError(400, "Invalid game ID");
|
||||
|
||||
const { error, value } = turnSchema.validate(req.body);
|
||||
if (error) throw new ApiError(400, error.details[0].message);
|
||||
|
||||
const { user, round_number, start_points, first_throw, second_throw, third_throw, end_points } = value;
|
||||
const conn = await pool.getConnection();
|
||||
|
||||
try {
|
||||
@@ -456,16 +444,12 @@ router.post("/games/:id/turns", async (req, res) => {
|
||||
[gameId]
|
||||
);
|
||||
|
||||
if (gameRows.length === 0) {
|
||||
return res.status(404).json({ error: "Game not found" });
|
||||
}
|
||||
if (!gameRows.length) throw new ApiError(404, "Game not found");
|
||||
|
||||
const game = gameRows[0];
|
||||
const turnOrder = JSON.parse(game.turn_order || '[]');
|
||||
|
||||
if (game.current_playing_user !== user) {
|
||||
return res.status(403).json({ error: "Not your turn" });
|
||||
}
|
||||
if (game.current_playing_user !== user) throw new ApiError(403, "Not your turn");
|
||||
|
||||
const [result] = await conn.query(
|
||||
`INSERT INTO turns
|
||||
@@ -484,18 +468,45 @@ router.post("/games/:id/turns", async (req, res) => {
|
||||
);
|
||||
|
||||
await conn.commit();
|
||||
|
||||
res.status(201).json({
|
||||
turnId: result.insertId,
|
||||
nextPlayer
|
||||
});
|
||||
res.status(201).json({ turnId: result.insertId, nextPlayer });
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
throw new ApiError(500, "Failed to record turn");
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/games/{id}/turns:
|
||||
* get:
|
||||
* summary: Get turns of a game
|
||||
* parameters:
|
||||
* - name: id
|
||||
* in: path
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 201:
|
||||
* description: The turn
|
||||
* content:
|
||||
* application/json:
|
||||
* example:
|
||||
* turnId: 1
|
||||
* nextPlayer: 2
|
||||
*/
|
||||
router.get("/games/:id/turns", asyncHandler(async (req, res) => {
|
||||
const gameId = parseInt(req.params.id, 10);
|
||||
if (isNaN(gameId)) throw new ApiError(400, "Invalid game ID");
|
||||
|
||||
const [rows] = await pool.query(
|
||||
"SELECT * FROM turns WHERE game = ? ORDER BY round_number",
|
||||
[gameId]
|
||||
);
|
||||
res.json(rows);
|
||||
}));
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
@@ -524,9 +535,14 @@ router.post("/games/:id/turns", async (req, res) => {
|
||||
* third_throw: 36
|
||||
* end_points: 360
|
||||
*/
|
||||
router.get("/turns/:id", async (req, res) => {
|
||||
const [rows] = await pool.query("SELECT * FROM turns WHERE id = ?", [req.params.id]);
|
||||
res.json(rows[0] || null);
|
||||
});
|
||||
router.get("/turns/:id", asyncHandler(async (req, res) => {
|
||||
const turnId = parseInt(req.params.id, 10);
|
||||
if (isNaN(turnId)) throw new ApiError(400, "Invalid turn ID");
|
||||
|
||||
const [rows] = await pool.query("SELECT * FROM turns WHERE id = ?", [turnId]);
|
||||
if (!rows.length) throw new ApiError(404, "Turn not found");
|
||||
|
||||
res.json(rows[0]);
|
||||
}));
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -23,9 +23,9 @@ const migrations = [
|
||||
await conn.query(`
|
||||
CREATE TABLE users (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
username VARCHAR(32) NOT NULL UNIQUE
|
||||
username VARCHAR(64) NOT NULL UNIQUE
|
||||
)
|
||||
`);
|
||||
`); // max length is double the actual max length cause of unicode stuff
|
||||
|
||||
await conn.query(`
|
||||
CREATE TABLE games (
|
||||
|
||||
@@ -5,7 +5,7 @@ import https from 'https';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
import apiRouter from './api.js';
|
||||
import apiRouter, { ApiError, generateRequestBodies } from './api.js';
|
||||
import { initDB } from './db.js';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
@@ -13,6 +13,7 @@ const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.join(path.dirname(__filename), ".."); // going back a dir cuz code is in src/
|
||||
|
||||
const app = express();
|
||||
|
||||
const specs = swaggerJsdoc({
|
||||
definition: {
|
||||
openapi: "3.0.0",
|
||||
@@ -23,6 +24,17 @@ const specs = swaggerJsdoc({
|
||||
},
|
||||
apis: ["./src/api.js"],
|
||||
});
|
||||
for (const [path, schema] of Object.entries(generateRequestBodies())) {
|
||||
const swaggerPath = path.replace(/:(\w+)/g, '{$1}');
|
||||
if (!specs.paths[swaggerPath]) continue;
|
||||
|
||||
const methodKeys = Object.keys(specs.paths[swaggerPath]);
|
||||
methodKeys.forEach(method => {
|
||||
if (["post", "put", "patch", "delete"].includes(method)) {
|
||||
specs.paths[swaggerPath][method].requestBody = schema;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initDB();
|
||||
|
||||
@@ -35,6 +47,16 @@ app.use(express.static(path.join(__dirname, "public"), {
|
||||
app.use("/api/docs", swaggerUi.serve, swaggerUi.setup(specs));
|
||||
app.use("/api", apiRouter);
|
||||
|
||||
// has to be last
|
||||
app.use((err, _req, res, _next) => {
|
||||
if (err instanceof ApiError) {
|
||||
return res.status(err.status).json({ status: "error", error: err.message });
|
||||
}
|
||||
|
||||
console.error(err);
|
||||
res.status(500).json({ status: "error", error: "Internal server error" })
|
||||
})
|
||||
|
||||
const server = https.createServer({
|
||||
key: fs.readFileSync('server.key'),
|
||||
cert: fs.readFileSync('server.cert')
|
||||
|
||||
Reference in New Issue
Block a user