@spinajs/http 2.0.490 → 2.0.494

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/README.md CHANGED
@@ -1,376 +1,376 @@
1
- # @spinajs/http
2
-
3
- HTTP server & controller framework for SpinaJS, built on top of Express.
4
-
5
- - Class-based controllers with decorator routing (`@Get`, `@Post`, …)
6
- - Declarative route arguments (`@Query`, `@Body`, `@Param`, `@File`, …) with validation & hydration
7
- - Policies (authorization) and route middlewares
8
- - Pluggable controller discovery (`ControllerSource`) — filesystem scan, DI registry, or your own
9
- - Typed response classes (`Ok`, `Created`, `NotFound`, …) with content negotiation (JSON / HTML / XML)
10
- - Controller metadata cache with ahead-of-time CLI build (fast cold starts in docker)
11
- - Fail-fast startup: broken controllers throw typed exceptions instead of half-starting
12
-
13
- ## Installation
14
-
15
- ```bash
16
- npm install @spinajs/http
17
- ```
18
-
19
- ## Quick start
20
-
21
- ```ts
22
- // src/controllers/UsersController.ts
23
- import { BaseController, BasePath, Get, Post, Ok, Created, NotFound, Query, Param, Body } from '@spinajs/http';
24
-
25
- @BasePath('users')
26
- export class UsersController extends BaseController {
27
- /**
28
- * GET /users?page=1
29
- */
30
- @Get('/')
31
- public async list(@Query() page?: number) {
32
- return new Ok([{ id: 1, name: 'John' }]);
33
- }
34
-
35
- /**
36
- * GET /users/42
37
- */
38
- @Get(':id')
39
- public async get(@Param() id: number) {
40
- if (id !== 42) {
41
- return new NotFound({ message: 'no such user' });
42
- }
43
- return new Ok({ id, name: 'John' });
44
- }
45
-
46
- /**
47
- * POST /users { "name": "Alice" }
48
- */
49
- @Post('/')
50
- public async create(@Body() user: UserDto) {
51
- return new Created(user);
52
- }
53
- }
54
- ```
55
-
56
- ```ts
57
- // src/index.ts — application bootstrap
58
- import { DI } from '@spinajs/di';
59
- import { Configuration } from '@spinajs/configuration';
60
- import { fsService } from '@spinajs/fs';
61
- import { Controllers, HttpServer } from '@spinajs/http';
62
-
63
- await DI.resolve(Configuration);
64
- await DI.resolve(fsService);
65
- await DI.resolve(Controllers); // discovers & mounts all controllers
66
-
67
- const server = await DI.resolve(HttpServer);
68
- server.start(); // listens on http.port ( default 1337 )
69
- ```
70
-
71
- Controllers are auto-discovered from directories configured at `system.dirs.controllers`.
72
-
73
- ## Configuration
74
-
75
- All settings live under the `http` config key ( see `src/config/http.ts` for full defaults ):
76
-
77
- ```ts
78
- // config/http.ts ( app override )
79
- import config from './config.js';
80
-
81
- export default {
82
- system: {
83
- dirs: {
84
- // where controller classes are scanned from
85
- controllers: ['/app/dist/controllers'],
86
- },
87
- },
88
- http: {
89
- port: 3000,
90
-
91
- // global prefix added to EVERY controller route, eg. api/v1 -> /api/v1/users
92
- controllers: {
93
- route: { prefix: 'api/v1' },
94
- },
95
-
96
- // raw express middlewares, executed before routing
97
- middlewares: [ /* helmet(), express.json(), ... */ ],
98
-
99
- // signed cookie secret — ALWAYS override in production
100
- cookie: {
101
- secret: 'change-me',
102
- options: { maxAge: 900000, httpOnly: true },
103
- },
104
-
105
- // static content: GET /_static/* served from Path
106
- Static: [{ Route: '/_static', Path: '/app/public' }],
107
-
108
- ssl: { key: '', cert: '' },
109
- },
110
- };
111
- ```
112
-
113
- ## Routing
114
-
115
- Route decorators: `@Get`, `@Post`, `@Put`, `@Patch`, `@Del`, `@Head` — all take optional path and schema.
116
-
117
- Path resolution rules:
118
-
119
- | Declaration | Resulting path |
120
- | --- | --- |
121
- | `@BasePath('user')` + `@Get()` on `refresh()` | `/user/refresh` ( method name fallback ) |
122
- | `@BasePath('user')` + `@Get('/')` | `/user` |
123
- | `@BasePath('user')` + `@Get('grants/:id')` | `/user/grants/:id` |
124
- | no `@BasePath` | controller class name lowercased |
125
- | config `http.controllers.route.prefix = 'api/v1'` | `/api/v1/...` prepended to all of the above |
126
-
127
- ## Route arguments
128
-
129
- Declared per-parameter with decorators; extracted, validated and hydrated before the action runs:
130
-
131
- ```ts
132
- import {
133
- Get, Post, Query, Body, Param, Header, Cookie, Form, File, CsvFile, JsonFile,
134
- FromXml, RawBody, Req, Res, Ip, RequestId, UserAgent, Referer, FromDI, PKey, Uuid,
135
- } from '@spinajs/http';
136
-
137
- class ExamplesController extends BaseController {
138
- @Get(':id')
139
- public async byId(@PKey() id: number) { /* primary key helper */ }
140
-
141
- @Get('search')
142
- public async search(@Query() q: string, @Header('x-api-key') key: string) { }
143
-
144
- @Post('upload')
145
- public async upload(@File({ maxFileSize: 1024 * 1024 }) file: IUploadedFile) { }
146
-
147
- @Post('import')
148
- public async import(@CsvFile() rows: unknown[]) { }
149
-
150
- @Post('webhook')
151
- public async webhook(@RawBody() raw: Buffer, @Header('x-signature') sig: string) {
152
- // raw = exact received bytes, for signature verification
153
- }
154
-
155
- @Get('whoami')
156
- public async whoami(@Ip() ip: string, @UserAgent() ua: string, @RequestId() rid: string) { }
157
-
158
- @Get('svc')
159
- public async svc(@FromDI() service: SomeService) { /* resolved from DI per request */ }
160
- }
161
- ```
162
-
163
- Selected argument decorators:
164
-
165
- | Decorator | Source |
166
- | --- | --- |
167
- | `@Query(schema?)` | query string parameter |
168
- | `@Body(options?)` | JSON body ( whole body or single field ) |
169
- | `@Param(schema?)` | URL parameter ( `:id` ) |
170
- | `@Header(name?)` | request header |
171
- | `@Cookie(secure?)` | cookie ( optionally signed ) |
172
- | `@Form` / `@FormField` | multipart form data |
173
- | `@File` / `@Files` | uploaded file(s), with size limits & upload middlewares |
174
- | `@CsvFile` / `@JsonFile` | uploaded file parsed to data |
175
- | `@FromXml` | XML request body, parsed |
176
- | `@RawBody` | raw request bytes ( webhook signatures ) |
177
- | `@Req` / `@Res` | express request / response |
178
- | `@Ip`, `@RequestId`, `@UserAgent`, `@Referer` | request metadata |
179
- | `@FromDI` | DI-resolved service |
180
- | `@PKey`, `@Uuid` | validated identifier helpers |
181
- | `@Model(Type)` | ORM model lookup ( with `@spinajs/orm-http` ) |
182
-
183
- Custom types passed to `@Body` / `@Query` are hydrated: class instances are constructed and (optionally) validated against JSON schema attached via `@Schema` from `@spinajs/validation`. Custom hydration via `@Hydrator(MyHydrator)` on the DTO class.
184
-
185
- ## Responses
186
-
187
- Actions return response objects ( content negotiation JSON / HTML / XML happens automatically based on `Accept` header ):
188
-
189
- ```ts
190
- import { Ok, Created, NoContent, BadRequestResponse, Unauthorized, ForbiddenResponse,
191
- NotFound, Conflict, ValidationError, ServerError, Json, Xml,
192
- FileResponse, ZipResponse, JsonFileResponse, TemplateResponse, Redirect } from '@spinajs/http';
193
-
194
- @Get('download')
195
- public async download() {
196
- return new FileResponse({ path: '/data/report.pdf', filename: 'report.pdf' });
197
- }
198
-
199
- @Get('page')
200
- public async page() {
201
- // renders pug template ( with @spinajs/templates-pug )
202
- return new TemplateResponse('page.pug', { title: 'Hello' });
203
- }
204
-
205
- @Get('legacy')
206
- public async legacy() {
207
- return new Redirect('/new-location');
208
- }
209
- ```
210
-
211
- ## Policies ( authorization )
212
-
213
- Policies gate route execution. When several policies are attached, **one success is enough** — this allows alternative access paths ( e.g. session cookie OR api token ):
214
-
215
- ```ts
216
- import { BasePolicy, Policy, IRoute, IController } from '@spinajs/http';
217
-
218
- export class ApiKeyPolicy extends BasePolicy {
219
- public isEnabled(_route: IRoute, _controller: IController): boolean {
220
- return true;
221
- }
222
-
223
- public async execute(req: express.Request): Promise<void> {
224
- if (req.headers['x-api-key'] !== process.env.API_KEY) {
225
- throw new Forbidden('invalid api key');
226
- }
227
- // resolving = access granted
228
- }
229
- }
230
-
231
- @BasePath('admin')
232
- @Policy(ApiKeyPolicy) // controller-wide
233
- export class AdminController extends BaseController {
234
- @Get()
235
- @Policy(SessionPolicy) // route-level, OR-ed with ApiKeyPolicy
236
- public async dashboard() { ... }
237
- }
238
- ```
239
-
240
- Policies can also be referenced **by configuration key** — the key must resolve to a registered policy type name:
241
-
242
- ```ts
243
- @Policy('rbac.session.policy') // read from configuration at startup
244
- ```
245
-
246
- A config key that does not resolve to a registered `BasePolicy` throws `RouteRegistrationException` at startup — a silently dropped policy would leave the route unprotected.
247
-
248
- ## Route middlewares
249
-
250
- Run before / after actions and can inspect the produced response:
251
-
252
- ```ts
253
- import { RouteMiddleware, Middleware } from '@spinajs/http';
254
-
255
- export class AuditMiddleware extends RouteMiddleware {
256
- public isEnabled(route: IRoute, controller: IController): boolean { return true; }
257
- public async onBefore(req, res, route, controller): Promise<void> { /* before action */ }
258
- public async onResponse(response, route, controller): Promise<void> { /* inspect response object */ }
259
- public async onAfter(req, res, route, controller): Promise<void> { /* after action */ }
260
- }
261
-
262
- @Middleware(AuditMiddleware) // controller-wide or per-route
263
- export class OrdersController extends BaseController { ... }
264
- ```
265
-
266
- Server-level middlewares ( whole express stack, not per-route ) ship in `src/middlewares/`: `AccessLog`, `Cors`, `Compression`, `RequestId` ( w3c traceparent + `x-request-id` ), `ResponseTime`, `RealIp`, `ServerTiming`, `PerfRollup`, `SlowRequestWarning`, `NotFound`, `ErrorHandler`.
267
-
268
- ## Controller discovery ( ControllerSource )
269
-
270
- Discovery is pluggable. Built-in sources:
271
-
272
- - `FilesystemControllerSource` — scans `system.dirs.controllers` directories
273
- - `DiRegistryControllerSource` — picks up types registered in DI **before** `Controllers` resolves:
274
-
275
- ```ts
276
- // package bootstrapper — conditional controller registration
277
- @Injectable(Bootstrapper)
278
- export class MyPackageBootstrapper extends Bootstrapper {
279
- public bootstrap(): void {
280
- if (someFeatureFlag) {
281
- DI.register(MyFeatureController).as(BaseController);
282
- }
283
- }
284
- }
285
- ```
286
-
287
- Custom source — implement and register, the loader picks it up automatically:
288
-
289
- ```ts
290
- import { ControllerSource, BaseController } from '@spinajs/http';
291
- import { ClassInfo, Injectable } from '@spinajs/di';
292
-
293
- @Injectable(ControllerSource)
294
- export class PluginManifestSource extends ControllerSource {
295
- public async getControllers(): Promise<Array<ClassInfo<BaseController>>> {
296
- // read your plugin manifest, return ClassInfo entries ( name, type, file )
297
- return [];
298
- }
299
- }
300
- ```
301
-
302
- ### Overriding a package controller
303
-
304
- Register the subclass as an override — only the subclass mounts:
305
-
306
- ```ts
307
- DI.register(MyUserController).as(PackageUserController);
308
- ```
309
-
310
- Subclassing a scanned controller **without** registering the override mounts BOTH and logs a warning ( express route order decides which answers ).
311
-
312
- ### Dynamic registration at runtime
313
-
314
- ```ts
315
- const controllers = await DI.resolve(Controllers);
316
- await controllers.add(LateBoundController); // idempotent, mounts immediately
317
- ```
318
-
319
- ## Startup error handling
320
-
321
- Registration is fail-fast — the app refuses to start instead of silently skipping broken pieces:
322
-
323
- | Condition | Exception |
324
- | --- | --- |
325
- | controller instance could not be resolved | `ControllerRegistrationException` |
326
- | controller has descriptor but no router ( `super.resolve()` not called ) | `ControllerRegistrationException` |
327
- | route declared for a member that does not exist | `RouteRegistrationException` |
328
- | unknown route type ( broken decorator ) | `RouteRegistrationException` |
329
- | string policy config key not resolvable | `RouteRegistrationException` |
330
-
331
- Routes inherited from a base class declared in another file are fine — parameter names fall back to runtime extraction.
332
-
333
- ## Controller cache & CLI
334
-
335
- Route parameter names and JSDoc documentation ( used by `@spinajs/http-swagger` ) are extracted from `.d.ts` files with the TypeScript compiler and cached under `__cache__/__controllers__` ( configurable via the `__fs_controller_cache__` fs provider ). Entries are keyed by source file content hash — changed files regenerate automatically.
336
-
337
- First app start pays the parsing cost. To avoid that ( e.g. docker images ), pre-build the cache at image build time:
338
-
339
- ```bash
340
- # generate missing cache entries
341
- spinajs http:controllers:cache
342
-
343
- # force regeneration even if entries exist
344
- spinajs http:controllers:cache --rebuild
345
- ```
346
-
347
- ```dockerfile
348
- FROM node:22 AS build
349
- WORKDIR /app
350
- COPY . .
351
- RUN npm ci && npm run build
352
- # pre-build controllers cache AFTER tsc — cache keys are content hashes of compiled .d.ts
353
- RUN node node_modules/.bin/spinajs http:controllers:cache --rebuild
354
-
355
- FROM node:22-slim
356
- WORKDIR /app
357
- COPY --from=build /app .
358
- CMD ["node", "dist/index.js"]
359
- ```
360
-
361
- The command exits non-zero when any controller fails to parse, failing the image build loudly.
362
-
363
- ## Exceptions
364
-
365
- | Exception | Purpose |
366
- | --- | --- |
367
- | `ControllerRegistrationException` | controller-level startup failure |
368
- | `RouteRegistrationException` | route-level startup failure |
369
- | `EntityTooLargeException` | uploaded file exceeds limits ( maps to HTTP 413 ) |
370
-
371
- ## Related packages
372
-
373
- - `@spinajs/http-swagger` — OpenAPI document generation from controller JSDoc
374
- - `@spinajs/orm-http` — `@Model()` route arg, ORM-aware responses
375
- - `@spinajs/rbac-http` — session / role policies
376
- - `@spinajs/templates-pug` — HTML rendering for `TemplateResponse` and error pages
1
+ # @spinajs/http
2
+
3
+ HTTP server & controller framework for SpinaJS, built on top of Express.
4
+
5
+ - Class-based controllers with decorator routing (`@Get`, `@Post`, …)
6
+ - Declarative route arguments (`@Query`, `@Body`, `@Param`, `@File`, …) with validation & hydration
7
+ - Policies (authorization) and route middlewares
8
+ - Pluggable controller discovery (`ControllerSource`) — filesystem scan, DI registry, or your own
9
+ - Typed response classes (`Ok`, `Created`, `NotFound`, …) with content negotiation (JSON / HTML / XML)
10
+ - Controller metadata cache with ahead-of-time CLI build (fast cold starts in docker)
11
+ - Fail-fast startup: broken controllers throw typed exceptions instead of half-starting
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ npm install @spinajs/http
17
+ ```
18
+
19
+ ## Quick start
20
+
21
+ ```ts
22
+ // src/controllers/UsersController.ts
23
+ import { BaseController, BasePath, Get, Post, Ok, Created, NotFound, Query, Param, Body } from '@spinajs/http';
24
+
25
+ @BasePath('users')
26
+ export class UsersController extends BaseController {
27
+ /**
28
+ * GET /users?page=1
29
+ */
30
+ @Get('/')
31
+ public async list(@Query() page?: number) {
32
+ return new Ok([{ id: 1, name: 'John' }]);
33
+ }
34
+
35
+ /**
36
+ * GET /users/42
37
+ */
38
+ @Get(':id')
39
+ public async get(@Param() id: number) {
40
+ if (id !== 42) {
41
+ return new NotFound({ message: 'no such user' });
42
+ }
43
+ return new Ok({ id, name: 'John' });
44
+ }
45
+
46
+ /**
47
+ * POST /users { "name": "Alice" }
48
+ */
49
+ @Post('/')
50
+ public async create(@Body() user: UserDto) {
51
+ return new Created(user);
52
+ }
53
+ }
54
+ ```
55
+
56
+ ```ts
57
+ // src/index.ts — application bootstrap
58
+ import { DI } from '@spinajs/di';
59
+ import { Configuration } from '@spinajs/configuration';
60
+ import { fsService } from '@spinajs/fs';
61
+ import { Controllers, HttpServer } from '@spinajs/http';
62
+
63
+ await DI.resolve(Configuration);
64
+ await DI.resolve(fsService);
65
+ await DI.resolve(Controllers); // discovers & mounts all controllers
66
+
67
+ const server = await DI.resolve(HttpServer);
68
+ server.start(); // listens on http.port ( default 1337 )
69
+ ```
70
+
71
+ Controllers are auto-discovered from directories configured at `system.dirs.controllers`.
72
+
73
+ ## Configuration
74
+
75
+ All settings live under the `http` config key ( see `src/config/http.ts` for full defaults ):
76
+
77
+ ```ts
78
+ // config/http.ts ( app override )
79
+ import config from './config.js';
80
+
81
+ export default {
82
+ system: {
83
+ dirs: {
84
+ // where controller classes are scanned from
85
+ controllers: ['/app/dist/controllers'],
86
+ },
87
+ },
88
+ http: {
89
+ port: 3000,
90
+
91
+ // global prefix added to EVERY controller route, eg. api/v1 -> /api/v1/users
92
+ controllers: {
93
+ route: { prefix: 'api/v1' },
94
+ },
95
+
96
+ // raw express middlewares, executed before routing
97
+ middlewares: [ /* helmet(), express.json(), ... */ ],
98
+
99
+ // signed cookie secret — ALWAYS override in production
100
+ cookie: {
101
+ secret: 'change-me',
102
+ options: { maxAge: 900000, httpOnly: true },
103
+ },
104
+
105
+ // static content: GET /_static/* served from Path
106
+ Static: [{ Route: '/_static', Path: '/app/public' }],
107
+
108
+ ssl: { key: '', cert: '' },
109
+ },
110
+ };
111
+ ```
112
+
113
+ ## Routing
114
+
115
+ Route decorators: `@Get`, `@Post`, `@Put`, `@Patch`, `@Del`, `@Head` — all take optional path and schema.
116
+
117
+ Path resolution rules:
118
+
119
+ | Declaration | Resulting path |
120
+ | --- | --- |
121
+ | `@BasePath('user')` + `@Get()` on `refresh()` | `/user/refresh` ( method name fallback ) |
122
+ | `@BasePath('user')` + `@Get('/')` | `/user` |
123
+ | `@BasePath('user')` + `@Get('grants/:id')` | `/user/grants/:id` |
124
+ | no `@BasePath` | controller class name lowercased |
125
+ | config `http.controllers.route.prefix = 'api/v1'` | `/api/v1/...` prepended to all of the above |
126
+
127
+ ## Route arguments
128
+
129
+ Declared per-parameter with decorators; extracted, validated and hydrated before the action runs:
130
+
131
+ ```ts
132
+ import {
133
+ Get, Post, Query, Body, Param, Header, Cookie, Form, File, CsvFile, JsonFile,
134
+ FromXml, RawBody, Req, Res, Ip, RequestId, UserAgent, Referer, FromDI, PKey, Uuid,
135
+ } from '@spinajs/http';
136
+
137
+ class ExamplesController extends BaseController {
138
+ @Get(':id')
139
+ public async byId(@PKey() id: number) { /* primary key helper */ }
140
+
141
+ @Get('search')
142
+ public async search(@Query() q: string, @Header('x-api-key') key: string) { }
143
+
144
+ @Post('upload')
145
+ public async upload(@File({ maxFileSize: 1024 * 1024 }) file: IUploadedFile) { }
146
+
147
+ @Post('import')
148
+ public async import(@CsvFile() rows: unknown[]) { }
149
+
150
+ @Post('webhook')
151
+ public async webhook(@RawBody() raw: Buffer, @Header('x-signature') sig: string) {
152
+ // raw = exact received bytes, for signature verification
153
+ }
154
+
155
+ @Get('whoami')
156
+ public async whoami(@Ip() ip: string, @UserAgent() ua: string, @RequestId() rid: string) { }
157
+
158
+ @Get('svc')
159
+ public async svc(@FromDI() service: SomeService) { /* resolved from DI per request */ }
160
+ }
161
+ ```
162
+
163
+ Selected argument decorators:
164
+
165
+ | Decorator | Source |
166
+ | --- | --- |
167
+ | `@Query(schema?)` | query string parameter |
168
+ | `@Body(options?)` | JSON body ( whole body or single field ) |
169
+ | `@Param(schema?)` | URL parameter ( `:id` ) |
170
+ | `@Header(name?)` | request header |
171
+ | `@Cookie(secure?)` | cookie ( optionally signed ) |
172
+ | `@Form` / `@FormField` | multipart form data |
173
+ | `@File` / `@Files` | uploaded file(s), with size limits & upload middlewares |
174
+ | `@CsvFile` / `@JsonFile` | uploaded file parsed to data |
175
+ | `@FromXml` | XML request body, parsed |
176
+ | `@RawBody` | raw request bytes ( webhook signatures ) |
177
+ | `@Req` / `@Res` | express request / response |
178
+ | `@Ip`, `@RequestId`, `@UserAgent`, `@Referer` | request metadata |
179
+ | `@FromDI` | DI-resolved service |
180
+ | `@PKey`, `@Uuid` | validated identifier helpers |
181
+ | `@Model(Type)` | ORM model lookup ( with `@spinajs/orm-http` ) |
182
+
183
+ Custom types passed to `@Body` / `@Query` are hydrated: class instances are constructed and (optionally) validated against JSON schema attached via `@Schema` from `@spinajs/validation`. Custom hydration via `@Hydrator(MyHydrator)` on the DTO class.
184
+
185
+ ## Responses
186
+
187
+ Actions return response objects ( content negotiation JSON / HTML / XML happens automatically based on `Accept` header ):
188
+
189
+ ```ts
190
+ import { Ok, Created, NoContent, BadRequestResponse, Unauthorized, ForbiddenResponse,
191
+ NotFound, Conflict, ValidationError, ServerError, Json, Xml,
192
+ FileResponse, ZipResponse, JsonFileResponse, TemplateResponse, Redirect } from '@spinajs/http';
193
+
194
+ @Get('download')
195
+ public async download() {
196
+ return new FileResponse({ path: '/data/report.pdf', filename: 'report.pdf' });
197
+ }
198
+
199
+ @Get('page')
200
+ public async page() {
201
+ // renders pug template ( with @spinajs/templates-pug )
202
+ return new TemplateResponse('page.pug', { title: 'Hello' });
203
+ }
204
+
205
+ @Get('legacy')
206
+ public async legacy() {
207
+ return new Redirect('/new-location');
208
+ }
209
+ ```
210
+
211
+ ## Policies ( authorization )
212
+
213
+ Policies gate route execution. When several policies are attached, **one success is enough** — this allows alternative access paths ( e.g. session cookie OR api token ):
214
+
215
+ ```ts
216
+ import { BasePolicy, Policy, IRoute, IController } from '@spinajs/http';
217
+
218
+ export class ApiKeyPolicy extends BasePolicy {
219
+ public isEnabled(_route: IRoute, _controller: IController): boolean {
220
+ return true;
221
+ }
222
+
223
+ public async execute(req: express.Request): Promise<void> {
224
+ if (req.headers['x-api-key'] !== process.env.API_KEY) {
225
+ throw new Forbidden('invalid api key');
226
+ }
227
+ // resolving = access granted
228
+ }
229
+ }
230
+
231
+ @BasePath('admin')
232
+ @Policy(ApiKeyPolicy) // controller-wide
233
+ export class AdminController extends BaseController {
234
+ @Get()
235
+ @Policy(SessionPolicy) // route-level, OR-ed with ApiKeyPolicy
236
+ public async dashboard() { ... }
237
+ }
238
+ ```
239
+
240
+ Policies can also be referenced **by configuration key** — the key must resolve to a registered policy type name:
241
+
242
+ ```ts
243
+ @Policy('rbac.session.policy') // read from configuration at startup
244
+ ```
245
+
246
+ A config key that does not resolve to a registered `BasePolicy` throws `RouteRegistrationException` at startup — a silently dropped policy would leave the route unprotected.
247
+
248
+ ## Route middlewares
249
+
250
+ Run before / after actions and can inspect the produced response:
251
+
252
+ ```ts
253
+ import { RouteMiddleware, Middleware } from '@spinajs/http';
254
+
255
+ export class AuditMiddleware extends RouteMiddleware {
256
+ public isEnabled(route: IRoute, controller: IController): boolean { return true; }
257
+ public async onBefore(req, res, route, controller): Promise<void> { /* before action */ }
258
+ public async onResponse(response, route, controller): Promise<void> { /* inspect response object */ }
259
+ public async onAfter(req, res, route, controller): Promise<void> { /* after action */ }
260
+ }
261
+
262
+ @Middleware(AuditMiddleware) // controller-wide or per-route
263
+ export class OrdersController extends BaseController { ... }
264
+ ```
265
+
266
+ Server-level middlewares ( whole express stack, not per-route ) ship in `src/middlewares/`: `AccessLog`, `Cors`, `Compression`, `RequestId` ( w3c traceparent + `x-request-id` ), `ResponseTime`, `RealIp`, `ServerTiming`, `PerfRollup`, `SlowRequestWarning`, `NotFound`, `ErrorHandler`.
267
+
268
+ ## Controller discovery ( ControllerSource )
269
+
270
+ Discovery is pluggable. Built-in sources:
271
+
272
+ - `FilesystemControllerSource` — scans `system.dirs.controllers` directories
273
+ - `DiRegistryControllerSource` — picks up types registered in DI **before** `Controllers` resolves:
274
+
275
+ ```ts
276
+ // package bootstrapper — conditional controller registration
277
+ @Injectable(Bootstrapper)
278
+ export class MyPackageBootstrapper extends Bootstrapper {
279
+ public bootstrap(): void {
280
+ if (someFeatureFlag) {
281
+ DI.register(MyFeatureController).as(BaseController);
282
+ }
283
+ }
284
+ }
285
+ ```
286
+
287
+ Custom source — implement and register, the loader picks it up automatically:
288
+
289
+ ```ts
290
+ import { ControllerSource, BaseController } from '@spinajs/http';
291
+ import { ClassInfo, Injectable } from '@spinajs/di';
292
+
293
+ @Injectable(ControllerSource)
294
+ export class PluginManifestSource extends ControllerSource {
295
+ public async getControllers(): Promise<Array<ClassInfo<BaseController>>> {
296
+ // read your plugin manifest, return ClassInfo entries ( name, type, file )
297
+ return [];
298
+ }
299
+ }
300
+ ```
301
+
302
+ ### Overriding a package controller
303
+
304
+ Register the subclass as an override — only the subclass mounts:
305
+
306
+ ```ts
307
+ DI.register(MyUserController).as(PackageUserController);
308
+ ```
309
+
310
+ Subclassing a scanned controller **without** registering the override mounts BOTH and logs a warning ( express route order decides which answers ).
311
+
312
+ ### Dynamic registration at runtime
313
+
314
+ ```ts
315
+ const controllers = await DI.resolve(Controllers);
316
+ await controllers.add(LateBoundController); // idempotent, mounts immediately
317
+ ```
318
+
319
+ ## Startup error handling
320
+
321
+ Registration is fail-fast — the app refuses to start instead of silently skipping broken pieces:
322
+
323
+ | Condition | Exception |
324
+ | --- | --- |
325
+ | controller instance could not be resolved | `ControllerRegistrationException` |
326
+ | controller has descriptor but no router ( `super.resolve()` not called ) | `ControllerRegistrationException` |
327
+ | route declared for a member that does not exist | `RouteRegistrationException` |
328
+ | unknown route type ( broken decorator ) | `RouteRegistrationException` |
329
+ | string policy config key not resolvable | `RouteRegistrationException` |
330
+
331
+ Routes inherited from a base class declared in another file are fine — parameter names fall back to runtime extraction.
332
+
333
+ ## Controller cache & CLI
334
+
335
+ Route parameter names and JSDoc documentation ( used by `@spinajs/http-swagger` ) are extracted from `.d.ts` files with the TypeScript compiler and cached under `__cache__/__controllers__` ( configurable via the `__fs_controller_cache__` fs provider ). Entries are keyed by source file content hash — changed files regenerate automatically.
336
+
337
+ First app start pays the parsing cost. To avoid that ( e.g. docker images ), pre-build the cache at image build time:
338
+
339
+ ```bash
340
+ # generate missing cache entries
341
+ spinajs http:controllers:cache
342
+
343
+ # force regeneration even if entries exist
344
+ spinajs http:controllers:cache --rebuild
345
+ ```
346
+
347
+ ```dockerfile
348
+ FROM node:22 AS build
349
+ WORKDIR /app
350
+ COPY . .
351
+ RUN npm ci && npm run build
352
+ # pre-build controllers cache AFTER tsc — cache keys are content hashes of compiled .d.ts
353
+ RUN node node_modules/.bin/spinajs http:controllers:cache --rebuild
354
+
355
+ FROM node:22-slim
356
+ WORKDIR /app
357
+ COPY --from=build /app .
358
+ CMD ["node", "dist/index.js"]
359
+ ```
360
+
361
+ The command exits non-zero when any controller fails to parse, failing the image build loudly.
362
+
363
+ ## Exceptions
364
+
365
+ | Exception | Purpose |
366
+ | --- | --- |
367
+ | `ControllerRegistrationException` | controller-level startup failure |
368
+ | `RouteRegistrationException` | route-level startup failure |
369
+ | `EntityTooLargeException` | uploaded file exceeds limits ( maps to HTTP 413 ) |
370
+
371
+ ## Related packages
372
+
373
+ - `@spinajs/http-swagger` — OpenAPI document generation from controller JSDoc
374
+ - `@spinajs/orm-http` — `@Model()` route arg, ORM-aware responses
375
+ - `@spinajs/rbac-http` — session / role policies
376
+ - `@spinajs/templates-pug` — HTML rendering for `TemplateResponse` and error pages