CrazyDoctor 1 year ago
commit
d4efd8b5a6

+ 23 - 0
.eslintrc

@@ -0,0 +1,23 @@
+{
+  "root": true,
+  "parser": "@typescript-eslint/parser",
+  "ignorePatterns": ["dist"],
+  "parserOptions": {
+    "ecmaVersion": 2020
+  },
+  "rules": {
+    "indent": ["error", "tab"],
+    "semi": ["error", "always"],
+    "@typescript-eslint/no-explicit-any": "off",
+    "@typescript-eslint/no-unused-vars": "off",
+    "quotes": ["error", "single"],
+    "@typescript-eslint/explicit-member-accessibility": "error",
+    "@typescript-eslint/explicit-function-return-type": "error"
+  },
+  "plugins": [
+    "@typescript-eslint"
+  ],
+  "extends": [
+    "plugin:@typescript-eslint/recommended"
+  ]
+}

+ 6 - 0
.gitignore

@@ -0,0 +1,6 @@
+.idea/
+node_modules/
+dist/
+*.iml
+index.ts
+package-lock.json

+ 27 - 0
package.json

@@ -0,0 +1,27 @@
+{
+  "name": "org.crazydoctor.expressts",
+  "version": "1.0.0",
+  "devDependencies": {
+    "@types/express-session": "^1.17.7",
+    "@typescript-eslint/eslint-plugin": "^6.2.1",
+    "@typescript-eslint/parser": "^6.2.1",
+    "eslint": "^8.46.0",
+    "ncp": "^2.0.0"
+  },
+  "dependencies": {
+    "@types/express": "^4.17.17",
+    "@types/pino": "^7.0.5",
+    "express": "^4.18.2",
+    "express-session": "^1.17.3",
+    "nodemon": "^3.0.1",
+    "pino": "^8.14.2",
+    "pino-http": "^8.3.3",
+    "pino-pretty": "^10.2.0",
+    "ts-node": "^10.9.1",
+    "typescript": "^5.1.6"
+  },
+  "scripts": {
+    "lint": "eslint .",
+    "build": "npm run lint && tsc && ncp src/resources/ dist/resources/"
+  }
+}

+ 8 - 0
src/base/exceptions/IncorrectMethodException.ts

@@ -0,0 +1,8 @@
+class IncorrectMethodException extends Error {
+	public constructor() {
+		super('Incorrect method');
+		this.name = 'IncorrectMethod';
+	}
+}
+
+export default IncorrectMethodException;

+ 8 - 0
src/base/exceptions/InvalidMiddlewareException.ts

@@ -0,0 +1,8 @@
+class InvalidMiddlewareException extends Error {
+	public constructor(file: string) {
+		super(`Invalid middleware: ${file}`);
+		this.name = 'InvalidMiddleware';
+	}
+}
+
+export default InvalidMiddlewareException;

+ 8 - 0
src/base/exceptions/InvalidRouteException.ts

@@ -0,0 +1,8 @@
+class InvalidRouteException extends Error {
+	public constructor(file: string) {
+		super(`Invalid route: ${file}`);
+		this.name = 'InvalidRoute';
+	}
+}
+
+export default InvalidRouteException;

+ 8 - 0
src/base/exceptions/RouteNotSetException.ts

@@ -0,0 +1,8 @@
+class RouteNotSetException extends Error {
+	public constructor() {
+		super('Route was not set.');
+		this.name = 'RouteNotSet';
+	}
+}
+
+export default RouteNotSetException;

+ 8 - 0
src/base/exceptions/ServerNotInitializedException.ts

@@ -0,0 +1,8 @@
+class ServerNotInitializedException extends Error {
+	public constructor() {
+		super('Server not initialized');
+		this.name = 'ServerNotInitialized';
+	}
+}
+
+export default ServerNotInitializedException;

+ 33 - 0
src/base/http/HttpHandler.ts

