@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.
- package/lib/cli.d.ts +4 -4
- package/lib/cli.js +4 -4
- package/lib/commands/init.js +58 -15
- package/lib/commands/init.js.map +1 -1
- package/lib/commands/sync.js +2 -2
- package/lib/commands/sync.js.map +1 -1
- package/lib/utils-registry.d.ts +1 -1
- package/lib/utils-registry.js +24 -24
- package/lib/utils-registry.js.map +1 -1
- package/package.json +1 -1
- package/src/project-template/.claude/settings.local.json +57 -0
- package/src/project-template/.claude/skills/bpbe/api/skill.md +403 -0
- package/src/project-template/.claude/skills/bpbe/data/skill.md +442 -0
- package/src/project-template/.claude/skills/bpbe/services/skill.md +497 -0
- package/src/project-template/.claude/skills/bpbe/setup/skill.md +301 -0
- package/src/project-template/.claude/skills/bpbe/skill.md +153 -0
- package/src/project-template/.claude/skills/bpbe/utils/skill.md +527 -0
- package/src/project-template/.claude/skills/skill.md +30 -0
- package/src/project-template/__scripts__/generate.js +5 -5
- package/src/project-template/functions/src/index.ts +2 -2
|
@@ -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
|
+
```
|