feat: add logging and error handling

This commit is contained in:
Axel Cocat 2022-02-27 17:46:38 +01:00
parent f324ca45ff
commit 56c0fed144
10 changed files with 720 additions and 49 deletions

View file

@ -1,6 +1,9 @@
import * as path from "path";
import * as uuid from "uuid";
import { Express } from "express";
import * as express from "express";
import * as winston from "winston";
import * as morgan from "morgan";
import { Connection, createConnection } from "mysql2/promise";
import { engine as handlebarsEngine } from "express-handlebars";
@ -15,34 +18,62 @@ export class Armory {
public dbc: DbcManager;
public config: Config;
public worldDb: Connection;
public logger: winston.Logger;
private charsDbs: { [key: string]: Connection };
private errorNames: { [key: number]: string };
private errorDescriptions: { [key: number]: string };
public constructor() {
this.dbc = new DbcManager();
this.characterCustomization = new CharacterCustomization();
this.charsDbs = {};
this.logger = winston.createLogger({
level: "info",
format: winston.format.combine(
winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss:ms" }),
winston.format.printf((info) => `[${info.timestamp}] [${info.level.toUpperCase()}]: ${info.message}`),
),
transports: [
new winston.transports.Console({ level: "debug" }),
new winston.transports.File({ filename: "armory.error.log", level: "error" }),
new winston.transports.File({ filename: "armory.combined.log", level: "http" }),
],
});
this.errorNames = {
400: "Bad Request",
401: "Unauthorized",
403: "Forbidden",
404: "Not Found",
500: "Internal Server Error",
};
this.errorDescriptions = {
400: "Invalid request.",
404: "Sorry, we could not find what you were looking for.",
500: "An unexpected internal error has occurred. Please contact the site owner.",
};
}
public async start(): Promise<void> {
const app: Express = express();
const listenPort = 48733;
console.log("Loading config...");
this.config = await Config.load();
console.log("Loading data files...");
this.logger.info("Loading config...");
this.config = await Config.load(this.logger);
this.logger.info("Loading data files...");
if (this.config.loadDbcs) {
await this.dbc.loadAllFiles();
}
await this.characterCustomization.loadData();
console.log("Connecting to databases...");
this.logger.info("Connecting to databases...");
this.worldDb = await createConnection(this.config.worldDatabase);
for (const realm of this.config.realms) {
this.charsDbs[realm.name.toLowerCase()] = await createConnection(realm.charactersDatabase);
}
console.log("Starting server...");
this.logger.info("Starting server...");
app.locals.aowow = this.config.aowowUrl;
app.engine(".html", handlebarsEngine({
extname: "html",
@ -54,6 +85,20 @@ export class Armory {
app.set("view engine", "handlebars");
app.set("views", path.join(process.cwd(), "static"));
app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
req.id = uuid.v4();
next();
});
morgan.token("id", (req: express.Request) => {
return req.id;
});
app.use(morgan(":method :url :status - ID :id - :remote-addr - :response-time ms", {
stream: {
write: (msg) => this.logger.http(msg.trim()),
},
}));
app.use("/js", express.static(`static/js`));
app.use("/css", express.static(`static/css`));
app.use("/img", express.static(`static/img`));
@ -64,19 +109,55 @@ export class Armory {
app.use("/data/background.png", express.static(`data/background.png`));
const indexController = new IndexController(this);
app.get("/", indexController.index.bind(indexController));
app.get("/search", indexController.search.bind(indexController));
app.get("/", this.wrapRoute(indexController.index.bind(indexController)));
app.get("/search", this.wrapRoute(indexController.search.bind(indexController)));
const charsController = new CharacterController(this);
await charsController.load();
app.get("/character/:realm/:name", charsController.character.bind(charsController));
app.get("/character/:realm/:name/talents", charsController.talents.bind(charsController));
app.get("/character/:realm/:name/achievements", charsController.achievements.bind(charsController));
app.get("/character/:realm/:character/achievements/data", charsController.achievementsData.bind(charsController));
app.get("/character/:realm/:name", this.wrapRoute(charsController.character.bind(charsController)));
app.get("/character/:realm/:name/talents", this.wrapRoute(charsController.talents.bind(charsController)));
app.get("/character/:realm/:name/achievements", this.wrapRoute(charsController.achievements.bind(charsController)));
app.get("/character/:realm/:character/achievements/data", this.wrapRoute(charsController.achievementsData.bind(charsController)));
app.use((err, req: express.Request, res: express.Response, next: express.NextFunction) => {
// Error handler
if (err instanceof Error) {
const contents = err.stack ?? `${err.name}: ${err.message}`;
this.logger.error(`Error on request ${req.id}. ${contents}`);
}
let status = 500;
if (typeof err === "number") {
status = err;
}
res.status(status).render("error.html", this.getErrorViewData(status, req));
});
app.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
// 404 handler
res.status(404);
// Respond with html page
if (req.accepts("html")) {
res.render("error.html", this.getErrorViewData(404, req));
return;
}
// Respond with json
if (req.accepts("json")) {
res.json({ error: this.errorNames[404] });
return;
}
// Default to plain-text
res.type("txt").send(this.errorNames[404]);
});
this.gc();
app.listen(listenPort, "0.0.0.0", () => {
console.log(`Server is listening on 0.0.0.0:${listenPort}.`);
this.logger.info(`Server is listening on 0.0.0.0:${listenPort}.`);
});
}
@ -95,4 +176,24 @@ export class Armory {
}
}, 500);
}
private wrapRoute(fn: (req: express.Request, res: express.Response, next: express.NextFunction) => Promise<any>) {
// Adds error handling for promise-based controller methods
return async (req: express.Request, res: express.Response, next: express.NextFunction) => {
try {
await fn(req, res, next);
} catch (e) {
next(e);
}
};
}
private getErrorViewData(status: number, req: express.Request) {
return {
status,
name: this.errorNames[status] || "An error occurred",
description: this.errorDescriptions[status] || "",
reqId: req.id,
};
}
}

