feat: standardize error envelope and request id propagation
This commit is contained in:
parent
b3f607d8f8
commit
ac92bed8a1
@ -3,6 +3,7 @@ const cors = require("cors");
|
||||
const path = require("path");
|
||||
const User = require("./models/user.model");
|
||||
const requestIdMiddleware = require("./middleware/request-id");
|
||||
const { sendError } = require("./utils/http");
|
||||
|
||||
const app = express();
|
||||
app.use(requestIdMiddleware);
|
||||
@ -28,8 +29,8 @@ app.use(
|
||||
);
|
||||
|
||||
app.get('/', async (req, res) => {
|
||||
resText = `Grocery List API is running.\n` +
|
||||
`Roles available: ${Object.values(User.ROLES).join(', ')}`
|
||||
const resText = `Grocery List API is running.\n` +
|
||||
`Roles available: ${Object.values(User.ROLES).join(', ')}`;
|
||||
|
||||
res.status(200).type("text/plain").send(resText);
|
||||
});
|
||||
@ -56,4 +57,16 @@ app.use("/households", householdsRoutes);
|
||||
const storesRoutes = require("./routes/stores.routes");
|
||||
app.use("/stores", storesRoutes);
|
||||
|
||||
app.use((err, req, res, next) => {
|
||||
if (res.headersSent) {
|
||||
return next(err);
|
||||
}
|
||||
|
||||
const statusCode = err.status || err.statusCode || 500;
|
||||
const message =
|
||||
statusCode >= 500 ? "Internal server error" : err.message || "Request failed";
|
||||
|
||||
return sendError(res, statusCode, message);
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
const bcrypt = require("bcryptjs");
|
||||
const jwt = require("jsonwebtoken");
|
||||
const User = require("../models/user.model");
|
||||
const { sendError } = require("../utils/http");
|
||||
|
||||
exports.register = async (req, res) => {
|
||||
let { username, password, name } = req.body;
|
||||
@ -10,11 +11,11 @@ exports.register = async (req, res) => {
|
||||
try {
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
const user = await User.createUser(username, hash, name);
|
||||
console.log(`✅ User registered: ${username}`);
|
||||
console.log(`User registered: ${username}`);
|
||||
|
||||
res.json({ message: "User registered", user });
|
||||
} catch (err) {
|
||||
res.status(400).json({ message: "Registration failed", error: err });
|
||||
sendError(res, 400, "Registration failed");
|
||||
}
|
||||
};
|
||||
|
||||
@ -24,14 +25,14 @@ exports.login = async (req, res) => {
|
||||
username = username.toLowerCase();
|
||||
const user = await User.findByUsername(username);
|
||||
if (!user) {
|
||||
console.log(`⚠️ Login attempt -> No user found: ${username}`);
|
||||
return res.status(401).json({ message: "User not found" });
|
||||
console.log(`Login attempt with unknown user: ${username}`);
|
||||
return sendError(res, 401, "User not found");
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, user.password);
|
||||
if (!valid) {
|
||||
console.log(`Invalid login attempt for user ${username}`);
|
||||
return res.status(401).json({ message: "Invalid credentials" });
|
||||
return sendError(res, 401, "Invalid credentials");
|
||||
}
|
||||
|
||||
const token = jwt.sign(
|
||||
|
||||
@ -1,18 +1,19 @@
|
||||
const jwt = require("jsonwebtoken");
|
||||
const { sendError } = require("../utils/http");
|
||||
|
||||
function auth(req, res, next) {
|
||||
const header = req.headers.authorization;
|
||||
if (!header) return res.status(401).json({ message: "Missing token" });
|
||||
if (!header) return sendError(res, 401, "Missing token");
|
||||
|
||||
const token = header.split(" ")[1];
|
||||
if (!token) return res.status(401).json({ message: "Invalid token format" });
|
||||
if (!token) return sendError(res, 401, "Invalid token format");
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
req.user = decoded; // id + role
|
||||
next();
|
||||
} catch (err) {
|
||||
res.status(401).json({ message: "Invalid or expired token" });
|
||||
sendError(res, 401, "Invalid or expired token");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
const householdModel = require("../models/household.model");
|
||||
const { sendError } = require("../utils/http");
|
||||
|
||||
// Middleware to check if user belongs to household
|
||||
exports.householdAccess = async (req, res, next) => {
|
||||
@ -7,16 +8,14 @@ exports.householdAccess = async (req, res, next) => {
|
||||
const userId = req.user.id;
|
||||
|
||||
if (!householdId) {
|
||||
return res.status(400).json({ error: "Household ID required" });
|
||||
return sendError(res, 400, "Household ID required");
|
||||
}
|
||||
|
||||
// Check if user is member of household
|
||||
const isMember = await householdModel.isHouseholdMember(householdId, userId);
|
||||
|
||||
if (!isMember) {
|
||||
return res.status(403).json({
|
||||
error: "Access denied. You are not a member of this household."
|
||||
});
|
||||
return sendError(res, 403, "Access denied. You are not a member of this household.");
|
||||
}
|
||||
|
||||
// Get user's role in household
|
||||
@ -31,7 +30,7 @@ exports.householdAccess = async (req, res, next) => {
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error("Household access check error:", error);
|
||||
res.status(500).json({ error: "Server error checking household access" });
|
||||
sendError(res, 500, "Server error checking household access");
|
||||
}
|
||||
};
|
||||
|
||||
@ -39,15 +38,15 @@ exports.householdAccess = async (req, res, next) => {
|
||||
exports.requireHouseholdRole = (...allowedRoles) => {
|
||||
return (req, res, next) => {
|
||||
if (!req.household) {
|
||||
return res.status(500).json({
|
||||
error: "Household context not set. Use householdAccess middleware first."
|
||||
});
|
||||
return sendError(res, 500, "Household context not set. Use householdAccess middleware first.");
|
||||
}
|
||||
|
||||
if (!allowedRoles.includes(req.household.role)) {
|
||||
return res.status(403).json({
|
||||
error: `Access denied. Required role: ${allowedRoles.join(" or ")}. Your role: ${req.household.role}`
|
||||
});
|
||||
return sendError(
|
||||
res,
|
||||
403,
|
||||
`Access denied. Required role: ${allowedRoles.join(" or ")}. Your role: ${req.household.role}`
|
||||
);
|
||||
}
|
||||
|
||||
next();
|
||||
@ -63,13 +62,11 @@ exports.storeAccess = async (req, res, next) => {
|
||||
const storeId = parseInt(req.params.storeId || req.params.sId);
|
||||
|
||||
if (!storeId) {
|
||||
return res.status(400).json({ error: "Store ID required" });
|
||||
return sendError(res, 400, "Store ID required");
|
||||
}
|
||||
|
||||
if (!req.household) {
|
||||
return res.status(500).json({
|
||||
error: "Household context not set. Use householdAccess middleware first."
|
||||
});
|
||||
return sendError(res, 500, "Household context not set. Use householdAccess middleware first.");
|
||||
}
|
||||
|
||||
// Check if household has access to this store
|
||||
@ -77,9 +74,7 @@ exports.storeAccess = async (req, res, next) => {
|
||||
const hasStore = await storeModel.householdHasStore(req.household.id, storeId);
|
||||
|
||||
if (!hasStore) {
|
||||
return res.status(403).json({
|
||||
error: "This household does not have access to this store."
|
||||
});
|
||||
return sendError(res, 403, "This household does not have access to this store.");
|
||||
}
|
||||
|
||||
// Attach store info to request
|
||||
@ -90,20 +85,18 @@ exports.storeAccess = async (req, res, next) => {
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error("Store access check error:", error);
|
||||
res.status(500).json({ error: "Server error checking store access" });
|
||||
sendError(res, 500, "Server error checking store access");
|
||||
}
|
||||
};
|
||||
|
||||
// Middleware to require system admin role
|
||||
exports.requireSystemAdmin = (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
return sendError(res, 401, "Authentication required");
|
||||
}
|
||||
|
||||
if (req.user.role !== 'system_admin') {
|
||||
return res.status(403).json({
|
||||
error: "Access denied. System administrator privileges required."
|
||||
});
|
||||
return sendError(res, 403, "Access denied. System administrator privileges required.");
|
||||
}
|
||||
|
||||
next();
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
const multer = require("multer");
|
||||
const sharp = require("sharp");
|
||||
const { MAX_FILE_SIZE_BYTES, MAX_IMAGE_DIMENSION, IMAGE_QUALITY } = require("../config/constants");
|
||||
const { sendError } = require("../utils/http");
|
||||
|
||||
// Configure multer for memory storage (we'll process before saving to DB)
|
||||
const upload = multer({
|
||||
@ -42,7 +43,7 @@ const processImage = async (req, res, next) => {
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
res.status(400).json({ message: "Error processing image: " + error.message });
|
||||
sendError(res, 400, `Error processing image: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
const { sendError } = require("../utils/http");
|
||||
|
||||
function requireRole(...allowedRoles) {
|
||||
return (req, res, next) => {
|
||||
if (!req.user) return res.status(401).json({ message: "Authentication required" });
|
||||
if (!req.user) return sendError(res, 401, "Authentication required");
|
||||
if (!allowedRoles.includes(req.user.role))
|
||||
return res.status(403).json({ message: "Forbidden" });
|
||||
return sendError(res, 403, "Forbidden");
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
const crypto = require("crypto");
|
||||
const { normalizeErrorPayload } = require("../utils/http");
|
||||
|
||||
function generateRequestId() {
|
||||
if (typeof crypto.randomUUID === "function") {
|
||||
@ -25,10 +26,16 @@ function requestIdMiddleware(req, res, next) {
|
||||
|
||||
const originalJson = res.json.bind(res);
|
||||
res.json = (payload) => {
|
||||
if (isPlainObject(payload) && payload.request_id === undefined) {
|
||||
return originalJson({ ...payload, request_id: requestId });
|
||||
const normalizedPayload = normalizeErrorPayload(payload, res.statusCode);
|
||||
|
||||
if (
|
||||
isPlainObject(normalizedPayload) &&
|
||||
normalizedPayload.request_id === undefined
|
||||
) {
|
||||
return originalJson({ ...normalizedPayload, request_id: requestId });
|
||||
}
|
||||
return originalJson(payload);
|
||||
|
||||
return originalJson(normalizedPayload);
|
||||
};
|
||||
|
||||
next();
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
const router = require("express").Router();
|
||||
const controller = require("../controllers/auth.controller");
|
||||
const User = require("../models/user.model");
|
||||
|
||||
router.post("/register", controller.register);
|
||||
router.post("/login", controller.login);
|
||||
router.post("/", async (req, res) => {
|
||||
resText = `Grocery List API is running.\n` +
|
||||
`Roles available: ${Object.values(User.ROLES).join(', ')}`
|
||||
const resText = `Grocery List API is running.\n` +
|
||||
`Roles available: ${Object.values(User.ROLES).join(', ')}`;
|
||||
|
||||
res.status(200).type("text/plain").send(resText);
|
||||
});
|
||||
|
||||
116
backend/utils/http.js
Normal file
116
backend/utils/http.js
Normal file
@ -0,0 +1,116 @@
|
||||
function isPlainObject(value) {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
Object.prototype.toString.call(value) === "[object Object]"
|
||||
);
|
||||
}
|
||||
|
||||
function errorCodeFromStatus(statusCode) {
|
||||
switch (statusCode) {
|
||||
case 400:
|
||||
return "bad_request";
|
||||
case 401:
|
||||
return "unauthorized";
|
||||
case 403:
|
||||
return "forbidden";
|
||||
case 404:
|
||||
return "not_found";
|
||||
case 409:
|
||||
return "conflict";
|
||||
case 413:
|
||||
return "payload_too_large";
|
||||
case 415:
|
||||
return "unsupported_media_type";
|
||||
case 422:
|
||||
return "unprocessable_entity";
|
||||
case 429:
|
||||
return "rate_limited";
|
||||
case 500:
|
||||
return "internal_error";
|
||||
default:
|
||||
return statusCode >= 500 ? "internal_error" : "request_error";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeErrorPayload(payload, statusCode) {
|
||||
if (statusCode < 400) return payload;
|
||||
|
||||
if (typeof payload === "string") {
|
||||
return {
|
||||
error: {
|
||||
code: errorCodeFromStatus(statusCode),
|
||||
message: payload,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (!isPlainObject(payload)) {
|
||||
return {
|
||||
error: {
|
||||
code: errorCodeFromStatus(statusCode),
|
||||
message: "Request failed",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (isPlainObject(payload.error)) {
|
||||
const code = payload.error.code || errorCodeFromStatus(statusCode);
|
||||
const message = payload.error.message || "Request failed";
|
||||
return {
|
||||
...payload,
|
||||
error: {
|
||||
...payload.error,
|
||||
code,
|
||||
message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof payload.error === "string") {
|
||||
const { error, ...rest } = payload;
|
||||
return {
|
||||
...rest,
|
||||
error: {
|
||||
code: errorCodeFromStatus(statusCode),
|
||||
message: error,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof payload.message === "string") {
|
||||
const { message, ...rest } = payload;
|
||||
return {
|
||||
...rest,
|
||||
error: {
|
||||
code: errorCodeFromStatus(statusCode),
|
||||
message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...payload,
|
||||
error: {
|
||||
code: errorCodeFromStatus(statusCode),
|
||||
message: "Request failed",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sendError(res, statusCode, message, code, extra = {}) {
|
||||
return res.status(statusCode).json({
|
||||
...extra,
|
||||
error: {
|
||||
code: code || errorCodeFromStatus(statusCode),
|
||||
message,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
errorCodeFromStatus,
|
||||
normalizeErrorPayload,
|
||||
sendError,
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user