Skip to content

Commit 5f426bf

Browse files
authored
Merge pull request #166 from Zindiks/claude/add-api-error-classes-01M2J3QBN5Zn7cTe7k9g8Hsd
Create custom error classes for API
2 parents e3bf333 + 65de52e commit 5f426bf

File tree

15 files changed

+909
-0
lines changed

15 files changed

+909
-0
lines changed

api/src/shared/README.md

Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
# Shared Module
2+
3+
This module contains shared utilities, constants, and error classes used across the entire API.
4+
5+
## Directory Structure
6+
7+
```
8+
shared/
9+
├── errors/ # Custom error classes
10+
├── constants/ # Application constants
11+
├── middleware/ # Shared middleware
12+
└── README.md # This file
13+
```
14+
15+
## Error Handling
16+
17+
### Overview
18+
19+
The API uses a centralized error handling system with custom error classes that extend `ApiError`. Each error class has:
20+
21+
- **HTTP Status Code**: The appropriate HTTP status code (400, 401, 404, etc.)
22+
- **Error Code**: A unique string identifier for client-side error handling
23+
- **Message**: Human-readable error description
24+
- **Details** (optional): Additional context or validation errors
25+
26+
### Error Response Format
27+
28+
All errors return a consistent JSON format:
29+
30+
```json
31+
{
32+
"error": {
33+
"code": "ERR_RESOURCE_NOT_FOUND",
34+
"message": "Board not found",
35+
"details": {
36+
"boardId": "123"
37+
}
38+
}
39+
}
40+
```
41+
42+
### Available Error Classes
43+
44+
#### Validation Errors (400 Bad Request)
45+
46+
```typescript
47+
import { ValidationError, InvalidInputError } from '@/shared/errors';
48+
49+
// Generic validation error
50+
throw new ValidationError('Validation failed', {
51+
field: 'email',
52+
issue: 'Invalid format'
53+
});
54+
55+
// Invalid input
56+
throw new InvalidInputError('Email format is invalid');
57+
```
58+
59+
#### Authentication Errors (401 Unauthorized)
60+
61+
```typescript
62+
import {
63+
AuthenticationError,
64+
InvalidCredentialsError,
65+
TokenExpiredError,
66+
InvalidTokenError
67+
} from '@/shared/errors';
68+
69+
// Generic authentication failure
70+
throw new AuthenticationError('Authentication required');
71+
72+
// Invalid login credentials
73+
throw new InvalidCredentialsError('Username or password is incorrect');
74+
75+
// Expired token
76+
throw new TokenExpiredError('Your session has expired');
77+
78+
// Invalid token
79+
throw new InvalidTokenError('Invalid authentication token');
80+
```
81+
82+
#### Authorization Errors (403 Forbidden)
83+
84+
```typescript
85+
import {
86+
AuthorizationError,
87+
InsufficientPermissionsError
88+
} from '@/shared/errors';
89+
90+
// Generic authorization failure
91+
throw new AuthorizationError('You do not have access to this resource');
92+
93+
// Insufficient permissions
94+
throw new InsufficientPermissionsError('Admin role required');
95+
```
96+
97+
#### Not Found Errors (404 Not Found)
98+
99+
```typescript
100+
import {
101+
NotFoundError,
102+
UserNotFoundError,
103+
BoardNotFoundError,
104+
CardNotFoundError,
105+
ProfileNotFoundError
106+
} from '@/shared/errors';
107+
108+
// Generic resource not found
109+
throw new NotFoundError('Resource not found');
110+
111+
// Specific resource not found
112+
throw new UserNotFoundError(`User with ID ${userId} not found`);
113+
throw new BoardNotFoundError(`Board with ID ${boardId} not found`);
114+
throw new CardNotFoundError(`Card with ID ${cardId} not found`);
115+
throw new ProfileNotFoundError(`Profile not found`);
116+
```
117+
118+
#### Conflict Errors (409 Conflict)
119+
120+
```typescript
121+
import {
122+
ConflictError,
123+
DuplicateResourceError,
124+
UsernameAlreadyExistsError,
125+
EmailAlreadyExistsError
126+
} from '@/shared/errors';
127+
128+
// Generic conflict
129+
throw new ConflictError('Resource already exists');
130+
131+
// Duplicate resource
132+
throw new DuplicateResourceError('A board with this name already exists');
133+
134+
// Username conflict
135+
throw new UsernameAlreadyExistsError('Username is already taken');
136+
137+
// Email conflict
138+
throw new EmailAlreadyExistsError('Email is already registered');
139+
```
140+
141+
#### Server Errors (500+)
142+
143+
```typescript
144+
import {
145+
InternalServerError,
146+
DatabaseError,
147+
ServiceUnavailableError
148+
} from '@/shared/errors';
149+
150+
// Generic server error
151+
throw new InternalServerError('An unexpected error occurred');
152+
153+
// Database error
154+
throw new DatabaseError('Failed to connect to database');
155+
156+
// Service unavailable
157+
throw new ServiceUnavailableError('Service is temporarily down');
158+
```
159+
160+
### Using Error Classes in Services
161+
162+
In your service classes, throw the appropriate error:
163+
164+
```typescript
165+
// profile.service.ts
166+
import { UsernameAlreadyExistsError, EmailAlreadyExistsError } from '@/shared/errors';
167+
168+
export class ProfileService {
169+
async createProfile(data: CreateProfileDto) {
170+
// Check if username exists
171+
const existingUsername = await this.repository.findByUsername(data.username);
172+
if (existingUsername) {
173+
throw new UsernameAlreadyExistsError();
174+
}
175+
176+
// Check if email exists
177+
const existingEmail = await this.repository.findByEmail(data.email);
178+
if (existingEmail) {
179+
throw new EmailAlreadyExistsError();
180+
}
181+
182+
return await this.repository.create(data);
183+
}
184+
}
185+
```
186+
187+
### Using Error Handler Middleware
188+
189+
Register the error handler in your Fastify app:
190+
191+
```typescript
192+
// app.ts
193+
import { errorHandler } from '@/shared/middleware';
194+
195+
const app = fastify();
196+
197+
// Register error handler
198+
app.setErrorHandler(errorHandler);
199+
```
200+
201+
### Controllers No Longer Need Try-Catch
202+
203+
With the error handler middleware, controllers can be simplified:
204+
205+
**Before:**
206+
```typescript
207+
async createProfile(request: FastifyRequest, reply: FastifyReply) {
208+
try {
209+
const profile = await this.profileService.createProfile(request.body);
210+
return reply.status(201).send(profile);
211+
} catch (err: any) {
212+
if (err.message === "Username already exists") {
213+
return reply.status(409).send({ message: err.message });
214+
}
215+
return reply.status(500).send(err);
216+
}
217+
}
218+
```
219+
220+
**After:**
221+
```typescript
222+
async createProfile(request: FastifyRequest, reply: FastifyReply) {
223+
const profile = await this.profileService.createProfile(request.body);
224+
return reply.status(201).send(profile);
225+
}
226+
```
227+
228+
The error handler middleware will automatically catch and format all errors!
229+
230+
## Constants
231+
232+
### HTTP Status Codes
233+
234+
```typescript
235+
import { HttpStatus } from '@/shared/constants';
236+
237+
reply.status(HttpStatus.OK).send(data);
238+
reply.status(HttpStatus.CREATED).send(newResource);
239+
reply.status(HttpStatus.NO_CONTENT).send();
240+
```
241+
242+
### Error Codes
243+
244+
```typescript
245+
import { ErrorCodes } from '@/shared/constants';
246+
247+
// Use in custom error handling
248+
if (error.code === ErrorCodes.USERNAME_ALREADY_EXISTS) {
249+
// Handle specific error
250+
}
251+
```
252+
253+
## Creating Custom Errors
254+
255+
To create a new error class:
256+
257+
1. Create a new file in `shared/errors/`
258+
2. Extend `ApiError`
259+
3. Set appropriate status code and error code
260+
4. Export from `shared/errors/index.ts`
261+
262+
Example:
263+
264+
```typescript
265+
// shared/errors/CustomError.ts
266+
import { ApiError } from './ApiError';
267+
import { HttpStatus } from '../constants/httpStatus';
268+
269+
export class CustomError extends ApiError {
270+
constructor(message: string = 'Custom error occurred', details?: any) {
271+
super(
272+
message,
273+
HttpStatus.BAD_REQUEST,
274+
'ERR_CUSTOM_ERROR',
275+
true,
276+
details
277+
);
278+
279+
Object.setPrototypeOf(this, CustomError.prototype);
280+
}
281+
}
282+
```
283+
284+
## Best Practices
285+
286+
1. **Always use specific error classes** instead of throwing generic `Error` objects
287+
2. **Provide meaningful error messages** that help users understand what went wrong
288+
3. **Use details parameter** to include additional context (field names, validation errors, etc.)
289+
4. **Let the error handler middleware handle responses** - don't manually send error responses in controllers
290+
5. **Log errors appropriately** - the error handler logs all errors automatically
291+
6. **Use error codes in client-side error handling** instead of parsing error messages
292+
293+
## Migration Guide
294+
295+
To migrate existing code:
296+
297+
1. Replace `throw new Error("message")` with specific error classes
298+
2. Remove try-catch blocks from controllers (unless you need custom handling)
299+
3. Register the error handler middleware in `app.ts`
300+
4. Update tests to expect the new error response format
301+
302+
Example migration:
303+
304+
```diff
305+
// Service
306+
- throw new Error("Username already exists");
307+
+ throw new UsernameAlreadyExistsError();
308+
309+
// Controller
310+
async createProfile(request, reply) {
311+
- try {
312+
const profile = await this.service.createProfile(request.body);
313+
return reply.status(201).send(profile);
314+
- } catch (err: any) {
315+
- if (err.message === "Username already exists") {
316+
- return reply.status(409).send({ message: err.message });
317+
- }
318+
- return reply.status(500).send(err);
319+
- }
320+
}
321+
```
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* API Error Codes
3+
* These error codes help clients identify specific error types
4+
*/
5+
export const ErrorCodes = {
6+
// Validation Errors (4000-4099)
7+
VALIDATION_ERROR: 'ERR_VALIDATION_FAILED',
8+
INVALID_INPUT: 'ERR_INVALID_INPUT',
9+
MISSING_REQUIRED_FIELD: 'ERR_MISSING_REQUIRED_FIELD',
10+
INVALID_FORMAT: 'ERR_INVALID_FORMAT',
11+
12+
// Authentication Errors (4010-4019)
13+
AUTHENTICATION_FAILED: 'ERR_AUTHENTICATION_FAILED',
14+
INVALID_CREDENTIALS: 'ERR_INVALID_CREDENTIALS',
15+
TOKEN_EXPIRED: 'ERR_TOKEN_EXPIRED',
16+
TOKEN_INVALID: 'ERR_TOKEN_INVALID',
17+
SESSION_EXPIRED: 'ERR_SESSION_EXPIRED',
18+
19+
// Authorization Errors (4030-4039)
20+
FORBIDDEN: 'ERR_FORBIDDEN',
21+
INSUFFICIENT_PERMISSIONS: 'ERR_INSUFFICIENT_PERMISSIONS',
22+
ACCESS_DENIED: 'ERR_ACCESS_DENIED',
23+
24+
// Not Found Errors (4040-4049)
25+
RESOURCE_NOT_FOUND: 'ERR_RESOURCE_NOT_FOUND',
26+
USER_NOT_FOUND: 'ERR_USER_NOT_FOUND',
27+
BOARD_NOT_FOUND: 'ERR_BOARD_NOT_FOUND',
28+
CARD_NOT_FOUND: 'ERR_CARD_NOT_FOUND',
29+
PROFILE_NOT_FOUND: 'ERR_PROFILE_NOT_FOUND',
30+
31+
// Conflict Errors (4090-4099)
32+
RESOURCE_CONFLICT: 'ERR_RESOURCE_CONFLICT',
33+
DUPLICATE_RESOURCE: 'ERR_DUPLICATE_RESOURCE',
34+
USERNAME_ALREADY_EXISTS: 'ERR_USERNAME_ALREADY_EXISTS',
35+
EMAIL_ALREADY_EXISTS: 'ERR_EMAIL_ALREADY_EXISTS',
36+
37+
// Rate Limiting (4290-4299)
38+
TOO_MANY_REQUESTS: 'ERR_TOO_MANY_REQUESTS',
39+
RATE_LIMIT_EXCEEDED: 'ERR_RATE_LIMIT_EXCEEDED',
40+
41+
// Server Errors (5000-5099)
42+
INTERNAL_SERVER_ERROR: 'ERR_INTERNAL_SERVER_ERROR',
43+
DATABASE_ERROR: 'ERR_DATABASE_ERROR',
44+
SERVICE_UNAVAILABLE: 'ERR_SERVICE_UNAVAILABLE',
45+
EXTERNAL_API_ERROR: 'ERR_EXTERNAL_API_ERROR',
46+
} as const;
47+
48+
export type ErrorCode = typeof ErrorCodes[keyof typeof ErrorCodes];

0 commit comments

Comments
 (0)