@@ -0,0 +1,33 @@
+abstract class HttpHandler {
+	protected abstract order: number;
+	protected abstract route: string | null;
+	protected abstract action: (...any: any[]) => any;
+	protected context: any;
+	
+	public constructor(context: Record<string, any>) {
+		this.context = context ?? null;
+	}
+
+	public setContext(context: Record<string, any>): HttpHandler {
+		this.context = context;
+		return this;
+	}
+
+	public getContext(): Record<string, any> {
+		return this.context;
+	}
+
+	public getRoute(): string | null {
+		return this.route;
+	}
+
+	public getAction(): (...any: any[]) => any {
+		return this.action;
+	}
+
+	public getOrder(): number {
+		return this.order;
+	}
+}
+
+export default HttpHandler;

+ 6 - 0
src/base/http/HttpMethod.ts

@@ -0,0 +1,6 @@
+enum HttpMethod {
+    GET,
+    POST
+}
+
+export default HttpMethod;

+ 7 - 0
src/base/http/Middleware.ts

@@ -0,0 +1,7 @@
+import HttpHandler from './HttpHandler';
+
+abstract class Middleware extends HttpHandler {
+	protected abstract action: (...any: any[]) => any;
+}
+
+export default Middleware;

+ 14 - 0
src/base/http/Route.ts

@@ -0,0 +1,14 @@
+import {Request, Response} from 'express';
+import HttpMethod from './HttpMethod';
+import HttpHandler from './HttpHandler';
+
+abstract class Route extends HttpHandler {
+	protected abstract method: HttpMethod;
+	protected abstract action: (req: Request, res: Response) => any;
+
+	public getMethod(): HttpMethod {
+		return this.method;
+	}
+}
+
+export default Route;

+ 44 - 0
src/base/http/StatusCodes.ts

@@ -0,0 +1,44 @@
+enum StatusCodes {
+	CONTINUE = 100,
+	SWITCHING_PROTOCOLS = 101,
+	OK = 200,
+	CREATED = 201,
+	ACCEPTED = 202,
+	NON_AUTHORITATIVE_INFORMATION = 203,
+	NO_CONTENT = 204,
+	RESET_CONTENT = 205,
+	PARTIAL_CONTENT = 206,
+	MULTIPLE_CHOICES = 300,
+	MOVED_PERMANENTLY = 301,
+	MOVED_TEMPORARILY = 302,
+	SEE_OTHER = 303,
+	NOT_MODIFIED = 304,
+	USE_PROXY = 305,
+	TEMPORARY_REDIRECT = 307,
+	BAD_REQUEST = 400,
+	UNAUTHORIZED = 401,
+	PAYMENT_REQUIRED = 402,
+	FORBIDDEN = 403,
+	NOT_FOUND = 404,
+	METHOD_NOT_ALLOWED = 405,
+	NOT_ACCEPTABLE = 406,
+	PROXY_AUTHENTICATION_REQUIRED = 407,
+	REQUEST_TIMEOUT = 408,
+	CONFLICT = 409,
+	GONE = 410,
+	LENGTH_REQUIRED = 411,
+	PRECONDITION_FAILED = 412,
+	REQUEST_ENTITY_TOO_LARGE = 413,
+	REQUEST_URI_TOO_LONG = 414,
+	UNSUPPORTED_MEDIA_TYPE = 415,
+	REQUESTED_RANGE_NOT_SATISFIABLE = 416,
+	EXPECTATION_FAILED = 417,
+	INTERNAL_SERVER_ERROR = 500,
+	NOT_IMPLEMENTED = 501,
+	BAD_GATEWAY = 502,
+	SERVICE_UNAVAILABLE = 503,
+	GATEWAY_TIMEOUT = 504,
+	HTTP_VERSION_NOT_SUPPORTED = 505
+}
+
+export default StatusCodes;

+ 5 - 0
src/base/i18n/KeyParams.ts

@@ -0,0 +1,5 @@
+type KeyParams = {
+	[key: string]: any
+};
+
+export default KeyParams;

+ 80 - 0
src/base/i18n/i18n.ts

