Skip to content
Merged
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
2 changes: 1 addition & 1 deletion api/src/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Request, Response, NextFunction } from 'express';

Check warning on line 1 in api/src/__tests__/auth.test.ts

View workflow job for this annotation

GitHub Actions / API — Lint, Test, Build (20)

'Request' is defined but never used
import jwt from 'jsonwebtoken';
import { authenticateToken, AuthRequest } from '../middleware/auth';
import { UnauthorizedError } from '../utils/errors';
Expand Down Expand Up @@ -36,7 +36,7 @@
authenticateToken(mockRequest as AuthRequest, mockResponse as Response, mockNext);

expect(mockNext).toHaveBeenCalledWith();
expect(mockRequest.user).toEqual({ address: '0x1234567890123456789012345678901234567890' });
expect(mockRequest.user).toMatchObject({ address: '0x1234567890123456789012345678901234567890' });
});

it('should return 401 when token is missing', () => {
Expand Down
10 changes: 8 additions & 2 deletions api/src/__tests__/bodySizeLimit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,24 @@
const createTestApp = (limit = config.bodySizeLimit.limit) => {
const testApp = express();
// Override the config for testing
const originalLimit = config.bodySizeLimit.limit;

Check warning on line 11 in api/src/__tests__/bodySizeLimit.test.ts

View workflow job for this annotation

GitHub Actions / API — Lint, Test, Build (20)

'originalLimit' is assigned a value but never used
config.bodySizeLimit.limit = limit;
testApp.use(bodySizeLimitMiddleware);
testApp.post('/test', (req, res) => {
res.status(200).json({ success: true });
});
testApp.use(errorHandler);
// Restore original limit
config.bodySizeLimit.limit = originalLimit;
// Don't restore limit here because the request executes asynchronously later.
// Instead we'll manage it per-test or use a wrapper.
return testApp;
};

const originalLimit = config.bodySizeLimit.limit;

afterEach(() => {
config.bodySizeLimit.limit = originalLimit;
});

describe('bodySizeLimitMiddleware', () => {
it('should allow requests with body size within limit', async () => {
const app = createTestApp('10kb');
Expand Down
3 changes: 3 additions & 0 deletions api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ import { errorHandler } from './middleware/errorHandler';
import { idempotencyMiddleware } from './middleware/idempotency';
import { swaggerSpec } from './config/swagger';
import logger from './utils/logger';
import { requestIdMiddleware } from './middleware/requestId';

const app: Application = express();
app.use(requestIdMiddleware);

const ipRateLimitStore = new MemoryStore();
const userRateLimitStore = new MemoryStore();

Expand Down
53 changes: 53 additions & 0 deletions api/src/middleware/__tests__/requestId.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Request, Response, NextFunction } from 'express';
import { requestIdMiddleware } from '../requestId';
import { requestContext } from '../../utils/requestContext';

describe('requestIdMiddleware', () => {
let mockRequest: Partial<Request>;
let mockResponse: Partial<Response>;
let nextFunction: NextFunction;

beforeEach(() => {
mockRequest = {
headers: {},
};
mockResponse = {
setHeader: jest.fn(),
};
nextFunction = jest.fn();
});

it('should generate a new UUID if x-request-id header is not present', () => {
requestIdMiddleware(mockRequest as Request, mockResponse as Response, nextFunction);

expect(mockRequest.id).toBeDefined();
expect(typeof mockRequest.id).toBe('string');
expect(mockRequest.id?.length).toBeGreaterThan(0);
expect(mockResponse.setHeader).toHaveBeenCalledWith('x-request-id', mockRequest.id);
expect(nextFunction).toHaveBeenCalled();
});

it('should use incoming x-request-id header if present', () => {
mockRequest.headers = { 'x-request-id': 'test-req-id' };

requestIdMiddleware(mockRequest as Request, mockResponse as Response, nextFunction);

expect(mockRequest.id).toBe('test-req-id');
expect(mockResponse.setHeader).toHaveBeenCalledWith('x-request-id', 'test-req-id');
expect(nextFunction).toHaveBeenCalled();
});

it('should run next function within async local storage context', (done) => {
nextFunction = jest.fn(() => {
try {
const storeId = requestContext.getStore();
expect(storeId).toBe(mockRequest.id);
done();
} catch (err) {
done(err);
}
});

requestIdMiddleware(mockRequest as Request, mockResponse as Response, nextFunction);
});
});
14 changes: 14 additions & 0 deletions api/src/middleware/requestId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Request, Response, NextFunction } from 'express';
import { randomUUID } from 'crypto';
import { requestContext } from '../utils/requestContext';

export const requestIdMiddleware = (req: Request, res: Response, next: NextFunction) => {
const reqId = req.headers['x-request-id'];
req.id = (Array.isArray(reqId) ? reqId[0] : reqId) || randomUUID();
res.setHeader('x-request-id', req.id);

// Set the request ID in the async local storage context for logger propagation
requestContext.run(req.id, () => {
next();
});
};
7 changes: 7 additions & 0 deletions api/src/types/express.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import 'express';

declare module 'express-serve-static-core' {
interface Request {
id: string;
}
}
8 changes: 7 additions & 1 deletion api/src/utils/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,35 +25,41 @@ export class ApiError extends Error {
export class ValidationError extends ApiError {
constructor(message: string) {
super(400, message, ErrorCode.VALIDATION_ERROR);
Object.setPrototypeOf(this, ValidationError.prototype);
}
}

export class UnauthorizedError extends ApiError {
constructor(message = 'Unauthorized') {
super(401, message, ErrorCode.UNAUTHORIZED);
Object.setPrototypeOf(this, UnauthorizedError.prototype);
}
}

export class NotFoundError extends ApiError {
constructor(message = 'Resource not found') {
super(404, message, ErrorCode.NOT_FOUND);
Object.setPrototypeOf(this, NotFoundError.prototype);
}
}

export class ConflictError extends ApiError {
constructor(message: string) {
super(409, message, ErrorCode.CONFLICT);
Object.setPrototypeOf(this, ConflictError.prototype);
}
}

export class PayloadTooLargeError extends ApiError {
constructor(message = 'Request body too large') {
super(413, message);
super(413, message, ErrorCode.VALIDATION_ERROR);
Object.setPrototypeOf(this, PayloadTooLargeError.prototype);
}
}

export class InternalServerError extends ApiError {
constructor(message = 'Internal server error') {
super(500, message, ErrorCode.INTERNAL_SERVER_ERROR);
Object.setPrototypeOf(this, InternalServerError.prototype);
}
}
10 changes: 10 additions & 0 deletions api/src/utils/logger.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
import winston from 'winston';
import { config } from '../config';
import { requestContext } from './requestContext';

const addRequestId = winston.format((info: winston.Logform.TransformableInfo) => {
const reqId = requestContext.getStore();
if (reqId) {
info.requestId = reqId;
}
return info;
});

const logger = winston.createLogger({
level: config.logging.level,
format: winston.format.combine(
addRequestId(),
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
Expand Down
3 changes: 3 additions & 0 deletions api/src/utils/requestContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { AsyncLocalStorage } from 'async_hooks';

export const requestContext = new AsyncLocalStorage<string>();
1 change: 1 addition & 0 deletions api/test-results.json

Large diffs are not rendered by default.

118 changes: 10 additions & 108 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading