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
4 changes: 3 additions & 1 deletion src/middlewares/body-parse-error.middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,9 @@ describe('bodyParseErrorMiddleware', () => {
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
message: 'Invalid JSON in request body',
error: expect.objectContaining({
message: 'Invalid JSON in request body',
}),
})
);
expect(next).not.toHaveBeenCalled();
Expand Down
21 changes: 11 additions & 10 deletions src/middlewares/body-parse-error.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { logger } from '../utils/logger.utils';
import { getClientIp } from '../utils/client-ip.utils';
import { ErrorCode } from '../constants/error.constants';
import { sanitizeLogFieldValue } from '../utils/log-field-sanitizer.utils';
import { buildErrorResponse } from '../utils/api-response.utils';

const MUTATION_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);

Expand Down Expand Up @@ -52,17 +53,17 @@ export const bodyParseErrorMiddleware = (
});

if (isEntityTooLarge) {
res.status(413).json({
success: false,
code: ErrorCode.BAD_REQUEST,
message: 'Request payload too large',
});
res
.status(413)
.json(
buildErrorResponse(ErrorCode.BAD_REQUEST, 'Request payload too large')
);
return;
}

res.status(400).json({
success: false,
code: ErrorCode.BAD_REQUEST,
message: 'Invalid JSON in request body',
});
res
.status(400)
.json(
buildErrorResponse(ErrorCode.BAD_REQUEST, 'Invalid JSON in request body')
);
};
58 changes: 27 additions & 31 deletions src/middlewares/error.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,9 @@ export const errorHandler: ErrorRequestHandler = (
route: `${req.method} ${sanitizeLogFieldValue(req.originalUrl)}`,
requestId: req.requestId,
});
res.status(401).json({
success: false,
code: ErrorCode.JWT_ERROR,
message: 'Invalid or expired token',
});
res.status(401).json(
buildErrorResponse(ErrorCode.JWT_ERROR, 'Invalid or expired token')
);
return;
}

Expand All @@ -132,11 +130,9 @@ export const errorHandler: ErrorRequestHandler = (
route: `${req.method} ${sanitizeLogFieldValue(req.originalUrl)}`,
requestId: req.requestId,
});
res.status(401).json({
success: false,
code: ErrorCode.JWT_ERROR,
message: 'Token has expired',
});
res.status(401).json(
buildErrorResponse(ErrorCode.JWT_ERROR, 'Token has expired')
);
return;
}

Expand All @@ -157,22 +153,26 @@ export const errorHandler: ErrorRequestHandler = (
break;
}

res.status(400).json({
success: false,
code: ErrorCode.PRISMA_ERROR,
message,
...(envConfig.MODE === 'development' && { error: err.message }),
});
res.status(400).json(
buildErrorResponse(
ErrorCode.PRISMA_ERROR,
message,
envConfig.MODE === 'development'
? [{ message: err.message }]
: undefined
)
);
return;
}

// Handle custom API errors
if (err instanceof ApiError) {
res.status(err.statusCode).json({
success: false,
code: err.errorCode || ErrorCode.INTERNAL_ERROR,
message: err.message,
});
res.status(err.statusCode).json(
buildErrorResponse(
err.errorCode || ErrorCode.INTERNAL_ERROR,
err.message
)
);
return;
}

Expand All @@ -188,21 +188,17 @@ export const errorHandler: ErrorRequestHandler = (
contentLength: req.headers['content-length'],
limitBytes: err.limit,
});
res.status(413).json({
success: false,
code: ErrorCode.BAD_REQUEST,
message: 'Request payload too large',
});
res.status(413).json(
buildErrorResponse(ErrorCode.BAD_REQUEST, 'Request payload too large')
);
return;
}

// Handle syntax errors (malformed JSON)
if (err instanceof SyntaxError && 'body' in err) {
res.status(400).json({
success: false,
code: ErrorCode.BAD_REQUEST,
message: 'Invalid JSON format',
});
res.status(400).json(
buildErrorResponse(ErrorCode.BAD_REQUEST, 'Invalid JSON format')
);
return;
}