@@ -0,0 +1,80 @@
+import fs from 'fs';
+import i18nMap from './i18nMap';
+import KeyParams from './KeyParams';
+
+export class i18nLoader {
+	private static instance: i18nLoader;
+	private readonly map: i18nMap;
+	private locale: string;
+	public static defaultLocale = 'en_US';
+	public constructor(locale: string) {
+		this.locale = locale;
+		this.map = <i18nMap>{};
+	}
+
+	public static getInstance(locale?: string): i18nLoader {
+		if(!i18nLoader.instance)
+			i18nLoader.instance = new i18nLoader(locale ?? i18nLoader.defaultLocale);
+		return i18nLoader.instance;
+	}
+
+	public setLocale(locale: string): i18nLoader {
+		this.locale = locale;
+		return this;
+	}
+
+	public load(...paths: string[]): i18nLoader {
+		for(const p of paths) {
+			try {
+				const data = fs.readFileSync(p).toString('utf8');
+				try {
+					this.loadJson(<i18nMap>JSON.parse(data));
+				} catch (err) {
+					console.error('JSON parsing error:', err);
+				}
+			} catch(error) {
+				console.error(`i18n file '${p}' not found.`, error);
+			}
+		}
+		return this;
+	}
+
+	public loadJson(obj: i18nMap): i18nLoader {
+		for(const locale of Object.keys(obj)) {
+			if(!this.map[<string>locale]) this.map[locale] = {};
+			for(const key of Object.keys(obj[locale]))
+				this.map[locale][key] = obj[locale][key];
+		}
+		return this;
+	}
+
+	public get(key: string): string {
+		let value;
+
+		if(!this.map[this.locale] && !this.map[i18nLoader.defaultLocale])
+			return key;
+
+		if(this.map[i18nLoader.defaultLocale])
+			value = this.map[i18nLoader.defaultLocale][key];
+
+		if(this.map[this.locale])
+			value = this.map[this.locale][key] ?? value;
+
+		return value ?? key;
+	}
+}
+
+export const $$ = (key: string, params?: KeyParams): string => {
+	let text = i18nLoader.getInstance().get(key);
+
+	if(params) {
+		for(const param of Object.keys(params)) {
+			const regex = new RegExp(`\{${param}\}`, 'g');
+			text = text.replace(regex, params[param]);
+		}
+	}
+
+	return text;
+};
+
+export default {i18nLoader, $$};

+ 7 - 0
src/base/i18n/i18nMap.ts

@@ -0,0 +1,7 @@
+type i18nMap = {
+	[key: string]: {
+		[key: string]: string;
+	}
+}
+
+export default i18nMap;

+ 40 - 0
src/base/logger/Logger.ts

@@ -0,0 +1,40 @@
+import pino from 'pino';
+import expressPino, {HttpLogger} from 'pino-http';
+import MessageTypes from './MessageTypes';
+import StatusCodes from '../http/StatusCodes';
+
+class Logger {
+	private readonly logger: pino.Logger;
+	private readonly httpLogger: HttpLogger;
+
+	public constructor() {
+		this.logger = pino({
+			transport: {
+				target: 'pino-pretty',
+				options: {
+					translateTime: 'yyyy-mm-dd HH:MM:ss.l o',
+				}
+			}
+		});
+		this.httpLogger = expressPino({
+			logger: this.logger,
+			customLogLevel: (req, res, err) => {
+				if (res.statusCode >= StatusCodes.INTERNAL_SERVER_ERROR || err)
+					return MessageTypes.ERROR;
+				if (res.statusCode >= StatusCodes.BAD_REQUEST)
+					return MessageTypes.WARNING;
+				return MessageTypes.INFO;
+			},
+		});
+	}
+
+	public getLogger(): pino.Logger {
+		return this.logger;
+	}
+
+	public getHttpLogger(): HttpLogger {
+		return this.httpLogger;
+	}
+}
+
+export default Logger;

+ 8 - 0
src/base/logger/Message.ts

@@ -0,0 +1,8 @@
+import MessageTypes from './MessageTypes';
+
+type Message = {
+	type: MessageTypes,
+	text: string
+};
+
+export default Message;

+ 7 - 0
src/base/logger/MessageTypes.ts

@@ -0,0 +1,7 @@
+enum MessageTypes {
+	INFO = 'info',
+	WARNING = 'warn',
+	ERROR = 'error'
+}
+
+export default MessageTypes;

+ 9 - 0
src/base/util/Guid.ts

@@ -0,0 +1,9 @@
+import * as crypto from 'crypto';
+
+class Guid {
+	public static new(): string {
+		return crypto.randomUUID();
+	}
+}
+
+export default Guid;

+ 10 - 0
src/middlewares/ExpressJsonMiddleware.ts

@@ -0,0 +1,10 @@
+import Middleware from '../base/http/Middleware';
+import express from 'express';
+
+class ExpressJsonMiddleware extends Middleware {
+	protected order = -100;
+	protected route = null;
+	protected action = express.json();
+}
+
+export default ExpressJsonMiddleware;

+ 10 - 0
src/middlewares/ExpressUrlencodedMiddleware.ts

@@ -0,0 +1,10 @@
+import Middleware from '../base/http/Middleware';
+import express from 'express';
+
+class ExpressUrlencodedMiddleware extends Middleware {
+	protected order = -99;
+	protected route = null;
+	protected action = express.urlencoded({ extended: false });
+}
+
+export default ExpressUrlencodedMiddleware;

+ 14 - 0
src/middlewares/InternalServerErrorHandler.ts

@@ -0,0 +1,14 @@
+import Middleware from '../base/http/Middleware';
+import {NextFunction, Request, Response} from 'express';
+import {$$} from '../base/i18n/i18n';
+import StatusCodes from '../base/http/StatusCodes';
+
+class InternalServerErrorHandler extends Middleware {
+	protected order = 10000;
+	protected route = null;
+	protected action = (err: Error, req: Request, res: Response, next: NextFunction): any => {
+		res.status(StatusCodes.INTERNAL_SERVER_ERROR).send($$('org.crazydoctor.expressts.httpError.500'));
+	};
+}
+
+export default InternalServerErrorHandler;

+ 10 - 0
src/middlewares/LoggerMiddleware.ts

@@ -0,0 +1,10 @@
+import Middleware from '../base/http/Middleware';
+import Logger from '../base/logger/Logger';
+
+class LoggerMiddleware extends Middleware {
+	protected order = -97;
+	protected route = null;
+	protected action = (<Logger>this.context.getLogger()).getHttpLogger();
+}
+
+export default LoggerMiddleware;

+ 14 - 0
src/middlewares/NotFoundErrorHandler.ts

@@ -0,0 +1,14 @@
+import Middleware from '../base/http/Middleware';
+import {NextFunction, Request, Response} from 'express';
+import {$$} from '../base/i18n/i18n';
+import StatusCodes from '../base/http/StatusCodes';
+
+class NotFoundErrorHandler extends Middleware {
+	protected order = 9999;
+	protected route = null;
+	protected action = (req: Request, res: Response, next: NextFunction): any => {
+		res.status(StatusCodes.NOT_FOUND).send($$('org.crazydoctor.expressts.httpError.404', { url: req.url }));
+	};
+}
+
+export default NotFoundErrorHandler;

+ 11 - 0
src/middlewares/SessionMiddleware.ts

@@ -0,0 +1,11 @@
+import Middleware from '../base/http/Middleware';
+import session from 'express-session';
+import Guid from '../base/util/Guid';
+
+class SessionMiddleware extends Middleware {
+	protected order = -98;
+	protected route = null;
+	protected action = session({ secret: Guid.new(), resave: false, saveUninitialized: false });
+}
+
+export default SessionMiddleware;

+ 7 - 0
src/resources/i18n.json

