UNPKG

@bcgov/citz-imb-express-utilities

Version:
463 lines (435 loc) 16.6 kB
'use strict'; var zod = require('zod'); var express = require('express'); const stringParam = (param, optional = false) => optional ? zod.z .string() .min(1, { message: `\`${param}\` must be a non-empty string.` }) .optional() : zod.z.string().min(1, { message: `\`${param}\` must be a non-empty string.` }); const booleanParam = (param, optional = false) => optional ? zod.z .string() .optional() .refine((value) => value === undefined || value === 'true' || value === 'false', { message: `\`${param}\` must be a boolean ('true' or 'false') or undefined.`, }) .transform((value) => (value === undefined ? undefined : value === 'true')) : zod.z .string() .refine((value) => value === 'true' || value === 'false', { message: `\`${param}\` must be a boolean ('true' or 'false').`, }) .transform((value) => value === 'true'); const integerParam = (param, optional = false) => optional ? zod.z .string() .optional() .refine((value) => value === undefined || (!Number.isNaN(Number.parseInt(value, 10)) && Number.isInteger(Number.parseFloat(value))), { message: `\`${param}\` must be an integer or undefined.`, }) .transform((value) => (value === undefined ? undefined : Number.parseInt(value, 10))) : zod.z .string() .refine((value) => !Number.isNaN(Number.parseInt(value, 10)) && Number.isInteger(Number.parseFloat(value)), { message: `\`${param}\` must be an integer.`, }) .transform((value) => Number.parseInt(value, 10)); const numberParam = (param, optional = false) => optional ? zod.z .string() .optional() .refine((value) => value === undefined || (value !== '' && !Number.isNaN(Number.parseFloat(value))), { message: `\`${param}\` must be a number.`, }) .transform((value) => (value === undefined ? undefined : Number.parseFloat(value))) : zod.z .string() .refine((value) => value !== '' && !Number.isNaN(Number.parseFloat(value)), { message: `\`${param}\` must be a number.`, }) .transform((value) => Number.parseFloat(value)); const refineAtLeastOneNonEmpty = (keys) => { return (data) => { const nonEmptyValues = keys.filter((key) => { const value = data[key]; if (typeof value === 'string') return value.trim() !== ''; if (typeof value === 'boolean' || typeof value === 'number') return true; return false; }); return nonEmptyValues.length > 0; }; }; const transformRemoveEmpty = (obj) => { return Object.entries(obj).reduce((acc, [key, value]) => { if (value !== undefined && value !== null && !(typeof value === 'string' && value.trim() === '')) { acc[key] = value; } return acc; }, {}); }; class HttpError extends Error { statusCode; constructor(statusCode, message) { super(message); this.statusCode = statusCode; } } const HTTP_STATUS_CODES = { OK: 200, CREATED: 201, ACCEPTED: 202, NO_CONTENT: 204, RESET_CONTENT: 205, MOVED_PERMANENTLY: 301, FOUND: 302, SEE_OTHER: 303, NOT_MODIFIED: 304, TEMPORARY_REDIRECT: 307, PERMANENT_REDIRECT: 308, BAD_REQUEST: 400, UNAUTHORIZED: 401, PAYMENT_REQUIRED: 402, FORBIDDEN: 403, NOT_FOUND: 404, METHOD_NOT_ALLOWED: 405, CONFLICT: 409, GONE: 410, LENGTH_REQUIRED: 411, PRECONDITION_FAILED: 412, PAYLOAD_TOO_LARGE: 413, URI_TOO_LONG: 414, UNSUPPORTED_MEDIA_TYPE: 415, IM_A_TEAPOT: 418, MISDIRECTED_REQUEST: 421, TOO_MANY_REQUESTS: 429, REQUEST_HEADER_FIELDS_TOO_LARGE: 431, UNAVAILABLE_FOR_LEGAL_REASONS: 451, INTERNAL_SERVER_ERROR: 500, NOT_IMPLEMENTED: 501, BAD_GATEWAY: 502, SERVICE_UNAVAIBLABLE: 503, GATEWAY_TIMEOUT: 504, NOT_EXTENDED: 510, }; const DEFAULT_LOG_FUNCTION = ({ method, originalUrl, message, }) => { console.error(`REQUEST ERROR: [${method}] ${originalUrl}: ${message}`); }; const ANSI_CODES = { FOREGROUND: { BLACK: '\x1b[30m', RED: '\x1b[31m', GREEN: '\x1b[32m', GOLD: '\x1b[33m', BLUE: '\x1b[34m', PURPLE: '\x1b[35m', CYAN: '\x1b[36m', WHITE: '\x1b[37m', GREY: '\x1b[1m\x1b[30m', PINK: '\x1b[1m\x1b[31m', LIME: '\x1b[1m\x1b[32m', YELLOW: '\x1b[1m\x1b[33m', LIGHT_BLUE: '\x1b[1m\x1b[34m', MAGENTA: '\x1b[1m\x1b[35m', AQUA: '\x1b[1m\x1b[36m', }, BACKGROUND: { BLACK: '\x1b[40m', RED: '\x1b[41m', GREEN: '\x1b[42m', GOLD: '\x1b[43m', BLUE: '\x1b[44m', PURPLE: '\x1b[45m', CYAN: '\x1b[46m', WHITE: '\x1b[47m', GREY: '\x1b[1m\x1b[40m', PINK: '\x1b[1m\x1b[41m', LIME: '\x1b[1m\x1b[42m', YELLOW: '\x1b[1m\x1b[43m', LIGHT_BLUE: '\x1b[1m\x1b[44m', MAGENTA: '\x1b[1m\x1b[45m', AQUA: '\x1b[1m\x1b[46m', }, FORMATTING: { RESET: '\x1b[0m', BRIGHT: '\x1b[1m', DIM: '\x1b[2m', UNDERSCORE: '\x1b[4m', REVERSE: '\x1b[7m', ITALIC: '\x1b[3m', STRIKETHROUGH: '\x1b[9m', }, }; const sanitize = (input, options = { removeHTMLTags: true, removeSQLInjectionPatterns: true, removeScriptTags: true, removeNoSQLInjectionPatterns: true, }) => { const removeHTMLTags = (str) => { return str.replace(/<[^>]*>/g, ''); }; const removeSQLInjectionPatterns = (str) => { return str.replace(/(\b(SELECT|INSERT|DELETE|UPDATE|DROP|CREATE|ALTER|TRUNCATE|EXEC|UNION|ALL)\b)/gi, ''); }; const removeScriptTags = (str) => { return str.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ''); }; const removeNoSQLInjectionPatterns = (str) => { return str.replace(/(\$\b(where|regex|ne|eq|gt|gte|lt|lte|in|nin|exists)\b)/gi, ''); }; let sanitizedString = input; if (options.removeHTMLTags) { sanitizedString = removeHTMLTags(sanitizedString); } if (options.removeSQLInjectionPatterns) { sanitizedString = removeSQLInjectionPatterns(sanitizedString); } if (options.removeScriptTags) { sanitizedString = removeScriptTags(sanitizedString); } if (options.removeNoSQLInjectionPatterns) { sanitizedString = removeNoSQLInjectionPatterns(sanitizedString); } return sanitizedString; }; const validateZodRequestSchema = (obj, schema, errorMsgPrefix, options) => { try { const sanitizedSchema = schema.transform((data) => { const sanitizedData = {}; Object.keys(data).forEach((key) => { if (typeof data[key] === 'string') { sanitizedData[key] = sanitize(data[key], options?.sanitizationOptions); } else { sanitizedData[key] = data[key]; } }); return sanitizedData; }); return sanitizedSchema.parse(obj); } catch (error) { if (error instanceof zod.ZodError) { const formattedErrors = error.errors .map((e) => { const detail = { path: e.path, message: e.message, expected: '', received: '', }; if (e.code === zod.ZodIssueCode.invalid_type) { detail.expected = e.expected; detail.received = e.received; return `${detail.path.join('.')}: (Expected: ${detail.expected}, Received: ${detail.received}, Message: ${detail.message})`; } return `${detail.path.join('.')}: (Message: ${detail.message})`; }) .join(', '); throw new HttpError(HTTP_STATUS_CODES.BAD_REQUEST, `${errorMsgPrefix}${formattedErrors}`); } throw error; } }; const zodValidationMiddlewareFunctions = (req) => { req.getZodValidatedParams = (schema, options) => validateZodRequestSchema(req.params, schema, 'Request is malformed. Invalid path parameters: ', options); req.getZodValidatedQuery = (schema, options) => validateZodRequestSchema(req.query, schema, 'Request is malformed. Invalid query parameters: ', options); req.getZodValidatedBody = (schema, options) => validateZodRequestSchema(req.body, schema, 'Request is malformed. Invalid request body: ', options); }; const errorWrapper = (handler, options = {}) => { const { logFunction = DEFAULT_LOG_FUNCTION } = options; return async (req, res, next) => { try { await handler(req, res, next); } catch (error) { const { method, originalUrl, getStandardResponse } = req; let statusCode = HTTP_STATUS_CODES.INTERNAL_SERVER_ERROR; let message = 'An unexpected error occurred'; if (error instanceof HttpError) { statusCode = error.statusCode; message = error.message; } else if (error instanceof Error) { message = error.message; } logFunction({ method, originalUrl, statusCode, message }); const responseData = getStandardResponse({ success: false, data: { method, originalUrl, statusCode }, message, }); res.status(statusCode).json(responseData); } }; }; const isHealthy = errorWrapper(async (req, res) => { res.send('Application is healthy!'); }); const router$1 = express.Router(); router$1.route('/').get(isHealthy); const getConfig = (config) => { return errorWrapper(async (req, res) => { res.json(config); }); }; const router = express.Router(); const configRouter = (config) => { router.route('/').get(getConfig(config)); return router; }; const healthModule = (app) => { app.use('/health', router$1); }; const configModule = (app, config) => { app.use('/config', configRouter(config)); }; const getCurrentUTCComponents = () => { const now = new Date(); return { utcYear: now.getUTCFullYear(), utcMonth: now.getUTCMonth(), utcDate: now.getUTCDate(), utcHours: now.getUTCHours(), utcMinutes: now.getUTCMinutes(), utcSeconds: now.getUTCSeconds(), }; }; const formatDate = (year, month, date) => { return `${year}-${String(month + 1).padStart(2, '0')}-${String(date).padStart(2, '0')}`; }; const formatTime = (hours, minutes, seconds) => { return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; }; const isDaylightSavingTime = (month) => { return month > 2 && month < 11; }; const getPacificTimeComponents = (utcHours, utcDate, utcMonth, utcYear, isDST) => { let pacificHours = utcHours - 8; if (isDST) pacificHours += 1; const pacificTimeZone = isDST ? 'PDT' : 'PST'; let pacificDate = utcDate; let pacificMonth = utcMonth; let pacificYear = utcYear; if (pacificHours < 0) { pacificHours += 24; pacificDate -= 1; if (pacificDate < 1) { pacificMonth -= 1; if (pacificMonth < 0) { pacificMonth = 11; pacificYear -= 1; } pacificDate = new Date(pacificYear, pacificMonth + 1, 0).getDate(); } } return { pacificHours, pacificDate, pacificMonth, pacificYear, pacificTimeZone }; }; const getCurrentDateTime = () => { const { utcYear, utcMonth, utcDate, utcHours, utcMinutes, utcSeconds } = getCurrentUTCComponents(); const formattedDateUTC = formatDate(utcYear, utcMonth, utcDate); const formattedTimeUTC = formatTime(utcHours, utcMinutes, utcSeconds); const isDST = isDaylightSavingTime(utcMonth); const { pacificHours, pacificDate, pacificMonth, pacificYear, pacificTimeZone } = getPacificTimeComponents(utcHours, utcDate, utcMonth, utcYear, isDST); const formattedDatePacific = formatDate(pacificYear, pacificMonth, pacificDate); const formattedTimePacific = formatTime(pacificHours, utcMinutes, utcSeconds); return { formattedDateUTC, formattedTimeUTC, formattedDatePacific, formattedTimePacific, pacificTimeZone, }; }; const elapsedTimeMiddlewareFunction = (req) => { const startHrTime = process.hrtime(); req.getElapsedTimeInMs = () => { const elapsedHrTime = process.hrtime(startHrTime); const elapsedTimeInMs = elapsedHrTime[0] * 1000 + elapsedHrTime[1] / 1e6; return elapsedTimeInMs.toFixed(3); }; }; const { FOREGROUND, FORMATTING } = ANSI_CODES; const { NODE_ENV } = process.env; const NODE_VERSION = process.version; const MEMORY_USAGE = process.memoryUsage(); const CPU_USAGE = process.cpuUsage(); const formatBytes = (bytes) => `${(bytes / 1024 / 1024).toFixed(2)} MB`; const formatCPUUsage = (microseconds) => `${(microseconds / 1000000).toFixed(2)} seconds`; const serverStartupLogs = (port) => { if (port) { console.info(`${FOREGROUND.LIGHT_BLUE}Express Server started on port ${FORMATTING.RESET}${port}${FOREGROUND.LIGHT_BLUE}${FORMATTING.RESET}.`); } else { console.info(`${FOREGROUND.LIGHT_BLUE}Express Server started${FORMATTING.RESET}.`); } console.info(`${FOREGROUND.YELLOW}[NODE]${FORMATTING.RESET} Current version of node.js: ${NODE_VERSION}`); console.info(`${FOREGROUND.YELLOW}[NODE]${FORMATTING.RESET} Current NODE_ENV environment: ${NODE_ENV}`); console.info(`${FOREGROUND.YELLOW}[MEMORY]${FORMATTING.RESET} Startup memory usage:`); console.log(` RSS: ${formatBytes(MEMORY_USAGE.rss)}`); console.log(` Heap Total: ${formatBytes(MEMORY_USAGE.heapTotal)}`); console.log(` Heap Used: ${formatBytes(MEMORY_USAGE.heapUsed)}`); console.info(`${FOREGROUND.YELLOW}[CPU]${FORMATTING.RESET} Startup cpu usage:`); console.log(` User: ${formatCPUUsage(CPU_USAGE.user)}`); console.log(` System: ${formatCPUUsage(CPU_USAGE.system)}`); console.info(`${FOREGROUND.YELLOW}[UTC]${FORMATTING.RESET} Current date and time: ${getCurrentDateTime().formattedDateUTC}, ${getCurrentDateTime().formattedTimeUTC}`); console.info(`${FOREGROUND.YELLOW}[${getCurrentDateTime().pacificTimeZone}]${FORMATTING.RESET} Current date and time: ${getCurrentDateTime().formattedDatePacific}, ${getCurrentDateTime().formattedTimePacific}`); }; const standardResponse = (dataInput, req) => { const { success = true, data, message } = dataInput; const dateTime = getCurrentDateTime(); return { success, data, message: message ?? '', responseDateUTC: dateTime.formattedDateUTC, responseTimeUTC: dateTime.formattedTimeUTC, responseTimeInMs: req.getElapsedTimeInMs(), }; }; const expressUtilitiesMiddleware = (req, res, next) => { elapsedTimeMiddlewareFunction(req); zodValidationMiddlewareFunctions(req); req.getStandardResponse = (inputData) => standardResponse(inputData, req); next(); }; const safePromise = async (promise) => { try { const response = await promise; const contentType = response.headers.get("content-type"); if (contentType?.includes("application/json")) { const jsonData = (await response.json()); return [null, jsonData]; } return [null, null]; } catch (error) { return [error, null]; } }; exports.ANSI_CODES = ANSI_CODES; exports.DEFAULT_LOG_FUNCTION = DEFAULT_LOG_FUNCTION; exports.HTTP_STATUS_CODES = HTTP_STATUS_CODES; exports.HttpError = HttpError; exports.booleanParam = booleanParam; exports.configModule = configModule; exports.errorWrapper = errorWrapper; exports.expressUtilitiesMiddleware = expressUtilitiesMiddleware; exports.healthModule = healthModule; exports.integerParam = integerParam; exports.numberParam = numberParam; exports.refineAtLeastOneNonEmpty = refineAtLeastOneNonEmpty; exports.safePromise = safePromise; exports.serverStartupLogs = serverStartupLogs; exports.stringParam = stringParam; exports.transformRemoveEmpty = transformRemoveEmpty; exports.validateZodRequestSchema = validateZodRequestSchema; exports.zodValidationMiddlewareFunctions = zodValidationMiddlewareFunctions; //# sourceMappingURL=index.js.map