feat: internationalize API error responses by locale
Add shared error codes with zh/en/ms messages, coded app exceptions, and locale-aware global filter. Frontends send X-Locale so error text matches the active UI language. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
45
apps/api/src/shared/common/app-error.ts
Normal file
45
apps/api/src/shared/common/app-error.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import type { ApiErrorCode, ApiErrorParams } from '@thebet365/shared';
|
||||
|
||||
function body(code: ApiErrorCode, params?: ApiErrorParams) {
|
||||
const clean = params
|
||||
? Object.fromEntries(
|
||||
Object.entries(params)
|
||||
.filter(([, v]) => v !== undefined && v !== null)
|
||||
.map(([k, v]) => [k, String(v)]),
|
||||
)
|
||||
: undefined;
|
||||
return { code, params: clean };
|
||||
}
|
||||
|
||||
export function appBadRequest(code: ApiErrorCode, params?: ApiErrorParams) {
|
||||
return new BadRequestException(body(code, params));
|
||||
}
|
||||
|
||||
export function appNotFound(code: ApiErrorCode, params?: ApiErrorParams) {
|
||||
return new NotFoundException(body(code, params));
|
||||
}
|
||||
|
||||
export function appForbidden(code: ApiErrorCode, params?: ApiErrorParams) {
|
||||
return new ForbiddenException(body(code, params));
|
||||
}
|
||||
|
||||
export function appUnauthorized(code: ApiErrorCode, params?: ApiErrorParams) {
|
||||
return new UnauthorizedException(body(code, params));
|
||||
}
|
||||
|
||||
export function isCodedExceptionResponse(
|
||||
res: unknown,
|
||||
): res is { code: ApiErrorCode; params?: ApiErrorParams } {
|
||||
return (
|
||||
typeof res === 'object' &&
|
||||
res !== null &&
|
||||
'code' in res &&
|
||||
typeof (res as { code: unknown }).code === 'string'
|
||||
);
|
||||
}
|
||||
@@ -5,38 +5,61 @@ import {
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { Request, Response } from 'express';
|
||||
import {
|
||||
formatApiErrorMessage,
|
||||
isApiErrorCode,
|
||||
type ApiErrorCode,
|
||||
type ApiErrorParams,
|
||||
} from '@thebet365/shared';
|
||||
import { serializeBigInt } from './decorators';
|
||||
import { isCodedExceptionResponse } from './app-error';
|
||||
import { resolveRequestLocale } from './resolve-request-locale';
|
||||
|
||||
@Catch()
|
||||
export class GlobalExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
const locale = resolveRequestLocale(request);
|
||||
|
||||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let message = 'Internal server error';
|
||||
let code: ApiErrorCode = 'INTERNAL_SERVER_ERROR';
|
||||
let params: ApiErrorParams | undefined;
|
||||
let message = formatApiErrorMessage('INTERNAL_SERVER_ERROR', locale);
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
const res = exception.getResponse();
|
||||
if (typeof res === 'string') {
|
||||
|
||||
if (isCodedExceptionResponse(res) && isApiErrorCode(res.code)) {
|
||||
code = res.code;
|
||||
params = res.params;
|
||||
message = formatApiErrorMessage(code, locale, params);
|
||||
} else if (typeof res === 'string') {
|
||||
message = res;
|
||||
} else if (typeof res === 'object' && res !== null) {
|
||||
const body = res as { message?: string | string[] };
|
||||
if (Array.isArray(body.message)) {
|
||||
const body = res as { message?: string | string[]; code?: string; params?: ApiErrorParams };
|
||||
if (body.code && isApiErrorCode(body.code)) {
|
||||
code = body.code;
|
||||
params = body.params;
|
||||
message = formatApiErrorMessage(code, locale, params);
|
||||
} else if (Array.isArray(body.message)) {
|
||||
message = body.message.join(';');
|
||||
} else if (typeof body.message === 'string' && body.message.trim()) {
|
||||
message = body.message;
|
||||
}
|
||||
}
|
||||
} else if (exception instanceof Error) {
|
||||
} else if (exception instanceof Error && exception.message.trim()) {
|
||||
message = exception.message;
|
||||
}
|
||||
|
||||
response.status(status).json({
|
||||
success: false,
|
||||
error: message,
|
||||
code,
|
||||
params: params ?? null,
|
||||
data: null,
|
||||
});
|
||||
}
|
||||
|
||||
22
apps/api/src/shared/common/resolve-request-locale.ts
Normal file
22
apps/api/src/shared/common/resolve-request-locale.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { Request } from 'express';
|
||||
import { normalizeLocale } from '@thebet365/shared';
|
||||
|
||||
export function resolveRequestLocale(req: Request): string {
|
||||
const xLocale = req.headers['x-locale'];
|
||||
if (typeof xLocale === 'string' && xLocale.trim()) {
|
||||
return normalizeLocale(xLocale);
|
||||
}
|
||||
|
||||
const queryLocale = req.query?.locale;
|
||||
if (typeof queryLocale === 'string' && queryLocale.trim()) {
|
||||
return normalizeLocale(queryLocale);
|
||||
}
|
||||
|
||||
const accept = req.headers['accept-language'];
|
||||
if (typeof accept === 'string' && accept.trim()) {
|
||||
const first = accept.split(',')[0]?.trim();
|
||||
if (first) return normalizeLocale(first);
|
||||
}
|
||||
|
||||
return normalizeLocale(undefined);
|
||||
}
|
||||
Reference in New Issue
Block a user