import type { Context, APIGatewayEvent } from 'aws-lambda' import Logger from '../Logger/Logger.js' import Utils from '../Util/Utils.js' /** * Represents a request object with generic types for input, query parameters, and path parameters. * @class Request * @template InputType - The type of the input data for the request. * @template PathParamsType - The type of the path parameters for the request. * @template QueryParamsType - The type of the query parameters for the request. */ export default class Request { /** * Default paths to exclude from verbose logging (health checks, monitoring, etc.) */ private static readonly DEFAULT_NO_LOG_PATHS = [ '/health', '/healthcheck', '/health-check', '/ping', '/status', '/ready', '/readiness', '/liveness', '/metrics', '/_health', ] /** * Combined list of ignored paths, computed once at class-load time. */ private static readonly IGNORED_PATHS: string[] = [ ...Request.DEFAULT_NO_LOG_PATHS, ...(process.env.NO_LOG_PATHS ? process.env.NO_LOG_PATHS.split(',').map(p => p.trim()) : []), ] /** * Represents an API Gateway event for a request. * @type {APIGatewayEvent} */ private requestEvent: APIGatewayEvent /** * The context object for the current instance. */ private context: Context /** * Cached result of whether this request is a health/monitoring endpoint. */ private readonly _isHealthCheckPath: boolean /** * Constructs a new instance of the class. * @param {APIGatewayEvent} requestEvent - The API Gateway event object. * @param {Context} context - The context object. * @param {Logger} logger - The logger object. * @returns None */ constructor(requestEvent: APIGatewayEvent, context: Context, logger: Logger) { this.requestEvent = requestEvent this.context = context // Skip verbose logging for health check and monitoring endpoints const path = requestEvent.path || '' this._isHealthCheckPath = this.isHealthOrMonitoringPath(path) if (!this._isHealthCheckPath) { logger.info('Request:', this.getRelevantLogData(requestEvent, context)) } } /** * Builds a concise, sanitized request summary for logging. * Avoid logging the full event object, raw body, or sensitive header values. */ private getRelevantLogData(requestEvent: APIGatewayEvent, context: Context): Record { if (this.isQueueEvent(requestEvent)) { return this.getQueueEventLogData(requestEvent, context) } const headers = requestEvent.headers || {} const queryParams = requestEvent.queryStringParameters || {} const pathParams = requestEvent.pathParameters || {} const body = this.getBody() return { method: requestEvent.httpMethod, path: requestEvent.path, stage: requestEvent.requestContext?.stage, requestId: context.awsRequestId || requestEvent.requestContext?.requestId, sourceIp: requestEvent.requestContext?.identity?.sourceIp || headers['x-forwarded-for'], userAgent: headers['User-Agent'] || headers['user-agent'], contentType: headers['Content-Type'] || headers['content-type'], contentLength: headers['Content-Length'] || headers['content-length'], ...(Object.keys(pathParams).length > 0 ? { pathParams } : {}), ...(Object.keys(queryParams).length > 0 ? { queryParams } : {}), ...this.getBodyLogData(body), } } /** * Detects queue-style event payloads such as SQS events. */ private isQueueEvent(requestEvent: any): requestEvent is { Records: Array> } { return Array.isArray(requestEvent?.Records) } /** * Builds a concise log payload for SQS-style record batches. */ private getQueueEventLogData( requestEvent: { Records: Array> }, context: Context ): Record { const records = requestEvent.Records || [] const eventSources = this.getUniqueRecordValues(records, 'eventSource') const queueArns = this.getUniqueRecordValues(records, 'eventSourceARN') const regions = this.getUniqueRecordValues(records, 'awsRegion') const messageIds = records.map(record => record.messageId).filter(Boolean) const messageGroupIds = records.map(record => record.attributes?.MessageGroupId).filter(Boolean) const bodySummary = records.map(record => this.getBodySummary(this.parseRecordBody(record.body)) ) return { eventType: eventSources.length === 1 ? eventSources[0] : eventSources, recordCount: records.length, requestId: context.awsRequestId, ...(queueArns.length > 0 ? { queueArn: queueArns.length === 1 ? queueArns[0] : queueArns } : {}), ...(regions.length > 0 ? { region: regions.length === 1 ? regions[0] : regions } : {}), ...(messageIds.length > 0 ? { messageIds } : {}), ...(messageGroupIds.length > 0 ? { messageGroupIds: [...new Set(messageGroupIds)] } : {}), body: records.length === 1 ? bodySummary[0] : bodySummary, } } private getUniqueRecordValues(records: Array>, key: string): string[] { return [...new Set(records.map(record => record[key]).filter(Boolean))] } /** * Parses record bodies when they are JSON strings; otherwise returns the raw value. */ private parseRecordBody(body: unknown): unknown { if (typeof body !== 'string') return body try { return JSON.parse(body) } catch { return body } } /** * Logs body shape instead of full request payload to keep logs useful and safe. */ private getBodyLogData(body: unknown): Record { if (body == null) return {} return { body: this.getBodySummary(body) } } private getBodySummary(body: unknown): Record { if (body == null) return { type: 'null' } if (Array.isArray(body)) { return { type: 'array', size: body.length } } if (Buffer.isBuffer(body)) { return { type: 'buffer', size: body.length } } if (typeof body === 'object') { return body as Record } if (typeof body === 'string') { return { type: 'string', size: body.length } } return { type: typeof body } } /** * Checks if the path is a health check or monitoring endpoint that should not be logged verbosely. * @param {string} path - The request path * @returns {boolean} - True if it's a health/monitoring endpoint */ private isHealthOrMonitoringPath(path: string): boolean { const lowerPath = path.toLowerCase() return Request.IGNORED_PATHS.some( ignored => lowerPath === ignored || lowerPath.startsWith(ignored + '/') ) } /** * Checks if the specified query parameter exists and has a valid value. * @param {keyof QueryParamsType} paramName - The name of the query parameter to check. * @returns {boolean} - True if the query parameter exists and has a valid value, false otherwise. */ public containsQueryParam(paramName: keyof QueryParamsType): boolean { const val = this.getQueryParam(paramName) return !!val && (Utils.isValidString(val) || Utils.isValidNumber(val)) } /** * Retrieves the value of a query parameter from the URL. * @param {keyof QueryParamsType} paramName - The name of the query parameter to retrieve. * @returns {string | null} The value of the query parameter, or null if it does not exist. */ public getQueryParam(paramName: keyof QueryParamsType): string { return Utils.caseInsensitiveObjectForKey( this.requestEvent.queryStringParameters, String(paramName) ) } /** * Retrieves the value of the specified header from the request event headers. * @param {string} headerName - The name of the header to retrieve. * @returns {string | null} - The value of the header, or null if the header is not found. */ public getHeader(headerName: string): string | null { return Utils.caseInsensitiveObjectForKey(this.requestEvent.headers, headerName) } /** * Retrieves the value of a context parameter from the request context object. * @param {string} cxtParam - The name of the context parameter to retrieve. * @returns The value of the context parameter, or null if it does not exist. */ public getContextParam(cxtParam: string): any | null { return Utils.caseInsensitiveObjectForKey(this.requestEvent.requestContext, cxtParam) } /** * Checks if the given parameter name exists in the PathParamsType object. * @param {keyof PathParamsType} paramName - The name of the parameter to check. * @returns {boolean} - True if the parameter exists, false otherwise. */ public containsPathParam(paramName: keyof PathParamsType): boolean { const val = this.getPathParam(paramName) return !!val && (Utils.isValidString(val) || Utils.isValidNumber(val)) } /** * Retrieves the value of a specific path parameter from the URL. * @param {keyof PathParamsType} paramName - The name of the path parameter to retrieve. * @returns {string} The value of the path parameter, or null if it does not exist. */ public getPathParam(paramName: keyof PathParamsType): string { return Utils.caseInsensitiveObjectForKey(this.requestEvent.pathParameters, String(paramName)) } /** * Retrieves the body of the request event and parses it if it is a string. * @returns {InputType} The parsed body of the request event. */ public getBody(raw?: boolean): InputType { const body: any = this.requestEvent.body if (raw) return Utils.isHybridlessContainer() ? this.requestEvent['rawBody'] : body if (typeof body === 'string' || body instanceof String) { try { return JSON.parse(body as string) } catch (e) { console.error('Error while getting request body!', e) } } return body } /** * Retrieves the path from the request event. * @returns {string} The path of the request event. */ public getPath(): string { return this.requestEvent.path } public isHealthCheckPath(): boolean { return this._isHealthCheckPath } public static isHealthCheckPath(path: string): boolean { const lowerPath = (path || '').toLowerCase() return Request.IGNORED_PATHS.some( ignored => lowerPath === ignored || lowerPath.startsWith(ignored + '/') ) } /** * Retrieves the HTTP method of the current request. * @returns {string} The HTTP method of the request. */ public getMethod(): HttpMethod { if (this.requestEvent.httpMethod) { const httpMethod = this.requestEvent.httpMethod.toUpperCase() if (httpMethod in HttpMethod) { return HttpMethod[httpMethod] } } throw new Error(`Invalid HTTP method: ${this.requestEvent.httpMethod}`) } /** * Retrieves the path parameters from the request event. * @returns {PathParamsType | null} - The path parameters object, or null if not found. */ public getPathParams(): PathParamsType { // type conversion guaranteed by validation return this.requestEvent.pathParameters as PathParamsType } /** * Retrieves the query parameters from the request event. * @returns {QueryParamsType | null} - The query parameters object, or null if not found. */ public getQueryParams(): QueryParamsType { // type conversion guaranteed by validation return this.requestEvent.queryStringParameters as QueryParamsType } /** * Retrieves the value of the 'Authorization' header from the request. * @returns The value of the 'Authorization' header, or null if it is not present. */ public getAuthorizationHeader(): string | null { return this.getHeader('Authorization') } /** * Retrieves the request ID associated with the current execution context. * @returns {string} The request ID. */ public getRequestID(): string { if (this.context.awsRequestId) return this.context.awsRequestId return this.requestEvent.requestContext ? this.requestEvent.requestContext.requestId : 'unknown' } /** * Retrieves the origin IP address of the request. * @returns {string} The origin IP address. If the IP address is not available, it returns 'unknown'. */ public getOriginIP(): string { const origin = this.getContextParam('identity')?.sourceIp const hOrigin = this.getHeader('X-Forwarded-For') return origin ? origin : hOrigin ? hOrigin : 'unknown' } /** * Sets the fixed path parameters in the request event object. * @param {any[]} keys - An array of keys representing the path parameter names. * @param {any[]} result - An array of values representing the path parameter values. * @returns None */ public setFixedPathParams(keys: any[], result: any[]): void { if (!this.requestEvent.pathParameters) this.requestEvent.pathParameters = {} keys.forEach((key, index) => { if (this.requestEvent?.pathParameters) { this.requestEvent.pathParameters[key.name] = result[index + 1] } }) } } /** * Enum representing the HTTP methods. */ export enum HttpMethod { GET = 'GET', HEAD = 'HEAD', POST = 'POST', PUT = 'PUT', DELETE = 'DELETE', CONNECT = 'CONNECT', OPTIONS = 'OPTIONS', TRACE = 'TRACE', PATCH = 'PATCH', }