Expand Down
26 changes: 13 additions & 13 deletions src/modules/admin/admin.controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
sendValidationError,
sendCreatorParamNotFound,
sendForbidden,
sendError,
} from '../../utils/api-response.utils';
import { prisma } from '../../utils/prisma.utils';
import { emitAuditEvent } from '../../utils/audit.utils';
Expand Down Expand Up @@ -166,19 +167,18 @@ export const httpReplayIndexerEvents: AsyncController = async (
});

if (!lock.acquired) {
return res.status(409).json({
success: false,
error: {
code: ErrorCode.CONFLICT,
message: 'Indexer replay job is already running',
details: [
{
field: 'indexerReplayLock',
message: `Lock is held by ${lock.holder || 'another worker'} until ${lock.expiresAt || 'unknown time'}`,
},
],
},
});
return sendError(
res,
409,
ErrorCode.CONFLICT,
'Indexer replay job is already running',
[
{
field: 'indexerReplayLock',
message: `Lock is held by ${lock.holder || 'another worker'} until ${lock.expiresAt || 'unknown time'}`,
},
]
);
}

const replayInitiated = {
Expand Down
27 changes: 17 additions & 10 deletions src/modules/auth/auth.controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { SendMailAsync } from '../../utils/mail.utils';
import { HTTP_STATUS, logger } from '../../utils/logger.utils';
import bcrypt from 'bcrypt';
import { refreshAccessToken } from './token-refresh.utils';
import { buildErrorResponse, ErrorCode } from '../../utils/api-response.utils';

export const httpRegisterUserWithPassword: AsyncController = async (
req,
Expand Down Expand Up @@ -153,21 +154,27 @@ export const httpRefreshToken: AsyncController = async (req, res, next) => {
: undefined) ?? req.body?.token;

if (!token) {
return res.status(HTTP_STATUS.UNAUTHORIZED).json({
success: false,
code: 'invalid_token',
message: 'No token provided',
});
return res
.status(HTTP_STATUS.UNAUTHORIZED)
.json(
buildErrorResponse(ErrorCode.JWT_ERROR, 'No token provided', [
{ message: 'invalid_token' },
])
);
}

const result = refreshAccessToken(token);

if (!result.success) {
return res.status(result.status).json({
success: false,
code: result.code,
message: 'Token could not be refreshed',
});
return res
.status(result.status)
.json(
buildErrorResponse(
ErrorCode.JWT_ERROR,
'Token could not be refreshed',
[{ message: result.code }]
)
);
}

return res.status(HTTP_STATUS.OK).json({
Expand Down
12 changes: 6 additions & 6 deletions src/modules/health/health.controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { prisma } from '../../utils/prisma.utils';
import { envConfig } from '../../config';
import { indexerHeartbeat } from '../../utils/heartbeat.service';
import { checkIndexerCursorStalenessFromStore } from '../../utils/indexer-cursor-staleness.utils';
import { sendSuccess } from '../../utils/api-response.utils';
import { sendSuccess, sendError, ErrorCode } from '../../utils/api-response.utils';
import { PUBLIC_ENDPOINT_CACHE_SECONDS } from '../../constants/public-endpoint-cache.constants';
import { logger } from '../../utils/logger.utils';

Expand Down Expand Up @@ -159,11 +159,11 @@ export const healthCheck = async (_: Request, res: Response): Promise<void> => {
res.status(overallHealthy ? 200 : 503).json(healthData);
} catch (error) {
logger.error({ error }, 'Health check failed');
res.status(500).json({
success: false,
message: 'Health check failed',
error: error instanceof Error ? error.message : 'Unknown error',
});
sendError(res, 500, ErrorCode.INTERNAL_ERROR, 'Health check failed', [
{
message: error instanceof Error ? error.message : 'Unknown error',
},
]);
}
};

Expand Down
15 changes: 8 additions & 7 deletions src/modules/ledger/ledger.controllers.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Request, Response } from 'express';
import { httpGetLedgerStatus } from './ledger.controllers';
import { prisma } from '../../utils/prisma.utils';
import { sendSuccess } from '../../utils/api-response.utils';
import { sendSuccess, sendError } from '../../utils/api-response.utils';

// Mock prisma and api-response utils
jest.mock('../../utils/prisma.utils', () => ({
Expand All @@ -14,6 +14,8 @@ jest.mock('../../utils/prisma.utils', () => ({

jest.mock('../../utils/api-response.utils', () => ({
sendSuccess: jest.fn(),
sendError: jest.fn(),
ErrorCode: { INTERNAL_ERROR: 'INTERNAL_ERROR' },
}));

jest.mock('../../utils/timestamp-headers.utils', () => ({
Expand Down Expand Up @@ -90,12 +92,11 @@ describe('Ledger Controller', () => {
mockResponse as Response
);

expect(statusFn).toHaveBeenCalledWith(500);
expect(jsonFn).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
message: 'Failed to fetch ledger status',
})
expect(sendError).toHaveBeenCalledWith(
mockResponse,
500,
'INTERNAL_ERROR',
'Failed to fetch ledger status'
);
});
});
16 changes: 11 additions & 5 deletions src/modules/ledger/ledger.controllers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { Request, Response } from 'express';
import { prisma } from '../../utils/prisma.utils';
import { sendSuccess } from '../../utils/api-response.utils';
import {
sendSuccess,
sendError,
ErrorCode,
} from '../../utils/api-response.utils';
import { attachTimestampHeader } from '../../utils/timestamp-headers.utils';
import { logger } from '../../utils/logger.utils';

Expand Down Expand Up @@ -40,9 +44,11 @@ export const httpGetLedgerStatus = async (
});
} catch (error) {
logger.error({ error }, 'Failed to fetch ledger status');
res.status(500).json({
success: false,
message: 'Failed to fetch ledger status',
});
sendError(
res,
500,
ErrorCode.INTERNAL_ERROR,
'Failed to fetch ledger status'
);
}
};
16 changes: 16 additions & 0 deletions src/utils/api-response.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ interface ApiErrorResponse {
};
}

/**
* Thrown by {@link buildErrorResponse} when called with an empty string
* error code, since an empty code can't be used by clients to distinguish
* error types.
*/
export class InvalidErrorCode extends Error {
constructor() {
super('buildErrorResponse: error code must not be an empty string');
this.name = 'InvalidErrorCode';
}
}

/**
* Builds a structured error response body, embedding the request ID from the
* current async-local-storage context when available. The `requestId` field is
Expand All @@ -47,6 +59,7 @@ interface ApiErrorResponse {
* @param message - Human-readable error message
* @param details - Optional per-field validation details
* @returns Structured error response body ready to pass to `res.json()`
* @throws {InvalidErrorCode} when `code` is an empty string
*
* @example
* res.status(400).json(buildErrorResponse(ErrorCode.VALIDATION_ERROR, 'Bad input'));
Expand All @@ -56,6 +69,9 @@ export function buildErrorResponse(
message: string,
details?: Array<{ field?: string; message: string }>
): ApiErrorResponse {
if (!code) {
throw new InvalidErrorCode();
}
const requestId = requestContextStorage.getStore()?.requestId;
const body: ApiErrorResponse = {
success: false,
Expand Down
8 changes: 8 additions & 0 deletions src/utils/test/api-response.utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
buildErrorResponse,
zodIssuesToDetails,
ErrorCode,
ErrorCodeType,
InvalidErrorCode,
} from '../api-response.utils';
import { requestContextStorage } from '../als.utils';

Expand Down Expand Up @@ -135,6 +137,12 @@ describe('buildErrorResponse', () => {
expect(body.error).not.toHaveProperty('details');
});

it('throws InvalidErrorCode when the code is an empty string', () => {
expect(() => buildErrorResponse('' as ErrorCodeType, 'message')).toThrow(
InvalidErrorCode
);
});

it('requestId in response matches the requestId in the server log context', () => {
// Simulates the traceability requirement: the same requestId that appears
// in the error response body is the one stored in the ALS context (which
Expand Down
Loading