@@ -0,0 +1,7 @@
+{
+  "en_US": {
+    "org.crazydoctor.expressts.start": "org.crazydoctor.expressts server started on port {port}",
+    "org.crazydoctor.expressts.httpError.500": "Internal server error",
+    "org.crazydoctor.expressts.httpError.404": "Page {url} not found"
+  }
+}

+ 15 - 0
src/routes/GetIndex.ts

@@ -0,0 +1,15 @@
+import {Request, Response} from 'express';
+import Route from '../base/http/Route';
+import HttpMethod from '../base/http/HttpMethod';
+import StatusCodes from '../base/http/StatusCodes';
+
+class GetIndex extends Route {
+	protected order = 0;
+	protected method = HttpMethod.GET;
+	protected route = '/';
+	protected action = (req: Request, res: Response): any => {
+		res.status(StatusCodes.OK).send('org.crazydoctor.expressts server sample page');
+	};
+}
+
+export default GetIndex;

+ 182 - 0
src/server/Server.ts

@@ -0,0 +1,182 @@
+import express, {Express} from 'express';
+import Middleware from '../base/http/Middleware';
+import Route from '../base/http/Route';
+import HttpMethod from '../base/http/HttpMethod';
+import RouteNotSetException from '../base/exceptions/RouteNotSetException';
+import IncorrectMethodException from '../base/exceptions/IncorrectMethodException';
+import Message from '../base/logger/Message';
+import MessageTypes from '../base/logger/MessageTypes';
+import Logger from '../base/logger/Logger';
+import ServerProperties from './ServerProperties';
+import {i18nLoader, $$} from '../base/i18n/i18n';
+import path from 'path';
+import fs from 'fs';
+import HttpHandler from '../base/http/HttpHandler';
+import InvalidMiddlewareException from '../base/exceptions/InvalidMiddlewareException';
+import InvalidRouteException from '../base/exceptions/InvalidRouteException';
+import ServerNotInitializedException from '../base/exceptions/ServerNotInitializedException';
+
+/** @sealed */
+class Server {
+	private instance: Express;
+	private readonly port: number;
+	private readonly logger: Logger;
+	private i18n: i18nLoader;
+	public static readonly i18nDefaultPath = '../resources/i18n.json';
+	public static readonly defaultMiddlewaresPath = '../middlewares';
+	public static readonly defaultRoutesPath = '../routes';
+	private readonly httpHandlers: {[key: string]: HttpHandler};
+	private initialized: boolean;
+
+	private readonly i18nPath?: string;
+	private readonly middlewaresPath?: string;
+	private readonly routesPath?: string;
+
+	public constructor(properties: ServerProperties) {
+		this.instance = express();
+		this.port = properties.port;
+		this.i18n = i18nLoader.getInstance().setLocale(properties.locale);
+		this.logger = new Logger();
+		this.httpHandlers = {};
+		this.i18nPath = properties.i18nPath;
+		this.middlewaresPath = properties.middlewaresPath;
+		this.routesPath = properties.routesPath;
+		this.initialized = false;
+	}
+
+	public async init(): Promise<Server> {
+		this.i18n.load(path.resolve(__dirname, Server.i18nDefaultPath));
+		await this.registerMiddlewares(path.resolve(__dirname, Server.defaultMiddlewaresPath));
+		await this.registerRoutes(path.resolve(__dirname, Server.defaultRoutesPath));
+		await this.postInit();
+		this.initialized = true;
+		return this;
+	}
+
+	private async postInit(): Promise<void> {
+		if(this.i18nPath)
+			this.i18n.load(this.i18nPath);
+		if(this.middlewaresPath)
+			await this.registerMiddlewares(this.middlewaresPath);
+		if(this.routesPath)
+			await this.registerRoutes(this.routesPath);
+	}
+
+	private processHttpHandlers(): void {
+		const handlers: HttpHandler[] = [];
+		for(const key in this.httpHandlers)
+			handlers.push(this.httpHandlers[key]);
+		handlers.sort((a, b) => a.getOrder() - b.getOrder());
+
+		for(const handler of handlers) {
+			if(handler instanceof Middleware)
+				this.addMiddleware(handler);
+			else if (handler instanceof Route)
+				this.addRoute(handler);
+		}
+	}
+
+	public addMiddleware(middleware: Middleware): Server {
+		if(middleware.getRoute() != null)
+			this.instance.use(<string>middleware.getRoute(), middleware.getAction());
+		else
+			this.instance.use(middleware.getAction());
+		return this;
+	}
+
+	public addRoute(route: Route): Server {
+		if(route.getRoute() == null)
+			throw new RouteNotSetException();
+		switch(route.getMethod()) {
+		case HttpMethod.GET:
+			return this.get(route);
+		case HttpMethod.POST:
+			return this.post(route);
+		default:
+			throw new IncorrectMethodException();
+		}
+	}
+
+	public logInfo(message: string): void {
+		this.logger.getLogger().info(message);
+	}
+
+	public logError(message: string): void {
+		this.logger.getLogger().error(message);
+	}
+
+	public logWarn(message: string): void {
+		this.logger.getLogger().warn(message);
+	}
+
+	public log(message: Message): void {
+		switch(message.type) {
+		case MessageTypes.WARNING:
+			return this.logWarn(message.text);
+		case MessageTypes.ERROR:
+			return this.logError(message.text);
+		default:
+			return this.logInfo(message.text);
+		}
+	}
+
+	private get(route: Route): Server {
+		this.instance.get(<string>route.getRoute(), route.getAction());
+		return this;
+	}
+
+	private post(route: Route): Server {
+		this.instance.post(<string>route.getRoute(), route.getAction());
+		return this;
+	}
+
+	public async registerRoutes(dir: string): Promise<Server> {
+		const files = fs.readdirSync(dir);
+
+		for(const file of files) {
+			if(/\.(ts|js)$/.test(file)) {
+				const {default: RouteClass} = await import(path.join(dir, file));
+				if(RouteClass.prototype instanceof Route) {
+					this.httpHandlers[RouteClass.name] = <HttpHandler>new RouteClass(this);
+				} else throw new InvalidRouteException(file);
+			}
+		}
+		return this;
+	}
+
+	public async registerMiddlewares(dir: string): Promise<Server> {
+		const files = fs.readdirSync(dir);
+
+		for(const file of files) {
+			if(/\.(ts|js)$/.test(file)) {
+				const {default: MiddlewareClass} = await import(path.join(dir, file));
+				if(MiddlewareClass.prototype instanceof Middleware) {
+					this.httpHandlers[MiddlewareClass.name] = <HttpHandler>new MiddlewareClass(this);
+				} else throw new InvalidMiddlewareException(file);
+			}
+		}
+		return this;
+	}
+
+	public getLogger(): Logger {
+		return this.logger;
+	}
+
+	public i18nLoad(path: string): Server {
+		this.i18n.load(path);
+		return this;
+	}
+
+	public start(callback?: () => any): void {
+		if(!this.initialized)
+			throw new ServerNotInitializedException();
+		this.processHttpHandlers();
+		const cb = (): void => {
+			this.logInfo($$('org.crazydoctor.expressts.start', { 'port': this.port }));
+			if(callback) callback();
+		};
+		this.instance.listen(this.port, cb);
+	}
+}
+
+export default Server;

+ 9 - 0
src/server/ServerProperties.ts

@@ -0,0 +1,9 @@
+type ServerProperties = {
+	port: number,
+	locale: string,
+	i18nPath?: string,
+	middlewaresPath?: string,
+	routesPath?: string
+};
+
+export default ServerProperties;

+ 12 - 0
tsconfig.json

@@ -0,0 +1,12 @@
+{
+  "compilerOptions": {
+    "target": "es6",
+    "module": "commonjs",
+    "outDir": "dist",
+    "strict": true,
+    "esModuleInterop": true
+  },
+  "include": [
+    "src"
+  ]
+}