UNPKG

exstack

Version:

A utility library designed to simplify and enhance Express.js applications.

457 lines (451 loc) 14.6 kB
import { Router } from "express"; //#region src/enums.ts /** * Enum representing HTTP status codes. * * @publicApi http-status code */ const HttpStatus = Object.freeze({ CONTINUE: 100, "100_NAME": "CONTINUE", SWITCHING_PROTOCOLS: 101, "101_NAME": "SWITCHING_PROTOCOLS", PROCESSING: 102, "102_NAME": "PROCESSING", EARLYHINTS: 103, "103_NAME": "EARLY_HINTS", OK: 200, "200_NAME": "OK", CREATED: 201, "201_NAME": "CREATED", ACCEPTED: 202, "202_NAME": "ACCEPTED", NON_AUTHORITATIVE_INFORMATION: 203, "203_NAME": "NON_AUTHORITATIVE_INFORMATION", NO_CONTENT: 204, "204_NAME": "NO_CONTENT", RESET_CONTENT: 205, "205_NAME": "RESET_CONTENT", PARTIAL_CONTENT: 206, "206_NAME": "PARTIAL_CONTENT", AMBIGUOUS: 300, "300_NAME": "AMBIGUOUS", MOVED_PERMANENTLY: 301, "301_NAME": "MOVED_PERMANENTLY", FOUND: 302, "302_NAME": "FOUND", SEE_OTHER: 303, "303_NAME": "SEE_OTHER", NOT_MODIFIED: 304, "304_NAME": "NOT_MODIFIED", TEMPORARY_REDIRECT: 307, "307_NAME": "TEMPORARY_REDIRECT", PERMANENT_REDIRECT: 308, "308_NAME": "PERMANENT_REDIRECT", BAD_REQUEST: 400, "400_NAME": "BAD_REQUEST", UNAUTHORIZED: 401, "401_NAME": "UNAUTHORIZED", PAYMENT_REQUIRED: 402, "402_NAME": "PAYMENT_REQUIRED", FORBIDDEN: 403, "403_NAME": "FORBIDDEN", NOT_FOUND: 404, "404_NAME": "NOT_FOUND", METHOD_NOT_ALLOWED: 405, "405_NAME": "METHOD_NOT_ALLOWED", NOT_ACCEPTABLE: 406, "406_NAME": "NOT_ACCEPTABLE", PROXY_AUTHENTICATION_REQUIRED: 407, "407_NAME": "PROXY_AUTHENTICATION_REQUIRED", REQUEST_TIMEOUT: 408, "408_NAME": "REQUEST_TIMEOUT", CONFLICT: 409, "409_NAME": "CONFLICT", GONE: 410, "410_NAME": "GONE", LENGTH_REQUIRED: 411, "411_NAME": "LENGTH_REQUIRED", PRECONDITION_FAILED: 412, "412_NAME": "PRECONDITION_FAILED", PAYLOAD_TOO_LARGE: 413, "413_NAME": "PAYLOAD_TOO_LARGE", URI_TOO_LONG: 414, "414_NAME": "URI_TOO_LONG", UNSUPPORTED_MEDIA_TYPE: 415, "415_NAME": "UNSUPPORTED_MEDIA_TYPE", REQUESTED_RANGE_NOT_SATISFIABLE: 416, "416_NAME": "REQUESTED_RANGE_NOT_SATISFIABLE", EXPECTATION_FAILED: 417, "417_NAME": "EXPECTATION_FAILED", I_AM_A_TEAPOT: 418, "418_NAME": "I_AM_A_TEAPOT", MISDIRECTED: 421, "421_NAME": "MISDIRECTED", UNPROCESSABLE_ENTITY: 422, "422_NAME": "UNPROCESSABLE_ENTITY", LOCKED: 423, "423_NAME": "LOCKED", FAILED_DEPENDENCY: 424, "424_NAME": "FAILED_DEPENDENCY", TOO_EARLY: 425, "425_NAME": "TOO_EARLY", UPGRADE_REQUIRED: 426, "426_NAME": "UPGRADE_REQUIRED", PRECONDITION_REQUIRED: 428, "428_NAME": "PRECONDITION_REQUIRED", TOO_MANY_REQUESTS: 429, "429_NAME": "TOO_MANY_REQUESTS", REQUEST_HEADER_FIELDS_TOO_LARGE: 431, "431_NAME": "REQUEST_HEADER_FIELDS_TOO_LARGE", UNAVAILABLE_FOR_LEGAL_REASONS: 451, "451_NAME": "UNAVAILABLE_FOR_LEGAL_REASONS", INTERNAL_SERVER_ERROR: 500, "500_NAME": "INTERNAL_SERVER_ERROR", NOT_IMPLEMENTED: 501, "501_NAME": "NOT_IMPLEMENTED", BAD_GATEWAY: 502, "502_NAME": "BAD_GATEWAY", SERVICE_UNAVAILABLE: 503, "503_NAME": "SERVICE_UNAVAILABLE", GATEWAY_TIMEOUT: 504, "504_NAME": "GATEWAY_TIMEOUT", HTTP_VERSION_NOT_SUPPORTED: 505, "505_NAME": "HTTP_VERSION_NOT_SUPPORTED", VARIANT_ALSO_NEGOTIATES: 506, "506_NAME": "VARIANT_ALSO_NEGOTIATES", INSUFFICIENT_STORAGE: 507, "507_NAME": "INSUFFICIENT_STORAGE", LOOP_DETECTED: 508, "508_NAME": "LOOP_DETECTED", BANDWIDTH_LIMIT_EXCEEDED: 509, "509_NAME": "BANDWIDTH_LIMIT_EXCEEDED", NOT_EXTENDED: 510, "510_NAME": "NOT_EXTENDED", NETWORK_AUTHENTICATION_REQUIRED: 511, "511_NAME": "NETWORK_AUTHENTICATION_REQUIRED" }); //#endregion //#region src/errors.ts /** * Get a human-readable error name from the HTTP status code. * @param {number} status - The HTTP status code. * @returns {string} - The formatted error name. */ const getErrorName = (status) => { if (status < 400 || status > 511) return "HttpError"; const statusKey = HttpStatus[`${status}_NAME`]; if (!statusKey) return "HttpError"; const name = statusKey.toLowerCase().replace(/_/g, " ").replace(/\b\w/g, (char) => char.toUpperCase()).replace(/\s+/g, ""); return name.endsWith("Error") ? name : name.concat("Error"); }; /** * Base class for handling HTTP errors. * @extends {Error} */ var HttpError = class HttpError extends Error { /** * Creates an instance of `HTTPException`. * @param status - HTTP status code for the exception. Defaults to 500. * @param options - Additional options for the exception. */ constructor(status = HttpStatus.INTERNAL_SERVER_ERROR, options) { super(typeof options.message === "string" ? options.message : getErrorName(status)); this.status = status; this.options = options; this.name = getErrorName(status); Error.captureStackTrace(this, this.constructor); } /** * Check if the given error is an instance of HttpError. * @param {unknown} value - The error to check. * @returns {boolean} - True if the error is an instance of HttpError, false otherwise. * * @example * if (HttpError.isHttpError(error)) { * // Handle the HttpError * } */ static isHttpError = (value) => value instanceof HttpError; /** * Convert the HttpError instance to a Body object. * @example * const errorBody = new HttpError(404, {message: 'Not Found'}).body; */ get body() { const { name: error, status } = this; const { message, data = null, code = null } = this.options; return { status, error, message, data, code }; } /** * Send the json of the error in an HTTP response. * @param {Response} res - The Express response object. * * @example * new HttpError(404, {message: 'Not Found'}).toJson(res); */ toJson(res) { res.status(this.status).json(this.body); } }; /** * Utility function to create custom error classes. * @param status - HTTP status code. * @returns - A new error class. * @example * const NotFoundError = createHttpErrorClass(HttpStatus.NOT_FOUND); */ const createHttpErrorClass = (status) => class extends HttpError { constructor(message, options = {}) { super(status, { message, ...options }); } }; /** * Represents a Bad Request HTTP error (400). * @extends {HttpError} */ const BadRequestError = createHttpErrorClass(HttpStatus.BAD_REQUEST); /** * Represents a Conflict HTTP error (409). * @extends {HttpError} */ const ConflictError = createHttpErrorClass(HttpStatus.CONFLICT); /** * Represents a Forbidden HTTP error (403). * @extends {HttpError} */ const ForbiddenError = createHttpErrorClass(HttpStatus.FORBIDDEN); /** * Represents a Not Found HTTP error (404). * @extends {HttpError} */ const NotFoundError = createHttpErrorClass(HttpStatus.NOT_FOUND); /** * Represents an UnAuthorized HTTP error (401). * @extends {HttpError} */ const UnAuthorizedError = createHttpErrorClass(HttpStatus.UNAUTHORIZED); /** * Represents an Internal Server Error HTTP error (500). * @extends {HttpError} */ const InternalServerError = createHttpErrorClass(HttpStatus.INTERNAL_SERVER_ERROR); /** * Represents an Content Too Larger Error HTTP error (413). * @extends {HttpError} */ const ContentTooLargeError = createHttpErrorClass(HttpStatus.PAYLOAD_TOO_LARGE); //#endregion //#region src/utils.ts /** * Express middleware to handle `HttpError` and unknown errors. * * - Sends JSON response for `HttpError` instances. * - Logs unknown errors and sends generic error response. * - Includes detailed error info in development (`isDev`). * * @param {Boolean} [isDev = true] - Flag to indicate if the environment is development. * @param {(error: unknown) => void} [logger = console.error] - Function to log errors. * @returns {ErrorRequestHandler} - Middleware for handling errors. * * @example * // Basic usage with default options: * app.use(errorHandler(process.env.NODE_ENV !== 'production')); * // Custom usage with a logging function in production mode: * app.use(errorHandler(conf.isDev, logger.error)); */ const errorHandler = (isDev = true, logger = console.error) => (err, _req, res, _next) => { if (HttpError.isHttpError(err)) { if (err.options.cause) logger?.(err.options.cause); return err.toJson(res); } logger?.(err); const unknown = { status: HttpStatus.INTERNAL_SERVER_ERROR, error: "InternalServerError", message: isDev ? err.message || "Unexpected error" : "Something went wrong", stack: isDev ? err.stack : void 0 }; res.status(unknown.status).json(unknown); }; /** * Middleware to handle 404 Not Found errors. * * This function creates an Express router that catches all requests to * undefined routes and returns a JSON response with a 404 error. * * @param {string} [path='*'] - The route pattern to match (default: '*'). * @returns {Router} Express router instance handling 404 errors. * * @example * app.use(notFound()) */ const notFound = (path = "*") => Router().all(path, (req, res) => new NotFoundError(`Cannot ${req.originalUrl} on ${req.method.toUpperCase()}`).toJson(res)); /** * Generates a permission object mapping subjects and actions to permission strings. * * @template A - List of actions that can be performed. * @template S - List of subjects (resources) being acted upon. * @template F - Optional object specifying which actions are allowed per subject. * * @param {Options<A, S, F>} options - The options object containing actions, subjects, and an optional filter. * @returns {Readonly<PermissionMapping<A, S, F>>} A frozen object mapping subjects and actions to permission strings. * * @example * const permissions = makePermission({ * actions: ['create', 'read', 'update', 'delete'] as const, * subjects: ['user', 'post', 'comment'] as const, * filter: { * user: ['read'], * post: ['create', 'update'], * }, * }); * console.log(permissions.USER_CREATE); */ function makePermission(options) { const { actions, subjects, filter } = options; const map_data = subjects.flatMap((subject) => (filter?.[subject] ?? actions).map((action) => [`${subject}_${action}`.toUpperCase(), `${subject.toLowerCase()}:${action.toLowerCase()}`])); return Object.freeze(Object.fromEntries(map_data)); } //#endregion //#region src/api-res.ts /** * ApiRes class for standardizing API responses */ var ApiRes = class ApiRes { /** * Creates an instance of ApiRes. * @param {any} result - The result of the operation * @param {Status} status - The HTTP status code * @param {string} message - The response message */ constructor(result = {}, status = HttpStatus.OK, message = "Operation successful") { this.result = result; this.status = status; this.message = message; } /** * Returns the Body (JSON) representation of the response. * @returns The Body (JSON) representation of the response * * @example * new ApiRes('Hello World', 200).body; */ get body() { return { status: this.status, message: this.message, result: this.result }; } /** * Send the json of HTTP response. * @param {Response} res - The Express response object. * * @example * new ApiRes('Hello World', 200).toJson(res); */ toJson(res) { res.status(this.status).json(this.body); } /** * Creates an OK (200) response. * @param {any} result - The result to be included in the response * @param {string} [message='Request processed successfully'] - The response message * @returns {ApiRes} An ApiRes instance with OK status */ static ok = (result, message = "Request processed successfully") => new ApiRes(result, HttpStatus.OK, message); /** * Creates a Created (201) response. * @param {any} result - The result to be included in the response * @param {string} [message='Resource created successfully'] - The response message * @returns {ApiRes} An ApiRes instance with Created status */ static created = (result, message = "Resource created successfully") => new ApiRes(result, HttpStatus.CREATED, message); /** * Creates a paginated OK (200) response. * @param {any} data - The paginated data * @param {object} meta - Metadata for pagination * @param {string} [message='Data retrieved successfully'] - The response message * @returns {ApiRes} An ApiRes instance with OK status and paginated data */ static paginated = (data, meta, message = "Data retrieved successfully") => new ApiRes({ ...meta, data }, HttpStatus.OK, message); }; //#endregion //#region src/handler.ts /** * Processes the result of a route handler and sends the appropriate response. * * @param {unknown} result - The result of the handler, either an ApiRes instance or any other value. * @param {Response} res - The Express response object. */ const handleResult = (result, res) => { if (result instanceof ApiRes) result.toJson(res); else if (result && result !== res) res.send(result); }; /** * Wraps an async route handler to manage errors and response handling. * * @param {Handler} callback - The route handler, which can return a value or a Promise. * @returns {Handler} - A wrapped handler with error and result handling. * * @example * // without type * app.get('/example', handler(async () => await fetchData())); * // with body type * app.get('/example', handler<InputType<{name: string}>>(async req => await fetchData(req.body.name))); * // with param type * app.get('/example', handler<InputType<any, {name: string}>>(async req => await fetchData(req.param.name))); * // with query type * app.get('/example', handler<InputType<any, any, {name: string}>>(async req => await fetchData(req.query.name))); */ const handler = (callback) => async (req, res, next) => { try { const result = callback(req, res, next); if (result instanceof Promise) await result.then((value) => handleResult(value, res)).catch(next); else handleResult(result, res); } catch (error) { next(error); } }; /** * @param clsOrInstance - The class constructor or an instance of the class. * @param args - The arguments for the class constructor. * @returns A proxied instance where all methods are wrapped with `async-handler`. * * @example * class MyClass { * async myMethod() { * return 'Hello, World!'; * } * } * const instance = proxyWrapper(MyClass); * * app.get("/", instance.myMethod) */ const proxyWrapper = (clsOrInstance, ...args) => { const instance = typeof clsOrInstance === "function" ? new clsOrInstance(...args) : clsOrInstance; return new Proxy(instance, { get(target, prop, receiver) { const value = Reflect.get(target, prop, receiver); return typeof value === "function" ? handler(value.bind(target)) : value; }, set() { throw new Error("Overriding methods and properties is not allowed."); } }); }; //#endregion export { ApiRes, BadRequestError, ConflictError, ContentTooLargeError, ForbiddenError, HttpError, HttpStatus, InternalServerError, NotFoundError, UnAuthorizedError, createHttpErrorClass, errorHandler, getErrorName, handler, makePermission, notFound, proxyWrapper };