@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 +376 -376
- package/lib/cjs/config/http.js +13 -13
- package/lib/cjs/route-builder.js +2 -2
- package/lib/mjs/config/http.js +13 -13
- package/lib/mjs/route-builder.js +2 -2
- package/lib/static/style.css +15 -15
- package/lib/tsconfig.cjs.tsbuildinfo +1 -1
- package/lib/tsconfig.mjs.tsbuildinfo +1 -1
- package/lib/views/responses/badRequest.pug +12 -12
- package/lib/views/responses/conflict.pug +12 -12
- package/lib/views/responses/entityTooLarge.pug +12 -12
- package/lib/views/responses/forbidden.pug +12 -12
- package/lib/views/responses/noContent.pug +12 -12
- package/lib/views/responses/not-acceptable.pug +12 -12
- package/lib/views/responses/not-allowed.pug +12 -12
- package/lib/views/responses/notFound.pug +12 -12
- package/lib/views/responses/ok.pug +11 -11
- package/lib/views/responses/serverError.pug +12 -12
- package/lib/views/responses/unauthorized.pug +12 -12
- package/lib/views/responses/validationError.pug +15 -15
- package/package.json +99 -97
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
|