Skip to content

Commit bec2703

Browse files
authored
Merge pull request #27 from Uyoxy/feat/standardized-error-formatting
feat: add standardized API error response formatting (closes #14)
2 parents 3f13cd2 + 1b5ae72 commit bec2703

8 files changed

Lines changed: 476 additions & 0 deletions

File tree

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import { HttpException, HttpStatus } from '@nestjs/common';
2+
import { ErrorCode, ErrorMessages } from './error.codes';
3+
4+
export class BaseCustomException extends HttpException {
5+
constructor(
6+
public readonly errorCode: ErrorCode,
7+
message?: string,
8+
public readonly details?: string[],
9+
statusCode?: HttpStatus,
10+
) {
11+
super(
12+
{
13+
errorCode,
14+
message: message || ErrorMessages[errorCode],
15+
details,
16+
},
17+
statusCode || HttpStatus.BAD_REQUEST,
18+
);
19+
}
20+
}
21+
22+
// Validation Exceptions
23+
export class ValidationException extends BaseCustomException {
24+
constructor(details?: string[], message?: string) {
25+
super(
26+
ErrorCode.VALIDATION_ERROR,
27+
message,
28+
details,
29+
HttpStatus.BAD_REQUEST,
30+
);
31+
}
32+
}
33+
34+
export class InvalidInputException extends BaseCustomException {
35+
constructor(details?: string[], message?: string) {
36+
super(
37+
ErrorCode.INVALID_INPUT,
38+
message,
39+
details,
40+
HttpStatus.BAD_REQUEST,
41+
);
42+
}
43+
}
44+
45+
// Authentication Exceptions
46+
export class UnauthorizedException extends BaseCustomException {
47+
constructor(message?: string, details?: string[]) {
48+
super(
49+
ErrorCode.UNAUTHORIZED,
50+
message,
51+
details,
52+
HttpStatus.UNAUTHORIZED,
53+
);
54+
}
55+
}
56+
57+
export class InvalidCredentialsException extends BaseCustomException {
58+
constructor(message?: string) {
59+
super(
60+
ErrorCode.INVALID_CREDENTIALS,
61+
message,
62+
undefined,
63+
HttpStatus.UNAUTHORIZED,
64+
);
65+
}
66+
}
67+
68+
export class TokenExpiredException extends BaseCustomException {
69+
constructor(message?: string) {
70+
super(
71+
ErrorCode.TOKEN_EXPIRED,
72+
message,
73+
undefined,
74+
HttpStatus.UNAUTHORIZED,
75+
);
76+
}
77+
}
78+
79+
// Authorization Exceptions
80+
export class ForbiddenException extends BaseCustomException {
81+
constructor(message?: string, details?: string[]) {
82+
super(
83+
ErrorCode.FORBIDDEN,
84+
message,
85+
details,
86+
HttpStatus.FORBIDDEN,
87+
);
88+
}
89+
}
90+
91+
export class InsufficientPermissionsException extends BaseCustomException {
92+
constructor(message?: string) {
93+
super(
94+
ErrorCode.INSUFFICIENT_PERMISSIONS,
95+
message,
96+
undefined,
97+
HttpStatus.FORBIDDEN,
98+
);
99+
}
100+
}
101+
102+
// Resource Exceptions
103+
export class ResourceNotFoundException extends BaseCustomException {
104+
constructor(resourceType?: string, message?: string) {
105+
const customMessage = message ||
106+
(resourceType ? `${resourceType} not found` : undefined);
107+
super(
108+
ErrorCode.RESOURCE_NOT_FOUND,
109+
customMessage,
110+
undefined,
111+
HttpStatus.NOT_FOUND,
112+
);
113+
}
114+
}
115+
116+
export class UserNotFoundException extends BaseCustomException {
117+
constructor(userId?: string) {
118+
const message = userId
119+
? `User with ID ${userId} not found`
120+
: undefined;
121+
super(
122+
ErrorCode.USER_NOT_FOUND,
123+
message,
124+
undefined,
125+
HttpStatus.NOT_FOUND,
126+
);
127+
}
128+
}
129+
130+
export class PropertyNotFoundException extends BaseCustomException {
131+
constructor(propertyId?: string) {
132+
const message = propertyId
133+
? `Property with ID ${propertyId} not found`
134+
: undefined;
135+
super(
136+
ErrorCode.PROPERTY_NOT_FOUND,
137+
message,
138+
undefined,
139+
HttpStatus.NOT_FOUND,
140+
);
141+
}
142+
}
143+
144+
// Conflict Exceptions
145+
export class ConflictException extends BaseCustomException {
146+
constructor(message?: string, details?: string[]) {
147+
super(
148+
ErrorCode.CONFLICT,
149+
message,
150+
details,
151+
HttpStatus.CONFLICT,
152+
);
153+
}
154+
}
155+
156+
export class DuplicateEntryException extends BaseCustomException {
157+
constructor(field?: string, message?: string) {
158+
const customMessage = message ||
159+
(field ? `${field} already exists` : undefined);
160+
super(
161+
ErrorCode.DUPLICATE_ENTRY,
162+
customMessage,
163+
undefined,
164+
HttpStatus.CONFLICT,
165+
);
166+
}
167+
}
168+
169+
// Server Exceptions
170+
export class InternalServerException extends BaseCustomException {
171+
constructor(message?: string, details?: string[]) {
172+
super(
173+
ErrorCode.INTERNAL_SERVER_ERROR,
174+
message,
175+
details,
176+
HttpStatus.INTERNAL_SERVER_ERROR,
177+
);
178+
}
179+
}
180+
181+
export class DatabaseException extends BaseCustomException {
182+
constructor(message?: string) {
183+
super(
184+
ErrorCode.DATABASE_ERROR,
185+
message,
186+
undefined,
187+
HttpStatus.INTERNAL_SERVER_ERROR,
188+
);
189+
}
190+
}
191+
192+
// Business Logic Exceptions
193+
export class BusinessRuleViolationException extends BaseCustomException {
194+
constructor(message?: string, details?: string[]) {
195+
super(
196+
ErrorCode.BUSINESS_RULE_VIOLATION,
197+
message,
198+
details,
199+
HttpStatus.UNPROCESSABLE_ENTITY,
200+
);
201+
}
202+
}
203+
204+
export class OperationNotAllowedException extends BaseCustomException {
205+
constructor(message?: string) {
206+
super(
207+
ErrorCode.OPERATION_NOT_ALLOWED,
208+
message,
209+
undefined,
210+
HttpStatus.FORBIDDEN,
211+
);
212+
}
213+
}

src/common/errors/error.codes.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
export enum ErrorCode {
2+
// Validation Errors (4000-4099)
3+
VALIDATION_ERROR = 'VALIDATION_ERROR',
4+
INVALID_INPUT = 'INVALID_INPUT',
5+
MISSING_REQUIRED_FIELD = 'MISSING_REQUIRED_FIELD',
6+
INVALID_FORMAT = 'INVALID_FORMAT',
7+
8+
// Authentication Errors (4100-4199)
9+
UNAUTHORIZED = 'UNAUTHORIZED',
10+
INVALID_CREDENTIALS = 'INVALID_CREDENTIALS',
11+
TOKEN_EXPIRED = 'TOKEN_EXPIRED',
12+
TOKEN_INVALID = 'TOKEN_INVALID',
13+
AUTHENTICATION_REQUIRED = 'AUTHENTICATION_REQUIRED',
14+
15+
// Authorization Errors (4300-4399)
16+
FORBIDDEN = 'FORBIDDEN',
17+
INSUFFICIENT_PERMISSIONS = 'INSUFFICIENT_PERMISSIONS',
18+
ACCESS_DENIED = 'ACCESS_DENIED',
19+
20+
// Resource Errors (4400-4499)
21+
NOT_FOUND = 'NOT_FOUND',
22+
RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND',
23+
USER_NOT_FOUND = 'USER_NOT_FOUND',
24+
PROPERTY_NOT_FOUND = 'PROPERTY_NOT_FOUND',
25+
26+
// Conflict Errors (4090-4099)
27+
CONFLICT = 'CONFLICT',
28+
DUPLICATE_ENTRY = 'DUPLICATE_ENTRY',
29+
RESOURCE_ALREADY_EXISTS = 'RESOURCE_ALREADY_EXISTS',
30+
31+
// Server Errors (5000-5099)
32+
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
33+
DATABASE_ERROR = 'DATABASE_ERROR',
34+
EXTERNAL_SERVICE_ERROR = 'EXTERNAL_SERVICE_ERROR',
35+
36+
// Business Logic Errors (5100-5199)
37+
BUSINESS_RULE_VIOLATION = 'BUSINESS_RULE_VIOLATION',
38+
OPERATION_NOT_ALLOWED = 'OPERATION_NOT_ALLOWED',
39+
INVALID_STATE = 'INVALID_STATE',
40+
}
41+
42+
export const ErrorMessages: Record<ErrorCode, string> = {
43+
// Validation
44+
[ErrorCode.VALIDATION_ERROR]: 'The provided data is invalid',
45+
[ErrorCode.INVALID_INPUT]: 'The input data contains invalid values',
46+
[ErrorCode.MISSING_REQUIRED_FIELD]: 'Required field is missing',
47+
[ErrorCode.INVALID_FORMAT]: 'The data format is incorrect',
48+
49+
// Authentication
50+
[ErrorCode.UNAUTHORIZED]: 'You are not authorized to access this resource',
51+
[ErrorCode.INVALID_CREDENTIALS]: 'The provided credentials are invalid',
52+
[ErrorCode.TOKEN_EXPIRED]: 'Your session has expired. Please login again',
53+
[ErrorCode.TOKEN_INVALID]: 'Invalid authentication token',
54+
[ErrorCode.AUTHENTICATION_REQUIRED]: 'Authentication is required to access this resource',
55+
56+
// Authorization
57+
[ErrorCode.FORBIDDEN]: 'You do not have permission to perform this action',
58+
[ErrorCode.INSUFFICIENT_PERMISSIONS]: 'You lack the necessary permissions',
59+
[ErrorCode.ACCESS_DENIED]: 'Access to this resource is denied',
60+
61+
// Resource
62+
[ErrorCode.NOT_FOUND]: 'The requested resource was not found',
63+
[ErrorCode.RESOURCE_NOT_FOUND]: 'The specified resource does not exist',
64+
[ErrorCode.USER_NOT_FOUND]: 'User not found',
65+
[ErrorCode.PROPERTY_NOT_FOUND]: 'Property not found',
66+
67+
// Conflict
68+
[ErrorCode.CONFLICT]: 'A conflict occurred while processing your request',
69+
[ErrorCode.DUPLICATE_ENTRY]: 'This entry already exists',
70+
[ErrorCode.RESOURCE_ALREADY_EXISTS]: 'A resource with this identifier already exists',
71+
72+
// Server
73+
[ErrorCode.INTERNAL_SERVER_ERROR]: 'An unexpected error occurred. Please try again later',
74+
[ErrorCode.DATABASE_ERROR]: 'A database error occurred',
75+
[ErrorCode.EXTERNAL_SERVICE_ERROR]: 'An external service is currently unavailable',
76+
77+
// Business Logic
78+
[ErrorCode.BUSINESS_RULE_VIOLATION]: 'This operation violates business rules',
79+
[ErrorCode.OPERATION_NOT_ALLOWED]: 'This operation is not allowed',
80+
[ErrorCode.INVALID_STATE]: 'The resource is in an invalid state for this operation',
81+
};

src/common/errors/error.dto.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
3+
export class ErrorResponseDto {
4+
@ApiProperty({
5+
description: 'HTTP status code',
6+
example: 400,
7+
})
8+
statusCode: number;
9+
10+
@ApiProperty({
11+
description: 'Application-specific error code',
12+
example: 'VALIDATION_ERROR',
13+
})
14+
errorCode: string;
15+
16+
@ApiProperty({
17+
description: 'User-friendly error message',
18+
example: 'The provided data is invalid',
19+
})
20+
message: string;
21+
22+
@ApiProperty({
23+
description: 'Detailed error information',
24+
example: ['email must be a valid email address'],
25+
required: false,
26+
type: [String],
27+
})
28+
details?: string[];
29+
30+
@ApiProperty({
31+
description: 'Timestamp of the error',
32+
example: '2024-01-22T10:30:00.000Z',
33+
})
34+
timestamp: string;
35+
36+
@ApiProperty({
37+
description: 'Request path where error occurred',
38+
example: '/api/v1/users',
39+
})
40+
path: string;
41+
42+
@ApiProperty({
43+
description: 'Unique request identifier for tracking',
44+
example: 'req_abc123xyz',
45+
required: false,
46+
})
47+
requestId?: string;
48+
49+
constructor(partial: Partial<ErrorResponseDto>) {
50+
Object.assign(this, partial);
51+
this.timestamp = this.timestamp || new Date().toISOString();
52+
}
53+
}

0 commit comments

Comments
 (0)