Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/core/app.module.core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { MongoStoreHealthIndicator } from '@waha/core/health/MongoStoreHealthInd
import { ChannelsInfoServiceCore } from '@waha/core/services/ChannelsInfoServiceCore';
import { parseBool } from '@waha/helpers';
import { BufferJsonReplacerInterceptor } from '@waha/nestjs/BufferJsonReplacerInterceptor';
import { generateRequestId, RequestIdInterceptor } from '@waha/nestjs/requestId';
import { HttpsExpress } from '@waha/nestjs/HttpsExpress';
import {
getPinoHttpUseLevel,
Expand Down Expand Up @@ -82,6 +83,7 @@ export const IMPORTS_CORE = [
LoggerModule.forRoot({
renameContext: 'name',
pinoHttp: {
genReqId: generateRequestId,
quietReqLogger: true,
level: getPinoLogLevel(),
useLevel: getPinoHttpUseLevel(),
Expand Down Expand Up @@ -196,6 +198,10 @@ export const CONTROLLERS = [
...AppsModuleExports.controllers,
];
export const PROVIDERS_BASE: Provider[] = [
{
provide: APP_INTERCEPTOR,
useClass: RequestIdInterceptor,
},
{
provide: APP_INTERCEPTOR,
useClass: BufferJsonReplacerInterceptor,
Expand Down
279 changes: 279 additions & 0 deletions src/nestjs/requestId.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
import { ExecutionContext, CallHandler } from '@nestjs/common';
import { of } from 'rxjs';

import {
DEFAULT_REQUEST_ID_HEADER,
generateRequestId,
getRequestIdHeader,
RequestIdInterceptor,
} from './requestId';

const ENV_KEY = 'WAHA_REQUEST_ID_HEADER';

function createExecutionContext(
reqId: unknown,
): { context: ExecutionContext; response: { setHeader: jest.Mock } } {
const response = { setHeader: jest.fn() } as any;
const httpContext = {
getRequest: jest.fn().mockReturnValue({ id: reqId }),
getResponse: jest.fn().mockReturnValue(response),
};
const context = {
switchToHttp: jest.fn().mockReturnValue(httpContext),
} as unknown as ExecutionContext;

return { context, response };
}

function createCallHandler(): CallHandler {
return { handle: jest.fn().mockReturnValue(of('response-body')) };
}

describe('getRequestIdHeader', () => {
afterEach(() => {
delete process.env[ENV_KEY];
});

it('returns default x-request-id when env var is not set', () => {
expect(getRequestIdHeader()).toBe(DEFAULT_REQUEST_ID_HEADER);
});

it('returns default when env var is an empty string', () => {
process.env[ENV_KEY] = '';

expect(getRequestIdHeader()).toBe(DEFAULT_REQUEST_ID_HEADER);
});

it('returns custom header from env var', () => {
process.env[ENV_KEY] = 'x-trace-id';

expect(getRequestIdHeader()).toBe('x-trace-id');
});

it('returns custom header with mixed case from env var', () => {
process.env[ENV_KEY] = 'X-Correlation-Id';

expect(getRequestIdHeader()).toBe('X-Correlation-Id');
});
});

describe('generateRequestId', () => {
afterEach(() => {
delete process.env[ENV_KEY];
});

it('generates a v4 UUID when no request-id header is present', () => {
const id = generateRequestId({ headers: {} } as any);

expect(id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
});

it('returns the caller-supplied header value when present', () => {
const id = generateRequestId({
headers: { [DEFAULT_REQUEST_ID_HEADER]: 'trace-abc-123' },
} as any);

expect(id).toBe('trace-abc-123');
});

it('returns first element when header value is an array', () => {
const id = generateRequestId({
headers: {
[DEFAULT_REQUEST_ID_HEADER]: ['trace-first', 'trace-second'],
},
} as any);

expect(id).toBe('trace-first');
});

it('generates unique UUIDs for successive calls without a header', () => {
const req = { headers: {} } as any;

const id1 = generateRequestId(req);
const id2 = generateRequestId(req);

expect(id1).not.toBe(id2);
expect(id1).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
expect(id2).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
});

it('preserves empty string header value', () => {
const id = generateRequestId({
headers: { [DEFAULT_REQUEST_ID_HEADER]: '' },
} as any);

expect(id).toBe('');
});

it('preserves non-UUID header value as-is', () => {
const id = generateRequestId({
headers: { [DEFAULT_REQUEST_ID_HEADER]: 'my-custom-id-!@#' },
} as any);

expect(id).toBe('my-custom-id-!@#');
});

it('reads from the configured header when env var is set', () => {
process.env[ENV_KEY] = 'x-trace-id';

const id = generateRequestId({
headers: { 'x-trace-id': 'trace-via-env' },
} as any);

expect(id).toBe('trace-via-env');
});

it('falls back to UUID when custom header is absent', () => {
process.env[ENV_KEY] = 'x-trace-id';

const id = generateRequestId({ headers: {} } as any);

expect(id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
});

it('ignores default header when a custom header is configured', () => {
process.env[ENV_KEY] = 'x-trace-id';

const id = generateRequestId({
headers: { [DEFAULT_REQUEST_ID_HEADER]: 'should-be-ignored' },
} as any);

expect(id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
});
});

describe('RequestIdInterceptor', () => {
afterEach(() => {
delete process.env[ENV_KEY];
});

it('sets response header from req.id string', () => {
const { context, response } = createExecutionContext(
'550e8400-e29b-41d4-a716-446655440000',
);
const handler = createCallHandler();
const interceptor = new RequestIdInterceptor();

interceptor.intercept(context, handler).subscribe({
next: (body) => {
expect(body).toBe('response-body');
},
});

expect(response.setHeader).toHaveBeenCalledWith(
'x-request-id',
'550e8400-e29b-41d4-a716-446655440000',
);
expect(handler.handle).toHaveBeenCalledTimes(1);
});

it('sets response header from req.id number', () => {
const { context, response } = createExecutionContext(42);
const handler = createCallHandler();
const interceptor = new RequestIdInterceptor();

interceptor.intercept(context, handler).subscribe();

expect(response.setHeader).toHaveBeenCalledWith('x-request-id', '42');
expect(handler.handle).toHaveBeenCalledTimes(1);
});

it('does not set header when req.id is undefined', () => {
const { context, response } = createExecutionContext(undefined);
const handler = createCallHandler();
const interceptor = new RequestIdInterceptor();

interceptor.intercept(context, handler).subscribe();

expect(response.setHeader).not.toHaveBeenCalled();
expect(handler.handle).toHaveBeenCalledTimes(1);
});

it('does not set header when req.id is null', () => {
const { context, response } = createExecutionContext(null);
const handler = createCallHandler();
const interceptor = new RequestIdInterceptor();

interceptor.intercept(context, handler).subscribe();

expect(response.setHeader).not.toHaveBeenCalled();
expect(handler.handle).toHaveBeenCalledTimes(1);
});

it('sets header when req.id is an empty string', () => {
const { context, response } = createExecutionContext('');
const handler = createCallHandler();
const interceptor = new RequestIdInterceptor();

interceptor.intercept(context, handler).subscribe();

expect(response.setHeader).toHaveBeenCalledWith('x-request-id', '');
expect(handler.handle).toHaveBeenCalledTimes(1);
});

it('passes through the observable result unchanged', () => {
const { context } = createExecutionContext('req-123');
const handler = createCallHandler();
const interceptor = new RequestIdInterceptor();

const results: unknown[] = [];
interceptor.intercept(context, handler).subscribe({
next: (body) => {
results.push(body);
},
});

expect(results).toEqual(['response-body']);
});

it('uses custom header name from env var in response', () => {
process.env[ENV_KEY] = 'x-correlation-id';
const { context, response } = createExecutionContext('trace-me');
const handler = createCallHandler();
const interceptor = new RequestIdInterceptor();

interceptor.intercept(context, handler).subscribe();

expect(response.setHeader).toHaveBeenCalledWith(
'x-correlation-id',
'trace-me',
);
});

it('uses mixed-case custom header from env var in response', () => {
process.env[ENV_KEY] = 'X-Trace-Id';
const { context, response } = createExecutionContext('trace-me');
const handler = createCallHandler();
const interceptor = new RequestIdInterceptor();

interceptor.intercept(context, handler).subscribe();

expect(response.setHeader).toHaveBeenCalledWith('X-Trace-Id', 'trace-me');
});

it('passes through result unchanged with custom header configured', () => {
process.env[ENV_KEY] = 'x-custom';
const { context } = createExecutionContext('req-456');
const handler = createCallHandler();
const interceptor = new RequestIdInterceptor();

const results: unknown[] = [];
interceptor.intercept(context, handler).subscribe({
next: (body) => {
results.push(body);
},
});

expect(results).toEqual(['response-body']);
});
});
60 changes: 60 additions & 0 deletions src/nestjs/requestId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { randomUUID } from 'node:crypto';
import { IncomingMessage } from 'node:http';

import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { Observable } from 'rxjs';

export const DEFAULT_REQUEST_ID_HEADER = 'x-request-id';

/**
* Returns the configured request ID header name.
* Reads `WAHA_REQUEST_ID_HEADER` from the environment, falling back to
* `x-request-id` when not set.
*/
export function getRequestIdHeader(): string {
return process.env.WAHA_REQUEST_ID_HEADER || DEFAULT_REQUEST_ID_HEADER;
}

/**
* Generates a request ID for pino-http's `genReqId` option.
* Uses the caller-supplied value from the configured incoming header
* (`WAHA_REQUEST_ID_HEADER`, default `x-request-id`) if present,
* otherwise generates a v4 UUID via `crypto.randomUUID()`.
*/
export function generateRequestId(req: IncomingMessage): string {
const headerName = getRequestIdHeader();
const incoming = req.headers[headerName];
if (incoming !== undefined && incoming !== null) {
return Array.isArray(incoming) ? incoming[0] : incoming;
}
return randomUUID();
}

/**
* NestJS interceptor that writes `req.id` into the configured response
* header (same name as the incoming header) so callers can correlate
* responses with their own tracing systems.
*/
@Injectable()
export class RequestIdInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const httpContext = context.switchToHttp();
const request = httpContext.getRequest<Request>();
const response = httpContext.getResponse<Response>();

const requestId = request.id;
if (requestId === undefined || requestId === null) {
return next.handle();
}

const headerName = getRequestIdHeader();
response.setHeader(headerName, String(requestId));
return next.handle();
}
}