View file

@ -1,6 +1,8 @@
import * as fs from "fs";
const fsp = fs.promises;
import * as winston from "winston";
export interface IDatabaseConfig {
host: string;
port: number;
@ -25,31 +27,31 @@ export class Config {
private static checkedMissingField: boolean = false;
public static async load(): Promise<Config> {
public static async load(logger: winston.Logger): Promise<Config> {
const json: Buffer = await fsp.readFile("config.json");
const config = JSON.parse(json.toString()) as Config;
if (!Config.checkedMissingField) {
const defaultConfigJson = await fsp.readFile("config.default.json");
const defaultConfig = JSON.parse(defaultConfigJson.toString());
Config.checkAllMissingFields(config, defaultConfig);
Config.checkAllMissingFields(logger, config, defaultConfig);
Config.checkedMissingField = true;
}
return config;
}
private static checkAllMissingFields(obj: object, model: object, parentName: string = "") {
private static checkAllMissingFields(logger: winston.Logger, obj: object, model: object, parentName: string = "") {
const missing = Config.hasMissingFields(obj, model);
if (parentName !== "") {
parentName += ".";
}
for (const field of missing) {
console.warn(`Field ${parentName}${field} is missing in config.json!`);
logger.warn(`Field ${parentName}${field} is missing in config.json!`);
}
for (const key in model) {
if (typeof model[key] === "object" && obj.hasOwnProperty(key)) {
Config.checkAllMissingFields(obj[key], model[key], parentName + key);
Config.checkAllMissingFields(logger, obj[key], model[key], parentName + key);
}
}
}

View file

@ -147,22 +147,20 @@ export class CharacterController {
}
}
public async character(req: express.Request, res: express.Response): Promise<void> {
public async character(req: express.Request, res: express.Response, next: express.NextFunction): Promise<void> {
const realmName = req.params.realm;
const charName = req.params.name;
const realm = this.getRealm(realmName);
if (realm === undefined) {
// Could not find realm
res.sendStatus(404);
return;
return next(404);
}
const charData = await this.getCharacterData(realm, charName);
if (charData === null) {
// Could not find character
res.sendStatus(404);
return;
return next(404);
}
const equipmentData = await this.getEquipmentData(realmName, charData.guid);
@ -193,22 +191,20 @@ export class CharacterController {
this.armory.gc();
}
public async talents(req: express.Request, res: express.Response): Promise<void> {
public async talents(req: express.Request, res: express.Response, next: express.NextFunction): Promise<void> {
const realmName = req.params.realm;
const charName = req.params.name;
const realm = this.getRealm(realmName);
if (realm === undefined) {
// Could not find realm
res.sendStatus(404);
return;
return next(404);
}
const charData = await this.getCharacterData(realm, charName);
if (charData === null) {
// Could not find character
res.sendStatus(404);
return;
return next(404);
}
res.render("character-talents.html", {
@ -222,22 +218,20 @@ export class CharacterController {
});
}
public async achievements(req: express.Request, res: express.Response): Promise<void> {
public async achievements(req: express.Request, res: express.Response, next: express.NextFunction): Promise<void> {
const realmName = req.params.realm;
const charName = req.params.name;
const realm = this.getRealm(realmName);
if (realm === undefined) {
// Could not find realm
res.sendStatus(404);
return;
return next(404);
}
const charData = await this.getCharacterData(realm, charName);
if (charData === null) {
// Could not find character
res.sendStatus(404);
return;
return next(404);
}
res.render("character-achievements.html", {
@ -246,22 +240,20 @@ export class CharacterController {
});
}
public async achievementsData(req: express.Request, res: express.Response): Promise<void> {
public async achievementsData(req: express.Request, res: express.Response, next: express.NextFunction): Promise<void> {
const realmName = req.params.realm;
const character = parseInt(req.params.character) || -1;
const realm = this.getRealm(realmName);
if (realm === undefined) {
// Could not find realm
res.sendStatus(404);
return;
return next(404);
}
const charData = await this.getCharacterData(realm, character);
if (charData === null) {
// Could not find character
res.sendStatus(404);
return;
return next(404);
}
res.json({

View file

@ -44,14 +44,13 @@ export class IndexController {
});
}
public async search(req: express.Request, res: express.Response): Promise<void> {
public async search(req: express.Request, res: express.Response, next: express.NextFunction): Promise<void> {
const realmName = req.query.realm as string;
const realm = realmName === undefined ?
this.armory.config.realms[0] :
this.armory.config.realms.find(r => r.name === realmName);
if (realm === undefined) {
res.status(400);
return;
return next(400);
}
const db = this.armory.getCharactersDb(realm.name);

5
src/index.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
declare module Express {
export interface Request {
id: string;
}
}