@xbg.solutions/create-backend 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,403 @@
1
+ ---
2
+ description: "API layer for the XBG boilerplate backend: implementing controllers with BaseController, registering routes, adding middleware, response shapes, and wiring up functions/src/index.ts."
3
+ ---
4
+
5
+ # XBG Boilerplate Backend — API Layer
6
+
7
+ Covers: `BaseController`, custom routes, middleware pipeline, response shapes, registering controllers in `index.ts`, and the Express app setup.
8
+
9
+ All base classes and middleware are imported from `@xbg.solutions/backend-core`.
10
+
11
+ ---
12
+
13
+ ## BaseController — HTTP Layer
14
+
15
+ **Package:** `@xbg.solutions/backend-core`
16
+
17
+ Controllers handle HTTP: extract request data, delegate to service, format response. No business logic here.
18
+
19
+ ### Implementing a Controller
20
+
21
+ ```typescript
22
+ import { Request, Response, NextFunction } from 'express';
23
+ import { BaseController, ApiResponse, requiredAuth, requireAdmin } from '@xbg.solutions/backend-core';
24
+ import { Product } from '../entities/Product';
25
+ import { ProductService } from '../services/ProductService';
26
+ import { tokenHandler } from '@xbg.solutions/utils-token-handler';
27
+
28
+ export class ProductController extends BaseController<Product> {
29
+ constructor(private productService: ProductService) {
30
+ super(productService, '/products'); // second arg = base path for this resource
31
+ }
32
+
33
+ // Override to add custom routes ON TOP of the default CRUD routes
34
+ protected registerRoutes(): void {
35
+ super.registerRoutes(); // ← registers GET /, GET /:id, POST /, PUT /:id, DELETE /:id
36
+
37
+ // Add auth middleware to writes
38
+ this.router.post('/', requiredAuth(tokenHandler), this.handleCreate.bind(this));
39
+ this.router.put('/:id', requireAdmin(tokenHandler), this.handleUpdate.bind(this));
40
+ this.router.delete('/:id', requireAdmin(tokenHandler), this.handleDelete.bind(this));
41
+
42
+ // Custom domain action
43
+ this.router.post(
44
+ '/:id/archive',
45
+ requireAdmin(tokenHandler),
46
+ this.handleArchive.bind(this)
47
+ );
48
+
49
+ // Custom query endpoint
50
+ this.router.get(
51
+ '/by-category/:categoryId',
52
+ this.handleFindByCategory.bind(this)
53
+ );
54
+ }
55
+
56
+ private async handleArchive(req: Request, res: Response, next: NextFunction): Promise<void> {
57
+ try {
58
+ const context = this.createContext(req);
59
+ const { id } = req.params;
60
+
61
+ const result = await this.productService.archiveProduct(id, context);
62
+
63
+ if (result.success && result.data) {
64
+ this.sendSuccess(res, result.data, 200, context.requestId);
65
+ } else {
66
+ this.sendError(res, result.error!, context.requestId);
67
+ }
68
+ } catch (error) {
69
+ next(error);
70
+ }
71
+ }
72
+
73
+ private async handleFindByCategory(req: Request, res: Response, next: NextFunction): Promise<void> {
74
+ try {
75
+ const context = this.createContext(req);
76
+ const { categoryId } = req.params;
77
+
78
+ const result = await this.productService.findByCategory(categoryId, context);
79
+
80
+ if (result.success && result.data) {
81
+ this.sendSuccess(res, result.data, 200, context.requestId);
82
+ } else {
83
+ this.sendError(res, result.error!, context.requestId);
84
+ }
85
+ } catch (error) {
86
+ next(error);
87
+ }
88
+ }
89
+ }
90
+ ```
91
+
92
+ ### Default Routes (from BaseController.registerRoutes)
93
+
94
+ | Method | Path | Handler |
95
+ |---|---|---|
96
+ | `POST` | `/` | `handleCreate` → `service.create(req.body, context)` |
97
+ | `GET` | `/` | `handleFindAll` → `service.findAll(options, context)` or `findPaginated` if `?page=N` |
98
+ | `GET` | `/:id` | `handleFindById` → `service.findById(id, context)` |
99
+ | `PUT` | `/:id` | `handleUpdate` → `service.update(id, req.body, context)` |
100
+ | `PATCH` | `/:id` | `handleUpdate` (same handler) |
101
+ | `DELETE` | `/:id` | `handleDelete` → `service.delete(id, context, hardDelete?)` |
102
+
103
+ ### Query String Parsing (handleFindAll)
104
+
105
+ ```
106
+ GET /api/v1/products?limit=20&offset=0&orderBy=price:asc,name:desc&where=status:==:active&page=2&pageSize=10
107
+ ```
108
+
109
+ - `limit`, `offset`: pagination
110
+ - `orderBy`: `field:direction` comma-separated
111
+ - `where`: `field:operator:value` comma-separated (multiple: repeat param)
112
+ - `page` + `pageSize`: triggers `findPaginated` instead of `findAll`
113
+ - `?hard=true`: on DELETE, triggers hard delete
114
+
115
+ ---
116
+
117
+ ## Response Shapes
118
+
119
+ All controllers inherit these helpers:
120
+
121
+ ### Success Response
122
+
123
+ ```typescript
124
+ this.sendSuccess(res, data, 200, context.requestId);
125
+ ```
126
+
127
+ ```json
128
+ {
129
+ "success": true,
130
+ "data": { "id": "...", "name": "Widget", "price": 9.99 },
131
+ "metadata": {
132
+ "requestId": "req-abc-123",
133
+ "timestamp": "2025-03-01T12:00:00.000Z",
134
+ "version": "1.0.0"
135
+ }
136
+ }
137
+ ```
138
+
139
+ ### Paginated Success Response
140
+
141
+ ```typescript
142
+ this.sendPaginatedSuccess(res, { data, total, page, pageSize, hasMore }, context.requestId);
143
+ ```
144
+
145
+ ```json
146
+ {
147
+ "success": true,
148
+ "data": [...],
149
+ "pagination": {
150
+ "page": 2,
151
+ "pageSize": 20,
152
+ "total": 147,
153
+ "hasMore": true
154
+ },
155
+ "metadata": { ... }
156
+ }
157
+ ```
158
+
159
+ ### Error Response
160
+
161
+ ```typescript
162
+ this.sendError(res, { code: 'NOT_FOUND', message: 'Product not found', details: { id } }, context.requestId);
163
+ ```
164
+
165
+ ```json
166
+ {
167
+ "success": false,
168
+ "error": {
169
+ "code": "NOT_FOUND",
170
+ "message": "Product not found",
171
+ "details": { "id": "prod-123" }
172
+ },
173
+ "metadata": { ... }
174
+ }
175
+ ```
176
+
177
+ ### Error Code → HTTP Status Mapping
178
+
179
+ | Code | HTTP |
180
+ |---|---|
181
+ | `NOT_FOUND` | 404 |
182
+ | `FORBIDDEN` | 403 |
183
+ | `UNAUTHORIZED` | 401 |
184
+ | `VALIDATION_ERROR` | 400 |
185
+ | `CONFLICT` | 409 |
186
+ | `INTERNAL_ERROR` | 500 |
187
+ | `UNKNOWN_ERROR` | 500 |
188
+
189
+ ---
190
+
191
+ ## Registering Controllers
192
+
193
+ **File:** `functions/src/index.ts` (in your generated project)
194
+
195
+ This is the Firebase Functions entry point. Add your controllers here.
196
+
197
+ ```typescript
198
+ import * as functions from 'firebase-functions';
199
+ import { createApp, getFirestoreDb } from '@xbg.solutions/backend-core';
200
+ import { logger } from '@xbg.solutions/utils-logger';
201
+
202
+ // 1. Import your generated/custom controllers
203
+ import { ProductController } from './generated/controllers/ProductController';
204
+ import { OrderController } from './orders/OrderController';
205
+
206
+ // 2. Import services and repositories
207
+ import { ProductRepository } from './generated/repositories/ProductRepository';
208
+ import { ProductService } from './generated/services/ProductService';
209
+ import { OrderRepository } from './orders/OrderRepository';
210
+ import { OrderService } from './orders/OrderService';
211
+
212
+ function initializeControllers(): Array<{ getRouter: () => any; getBasePath: () => string }> {
213
+ const db = getFirestoreDb('main');
214
+
215
+ // Wire up the dependency graph
216
+ const productRepo = new ProductRepository(db);
217
+ const productService = new ProductService(productRepo);
218
+ const productController = new ProductController(productService);
219
+
220
+ const orderRepo = new OrderRepository(db);
221
+ const orderService = new OrderService(orderRepo, productService);
222
+ const orderController = new OrderController(orderService);
223
+
224
+ return [productController, orderController];
225
+ }
226
+
227
+ const expressApp = createApp({
228
+ controllers: initializeControllers(),
229
+ });
230
+
231
+ export const api = functions.https.onRequest(expressApp);
232
+ ```
233
+
234
+ The `api` export becomes your Cloud Function. All routes are mounted under `API_BASE_PATH` (default `/api/v1`):
235
+
236
+ - `GET /api/v1/products` → `ProductController`
237
+ - `GET /api/v1/orders` → `OrderController`
238
+
239
+ ---
240
+
241
+ ## Express App & Middleware Pipeline
242
+
243
+ **Package:** `@xbg.solutions/backend-core` (`createApp`)
244
+
245
+ ### Middleware Stack (Order is Critical)
246
+
247
+ ```
248
+ 1. app.set('trust proxy', 1) ← real IP (1 hop behind Google's LB)
249
+ 2. helmet() ← security headers (all environments)
250
+ 3. createCorsMiddleware() ← CORS from CORS_ORIGINS env
251
+ 4. requestIdMiddleware() ← X-Request-ID header
252
+ 5. requestLoggingMiddleware() ← logs all requests
253
+ 6. express.json({ limit: '10mb' }) ← parse JSON bodies
254
+ 7. express.urlencoded({ extended: true }) ← parse form bodies
255
+ 8. sanitizeBody() ← strip dangerous chars
256
+ 9. createRateLimiter() ← rate limiting (non-dev only)
257
+ 10. /health, /health/ready routes
258
+ 11. apiRouter (your controllers)
259
+ 12. notFoundHandler() ← 404 for unmatched routes
260
+ 13. errorHandler() ← global error handler (MUST be last)
261
+ ```
262
+
263
+ ### Adding Middleware to Specific Route Groups
264
+
265
+ ```typescript
266
+ // In your controller, you can scope middleware to specific routes:
267
+ protected registerRoutes(): void {
268
+ super.registerRoutes();
269
+
270
+ // Add auth to all write operations in this controller
271
+ this.router.use((req, res, next) => {
272
+ if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
273
+ requiredAuth(tokenHandler)(req, res, next);
274
+ } else {
275
+ next();
276
+ }
277
+ });
278
+ }
279
+ ```
280
+
281
+ Or apply per-route as shown in the controller example above.
282
+
283
+ ### Health Endpoints (Built-In)
284
+
285
+ ```
286
+ GET /health → { success: true, data: { status: 'healthy', timestamp } }
287
+ (version and environment included in non-production only)
288
+ GET /health/ready → { success: true, data: { status: 'ready', checks: { database: 'ok' } } }
289
+ ```
290
+
291
+ Use `/health/ready` for Kubernetes liveness/readiness probes and Firebase healthcheck config.
292
+
293
+ ---
294
+
295
+ ## Validation Middleware
296
+
297
+ **Package:** `@xbg.solutions/backend-core` (middleware exports)
298
+
299
+ Add request body validation using `express-validator` in your routes:
300
+
301
+ ```typescript
302
+ import { body, validationResult } from 'express-validator';
303
+
304
+ // Define validation rules
305
+ const createProductValidation = [
306
+ body('name').isString().isLength({ min: 3, max: 100 }),
307
+ body('price').isFloat({ min: 0.01 }),
308
+ body('categoryId').isString().notEmpty(),
309
+ body('status').optional().isIn(['active', 'archived']),
310
+ ];
311
+
312
+ // In registerRoutes():
313
+ this.router.post(
314
+ '/',
315
+ requiredAuth(tokenHandler),
316
+ ...createProductValidation,
317
+ this.validateRequest.bind(this), // included in BaseController
318
+ this.handleCreate.bind(this)
319
+ );
320
+ ```
321
+
322
+ ---
323
+
324
+ ## CORS Configuration
325
+
326
+ Configured via `CORS_ORIGINS` environment variable:
327
+
328
+ ```bash
329
+ # .env
330
+ CORS_ORIGINS=http://localhost:5173,http://localhost:3000,https://myapp.com
331
+ ```
332
+
333
+ In development, all origins are allowed. In production, only listed origins.
334
+
335
+ ---
336
+
337
+ ## Handling File Uploads
338
+
339
+ Not yet built into the boilerplate. Pattern to add:
340
+
341
+ ```typescript
342
+ import multer from 'multer';
343
+ const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024 } });
344
+
345
+ // In registerRoutes():
346
+ this.router.post(
347
+ '/:id/image',
348
+ requiredAuth(tokenHandler),
349
+ upload.single('image'),
350
+ this.handleUploadImage.bind(this)
351
+ );
352
+
353
+ private async handleUploadImage(req: Request, res: Response, next: NextFunction): Promise<void> {
354
+ // req.file is the uploaded file buffer
355
+ }
356
+ ```
357
+
358
+ ---
359
+
360
+ ## Anti-Examples
361
+
362
+ ```typescript
363
+ // ❌ Don't put business logic in controllers
364
+ private async handleCreate(req: Request, res: Response): Promise<void> {
365
+ const product = new Product(req.body);
366
+ product.status = 'active'; // ← business logic — belongs in service.beforeCreate()
367
+ await productRepo.create(product); // ← bypass service — don't do this
368
+ res.json(product);
369
+ }
370
+
371
+ // ✅ Delegate everything to the service
372
+ private async handleCreate(req: Request, res: Response, next: NextFunction): Promise<void> {
373
+ try {
374
+ const context = this.createContext(req);
375
+ const result = await this.service.create(req.body, context);
376
+ if (result.success) {
377
+ this.sendSuccess(res, result.data!, 201, context.requestId);
378
+ } else {
379
+ this.sendError(res, result.error!, context.requestId);
380
+ }
381
+ } catch (error) {
382
+ next(error); // ← always pass unexpected errors to next()
383
+ }
384
+ }
385
+
386
+ // ❌ Don't forget next(error) in try/catch — unhandled errors crash the function
387
+ private async handleCreate(req: Request, res: Response): Promise<void> {
388
+ try { ... } catch (error) {
389
+ res.status(500).json({ error: 'Something went wrong' }); // ← doesn't use errorHandler
390
+ }
391
+ }
392
+
393
+ // ❌ Don't add controllers to app.use() directly — use initializeControllers() in index.ts
394
+ app.use('/products', new ProductController(service).getRouter()); // wrong place
395
+
396
+ // ❌ Don't hardcode status codes
397
+ res.status(200).json({ ... }); // use this.sendSuccess() which handles 200 vs 201
398
+
399
+ // ❌ Don't return responses without the standard shape
400
+ res.json({ product: data }); // clients expect { success, data, metadata }
401
+ // ✅
402
+ this.sendSuccess(res, data, 200, context.requestId);
403
+ ```