expediate 1.0.5 → 1.0.6
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/CHANGELOG.md +138 -0
- package/CONTRIBUTING.md +150 -0
- package/README.md +278 -779
- package/dist/apis.d.ts +372 -12
- package/dist/apis.d.ts.map +1 -1
- package/dist/apis.js +483 -65
- package/dist/apis.js.map +1 -1
- package/dist/cjs/index.js +2290 -807
- package/dist/git.d.ts +1 -1
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +5 -5
- package/dist/git.js.map +1 -1
- package/dist/http-objects.d.ts +26 -0
- package/dist/http-objects.d.ts.map +1 -0
- package/dist/http-objects.js +588 -0
- package/dist/http-objects.js.map +1 -0
- package/dist/index.d.ts +6 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/jwt-auth.d.ts +11 -0
- package/dist/jwt-auth.d.ts.map +1 -1
- package/dist/jwt-auth.js +9 -9
- package/dist/jwt-auth.js.map +1 -1
- package/dist/middleware.js +2 -2
- package/dist/middleware.js.map +1 -1
- package/dist/mimetypes.json +882 -1
- package/dist/misc.d.ts +161 -25
- package/dist/misc.d.ts.map +1 -1
- package/dist/misc.js +228 -80
- package/dist/misc.js.map +1 -1
- package/dist/openapi.d.ts +156 -13
- package/dist/openapi.d.ts.map +1 -1
- package/dist/openapi.js +214 -71
- package/dist/openapi.js.map +1 -1
- package/dist/router-types.d.ts +760 -0
- package/dist/router-types.d.ts.map +1 -0
- package/dist/router-types.js +23 -0
- package/dist/router-types.js.map +1 -0
- package/dist/router.d.ts +7 -530
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +128 -375
- package/dist/router.js.map +1 -1
- package/dist/static.d.ts +2 -2
- package/dist/static.d.ts.map +1 -1
- package/dist/static.js +77 -22
- package/dist/static.js.map +1 -1
- package/docs/THREAT_MODEL.md +52 -0
- package/docs/api-builder-v2-design.md +644 -0
- package/docs/api-builder-v3-design.md +397 -0
- package/docs/api-builder.md +454 -0
- package/docs/benchmark.md +27 -0
- package/docs/body-parsing.md +223 -0
- package/docs/errors.md +359 -0
- package/docs/expediate.png +0 -0
- package/docs/git.md +139 -0
- package/docs/jwt-auth.md +251 -0
- package/docs/logo.svg +12 -0
- package/docs/middleware.md +264 -0
- package/docs/openapi.md +180 -0
- package/docs/router.md +356 -0
- package/docs/static.md +128 -0
- package/docs/wiki.json +123 -0
- package/package.json +30 -8
- package/dist/cjs/apis.js +0 -327
- package/dist/cjs/git.js +0 -293
- package/dist/cjs/jwt-auth.js +0 -532
- package/dist/cjs/middleware.js +0 -511
- package/dist/cjs/mimetypes.json +0 -1
- package/dist/cjs/misc.js +0 -787
- package/dist/cjs/openapi.js +0 -485
- package/dist/cjs/router.js +0 -898
- package/dist/cjs/static.js +0 -669
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
# API Builder and OpenAPI
|
|
2
|
+
|
|
3
|
+
`apiBuilder` lets you define REST endpoints as a controller-style service object. It handles instance scoping, lifecycle, method binding, route specificity sorting, and error translation automatically. Since v2 it also supports **multi-file composition** (controllers), **guards**, a declarative **auth binding**, and **runtime request validation** from the JSON Schemas you already declare for the spec. The companion `describe()` helper annotates handlers with OpenAPI metadata so a full spec can be generated without duplication.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## `apiBuilder(service)`
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { createRouter, json, apiBuilder } from 'expediate';
|
|
11
|
+
import type { ServiceDefinition, ApiContext } from 'expediate';
|
|
12
|
+
|
|
13
|
+
interface State {
|
|
14
|
+
items: Record<string, { title: string; done: boolean }>;
|
|
15
|
+
nextId: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const todoService: ServiceDefinition<State> = {
|
|
19
|
+
data: () => ({ items: {}, nextId: 1 }),
|
|
20
|
+
|
|
21
|
+
methods: {
|
|
22
|
+
findOrThrow(this: State, id: string) {
|
|
23
|
+
const item = this.items[id];
|
|
24
|
+
if (!item) throw { status: 404, message: 'Not found' };
|
|
25
|
+
return item;
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
GET: {
|
|
30
|
+
'/todos': function (this: State) {
|
|
31
|
+
return Object.entries(this.items).map(([id, v]) => ({ id, ...v }));
|
|
32
|
+
},
|
|
33
|
+
'/todos/:id': function (this: State, ctx: ApiContext) {
|
|
34
|
+
return this.findOrThrow(ctx.params.id);
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
POST: {
|
|
39
|
+
'/todos': function (this: State, _ctx: ApiContext, body: any) {
|
|
40
|
+
const id = String(this.nextId++);
|
|
41
|
+
this.items[id] = { title: body.title, done: false };
|
|
42
|
+
return { id, ...this.items[id] };
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
DELETE: {
|
|
47
|
+
'/todos/:id': function (this: State, ctx: ApiContext) {
|
|
48
|
+
this.findOrThrow(ctx.params.id);
|
|
49
|
+
delete this.items[ctx.params.id];
|
|
50
|
+
}, // no return → 201 No Content
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const app = createRouter();
|
|
55
|
+
app.use('/', json());
|
|
56
|
+
app.use('/api', apiBuilder(todoService));
|
|
57
|
+
app.listen(3000);
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Handler signature and `ApiContext`
|
|
61
|
+
|
|
62
|
+
Handlers are called with `this` bound to the service instance and receive `(ctx, body)`:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
interface ApiContext<TUser = unknown, TState = Record<string, unknown>> {
|
|
66
|
+
query: {
|
|
67
|
+
route: Record<string, string>; // named :params from the path
|
|
68
|
+
url: Record<string, string | string[]>; // ?query=string parameters
|
|
69
|
+
};
|
|
70
|
+
params: Record<string, string>; // shorthand alias for query.route
|
|
71
|
+
path: string; // request path (after parent use() stripping)
|
|
72
|
+
user?: TUser; // set by an authenticate middleware
|
|
73
|
+
state: TState; // values produced by guards
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`ctx.params` is the dominant access pattern; the namespaced `ctx.query.route` / `ctx.query.url` forms remain for collision cases. Type `user` and `state` explicitly when you need strict access:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import type { TokenPayload } from 'expediate';
|
|
81
|
+
|
|
82
|
+
'/me': (ctx: ApiContext<TokenPayload>) => ({ name: ctx.user?.sub }),
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Handler conventions
|
|
86
|
+
|
|
87
|
+
| Return value | HTTP response |
|
|
88
|
+
|---|---|
|
|
89
|
+
| Truthy value (`object`, `string`, `number`, `true`) | `200 OK` with JSON body |
|
|
90
|
+
| `undefined`, `null`, `false`, `0`, `''` | `201 No Content` |
|
|
91
|
+
| Throw `{ status, message }` | `<status>` with plain-text body |
|
|
92
|
+
| Throw `{ status, data }` | `<status>` with JSON body |
|
|
93
|
+
| Throw anything else | `500 Internal Server Error` |
|
|
94
|
+
|
|
95
|
+
### Route specificity sorting
|
|
96
|
+
|
|
97
|
+
All routes — root-level and controller-level together — are sorted **globally** before registration so that more-specific paths are registered first and cannot be shadowed:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
score = (segment_count × 100) − (param_count × 10)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`/items/special` (score 200) is registered before `/items/:id` (score 190), so you do not need to care about declaration order, even across controllers.
|
|
104
|
+
|
|
105
|
+
### ServiceDefinition structure
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
interface ServiceDefinition<TInstance> {
|
|
109
|
+
// Instance lifecycle (API-wide, shared by all controllers)
|
|
110
|
+
scope?: (req: RouterRequest) => string | null;
|
|
111
|
+
data?: (key: string | null) => Partial<TInstance>;
|
|
112
|
+
setup?: (this: TInstance) => void | Promise<void>;
|
|
113
|
+
methods?: ServiceMethods<TInstance>;
|
|
114
|
+
|
|
115
|
+
// v2 — composition, guards, auth, validation
|
|
116
|
+
controllers?: ControllerDefinition<TInstance>[];
|
|
117
|
+
guards?: Guard[];
|
|
118
|
+
auth?: AuthBinding;
|
|
119
|
+
validate?: boolean | ApiBuilderOptions;
|
|
120
|
+
schemas?: Record<string, JsonSchema>;
|
|
121
|
+
|
|
122
|
+
// Root route maps (form an implicit controller with no prefix)
|
|
123
|
+
GET?: RouteMap<TInstance>;
|
|
124
|
+
POST?: RouteMap<TInstance>;
|
|
125
|
+
PUT?: RouteMap<TInstance>;
|
|
126
|
+
DELETE?: RouteMap<TInstance>;
|
|
127
|
+
PATCH?: RouteMap<TInstance>;
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Scoping
|
|
134
|
+
|
|
135
|
+
Control how many instances of the service state are created:
|
|
136
|
+
|
|
137
|
+
| `scope` field value | Behaviour |
|
|
138
|
+
|---|---|
|
|
139
|
+
| Absent (no `scope`) | **Singleton** — one global instance for all requests |
|
|
140
|
+
| Returns a `string` | **Keyed** — one instance per key, cached indefinitely |
|
|
141
|
+
| Returns `null` | **Ephemeral** — fresh instance per request, discarded afterwards |
|
|
142
|
+
|
|
143
|
+
The key is stored at `this.$key` on the instance.
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
const service: ServiceDefinition<State> = {
|
|
147
|
+
// Per-session scope
|
|
148
|
+
scope: (req) => (req as any).session?.id ?? null,
|
|
149
|
+
|
|
150
|
+
// Per-request (ephemeral)
|
|
151
|
+
scope: () => null,
|
|
152
|
+
|
|
153
|
+
data: () => ({ /* initial state */ }),
|
|
154
|
+
};
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The instance lifecycle stays at the top level: **one lifecycle for the whole API, shared by all controllers**. Controllers organise routes; they do not isolate state.
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## Async setup
|
|
162
|
+
|
|
163
|
+
`setup()` is called after `data()` and methods are bound. If it returns a `Promise`, it is awaited before the module is put into service. For singletons, requests arriving while setup is in progress receive **503 Service not ready**; for keyed/ephemeral instances, the handler awaits instance creation per request.
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
const service: ServiceDefinition<State> = {
|
|
167
|
+
data: () => ({ db: null as any }),
|
|
168
|
+
|
|
169
|
+
setup: async function (this: State) {
|
|
170
|
+
this.db = await connectToDatabase();
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
GET: {
|
|
174
|
+
'/items': function (this: State) {
|
|
175
|
+
return this.db.query('SELECT * FROM items');
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## Controllers and composition
|
|
184
|
+
|
|
185
|
+
Split a large API into per-domain files. Each file exports a `ControllerDefinition`; the composition root merges them into **one router** and **one OpenAPI document**.
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
interface ControllerDefinition<TInstance> {
|
|
189
|
+
prefix?: string; // prepended to every route (may contain :params)
|
|
190
|
+
tags?: string[]; // default OpenAPI tags for untagged routes
|
|
191
|
+
guards?: Guard[]; // run before every handler of this controller
|
|
192
|
+
permission?: string | string[]; // default permission (route meta overrides)
|
|
193
|
+
|
|
194
|
+
GET?: RouteMap<TInstance>; POST?: ...; PUT?: ...; DELETE?: ...; PATCH?: ...;
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
`defineController()` is an identity helper providing type inference when declaring a controller in its own file:
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
// wiki.controller.ts
|
|
202
|
+
import { defineController, describe } from 'expediate';
|
|
203
|
+
|
|
204
|
+
export const wikiController = defineController({
|
|
205
|
+
prefix: '/p/:proj/wiki',
|
|
206
|
+
tags: ['Wiki'],
|
|
207
|
+
permission: 'wiki.read',
|
|
208
|
+
|
|
209
|
+
GET: {
|
|
210
|
+
'/tree': describe(
|
|
211
|
+
(ctx) => wikiService.listPages(ctx.params.proj),
|
|
212
|
+
{ summary: 'List all wiki pages' }),
|
|
213
|
+
|
|
214
|
+
'/pages/:slug': describe(
|
|
215
|
+
(ctx) => wikiService.readPage(ctx.params.proj, ctx.params.slug),
|
|
216
|
+
{ summary: 'Read a wiki page' }),
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
PUT: {
|
|
220
|
+
'/pages/:slug': describe(
|
|
221
|
+
(ctx, body) => wikiService.writePage(ctx.params.proj, ctx.params.slug, body),
|
|
222
|
+
{ summary: 'Create or update a page', permission: 'wiki.write' }),
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
// index.ts — composition root
|
|
229
|
+
const api = apiBuilder({
|
|
230
|
+
controllers: [wikiController, issuesController, adminController],
|
|
231
|
+
});
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### Merge rules
|
|
235
|
+
|
|
236
|
+
- Each controller route is rewritten to `joinPath(prefix, path)` (duplicate slashes normalised; a `'/'` route + prefix `/p/:proj/wiki` → `/p/:proj/wiki`).
|
|
237
|
+
- Root-level route maps form an implicit controller with no prefix, so v1 single-definition services keep working unchanged.
|
|
238
|
+
- Routes of all controllers are concatenated and specificity-sorted **globally**.
|
|
239
|
+
- A duplicate `(verb, joined path)` pair **throws at build time**, naming both declaring controllers:
|
|
240
|
+
|
|
241
|
+
```
|
|
242
|
+
Error: apiBuilder: duplicate route GET /p/:proj/settings
|
|
243
|
+
declared by controllers 'Settings' and 'Projects'
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
- Handlers in every controller run with `this` bound to the same service instance.
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
## Guards and `ctx.state`
|
|
251
|
+
|
|
252
|
+
A *guard* is a pre-handler hook living in the `ctx` world:
|
|
253
|
+
|
|
254
|
+
```ts
|
|
255
|
+
type Guard = (ctx: ApiContext, req: RouterRequest)
|
|
256
|
+
=> void | Record<string, unknown> | Promise<void | Record<string, unknown>>;
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
A guard may **throw / reject an `ApiError`** (translated to an HTTP error response), **return an object** (shallow-merged into `ctx.state`), or **return void** (pure check).
|
|
260
|
+
|
|
261
|
+
Guards attach at three levels and run outermost-first:
|
|
262
|
+
|
|
263
|
+
```
|
|
264
|
+
auth.authenticate → auth.check → api guards → controller guards → route guards → handler
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Per-route guards ride on the `describe()` metadata:
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
import type { Guard } from 'expediate';
|
|
271
|
+
|
|
272
|
+
/** Require a project feature flag; expects ctx.state.proj loaded earlier. */
|
|
273
|
+
const featureEnabled = (flag: string): Guard => (ctx) => {
|
|
274
|
+
const proj = ctx.state.proj as any;
|
|
275
|
+
if (!proj?.[flag]) throw { status: 404, message: 'Feature is not enabled' };
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
/** Load a resource once and share it with later guards and the handler. */
|
|
279
|
+
const loadProject: Guard = async (ctx) => ({
|
|
280
|
+
proj: await projectService.openProject(ctx.params.proj),
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const api = apiBuilder({
|
|
284
|
+
guards: [/* run for every route of the API */],
|
|
285
|
+
controllers: [defineController({
|
|
286
|
+
prefix: '/p/:proj/wiki',
|
|
287
|
+
guards: [loadProject, featureEnabled('wikiEnabled')],
|
|
288
|
+
GET: {
|
|
289
|
+
'/pages/:slug': describe(
|
|
290
|
+
(ctx) => wikiService.readPage(ctx.params.proj, ctx.params.slug),
|
|
291
|
+
{ summary: 'Read a wiki page', guards: [/* route-level guards */] }),
|
|
292
|
+
},
|
|
293
|
+
})],
|
|
294
|
+
});
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
---
|
|
298
|
+
|
|
299
|
+
## Auth binding
|
|
300
|
+
|
|
301
|
+
Connect an authentication layer (typically the JWT plugin) to the API Builder once, then declare permissions per route or per controller:
|
|
302
|
+
|
|
303
|
+
```ts
|
|
304
|
+
interface AuthBinding<TUser = unknown> {
|
|
305
|
+
authenticate?: Middleware; // registered on the internal router, runs first
|
|
306
|
+
check?: (ctx: ApiContext<TUser>, required: string[]) => void | Promise<void>;
|
|
307
|
+
scheme?: Record<string, unknown>; // OpenAPI security scheme (default: bearer JWT)
|
|
308
|
+
permissionsExtension?: string; // default: 'x-required-permissions'
|
|
309
|
+
}
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
### Out-of-the-box JWT integration
|
|
313
|
+
|
|
314
|
+
```ts
|
|
315
|
+
import { createJwtPlugin, apiBuilder, describe } from 'expediate';
|
|
316
|
+
|
|
317
|
+
const jwt = createJwtPlugin({ accessTokenSecret: SECRET });
|
|
318
|
+
|
|
319
|
+
const api = apiBuilder({
|
|
320
|
+
auth: { authenticate: jwt.authenticate }, // default check() reads ctx.user.permissions
|
|
321
|
+
GET: {
|
|
322
|
+
'/public': () => ({ ok: true }), // no permission → public
|
|
323
|
+
'/private': describe(handler, { permission: 'write' }), // 401 / 403 / pass
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
The default `check` mirrors `jwtPlugin.requirePermission` semantics in the `ctx` world: `401` when `ctx.user` is absent, `403` when `ctx.user.permissions` is missing **any** required entry. Routes without a `permission` stay public — authorization is opt-in per route.
|
|
329
|
+
|
|
330
|
+
### Resource-scoped models
|
|
331
|
+
|
|
332
|
+
Override `check` **once** for per-resource permissions; it may load resources and share them through `ctx.state`:
|
|
333
|
+
|
|
334
|
+
```ts
|
|
335
|
+
auth: {
|
|
336
|
+
authenticate: jwt.authenticate,
|
|
337
|
+
check: async (ctx, required) => {
|
|
338
|
+
const proj = await projectService.openProject(ctx.params.proj);
|
|
339
|
+
for (const p of required)
|
|
340
|
+
authService.requirePermission(ctx.user?.sub ?? null, proj, p);
|
|
341
|
+
ctx.state.proj = proj; // share the loaded project with guards/handler
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
---
|
|
347
|
+
|
|
348
|
+
## Request validation
|
|
349
|
+
|
|
350
|
+
Execute the JSON Schemas you already declare in `describe()` metadata:
|
|
351
|
+
|
|
352
|
+
```ts
|
|
353
|
+
const api = apiBuilder({
|
|
354
|
+
validate: true, // or { requests: true }
|
|
355
|
+
schemas: { // shared by the validator AND the spec
|
|
356
|
+
Item: {
|
|
357
|
+
type: 'object',
|
|
358
|
+
required: ['name'],
|
|
359
|
+
properties: {
|
|
360
|
+
name: { type: 'string', pattern: '^[a-z0-9][a-z0-9.\\-]*$' },
|
|
361
|
+
size: { type: 'integer', minimum: 1 },
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
},
|
|
365
|
+
POST: {
|
|
366
|
+
'/items': describe(createItem, {
|
|
367
|
+
requestBody: {
|
|
368
|
+
required: true,
|
|
369
|
+
content: { 'application/json': { schema: { $ref: '#/components/schemas/Item' } } },
|
|
370
|
+
},
|
|
371
|
+
}),
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
When enabled, the body is checked against the route's `requestBody` schema before the guards run. Failures produce `400`:
|
|
377
|
+
|
|
378
|
+
```json
|
|
379
|
+
{ "message": "Request body validation failed",
|
|
380
|
+
"fieldErrors": { "name": "does not match pattern ^[a-z0-9][a-z0-9.\\-]*$" } }
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
Supported keywords: `type`, `required`, `properties`, `items`, `enum`, `pattern`, `minLength`/`maxLength`, `minimum`/`maximum`, `additionalProperties`, `allOf`/`anyOf`/`oneOf`, and `$ref` resolved against `ServiceDefinition.schemas`. Field-error paths are dotted (`name`, `address.city`, `tags.0`); errors on the body itself are keyed `'$'`.
|
|
384
|
+
|
|
385
|
+
---
|
|
386
|
+
|
|
387
|
+
## Builder options: `apiBuilder(service, options?)`
|
|
388
|
+
|
|
389
|
+
`apiBuilder` takes an optional second argument, `ApiBuilderOptions`, that controls validation. When you pass it, it is authoritative and overrides the `service.validate` field (which accepts the same `boolean | ApiBuilderOptions` shape):
|
|
390
|
+
|
|
391
|
+
```ts
|
|
392
|
+
apiBuilder(service); // follows service.validate
|
|
393
|
+
apiBuilder(service, {}); // validate requests (default on), not responses
|
|
394
|
+
apiBuilder(service, { validateRequests: false }); // validate nothing
|
|
395
|
+
apiBuilder(service, { validateResponses: true }); // requests + responses (500 on mismatch)
|
|
396
|
+
apiBuilder(service, { validateResponses: 'warn' }); // requests + responses (log only, no 500)
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
`ApiBuilderOptions`:
|
|
400
|
+
|
|
401
|
+
| Field | Type | Default | Effect |
|
|
402
|
+
|---------------------|--------------------|---------|--------|
|
|
403
|
+
| `validateRequests` | `boolean` | `true` | Check incoming bodies against `requestBody` schemas. `false` cancels it. Failure → `400`. |
|
|
404
|
+
| `validateResponses` | `boolean \| 'warn'`| `false` | Check each handler's return against the route's `responses['200']` schema. `true` → `500` on mismatch (off-spec body not sent); `'warn'` → log via `console.warn` and send anyway. |
|
|
405
|
+
|
|
406
|
+
Response validation is a **server-contract** check: a handler returning data that violates its own declared `200` schema means the *server* is at fault. With `true`, a mismatch yields `500 { message: 'Response body validation failed', fieldErrors }` instead of emitting an off-spec body; with `'warn'`, it logs server-side and sends the response unchanged (handy in development). Only truthy returns (sent as `200`) are checked — falsy returns (`201 No Content`) and routes with no declared `200` content schema are skipped. It uses the same validator and `fieldErrors` shape as request validation.
|
|
407
|
+
|
|
408
|
+
```ts
|
|
409
|
+
const api = apiBuilder(todoService, { validateResponses: true });
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
---
|
|
413
|
+
|
|
414
|
+
## Mounting
|
|
415
|
+
|
|
416
|
+
`apiBuilder` returns a `Router`. Mount it with `app.use()`:
|
|
417
|
+
|
|
418
|
+
```ts
|
|
419
|
+
app.use('/api/v1', apiBuilder(todoService));
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
For tests, mounting at `'/'` avoids path-stripping concerns:
|
|
423
|
+
|
|
424
|
+
```ts
|
|
425
|
+
app.use('/', apiBuilder(todoService));
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
Body-parsing middleware must be registered before the API router for `body` to be populated in handlers.
|
|
429
|
+
|
|
430
|
+
---
|
|
431
|
+
|
|
432
|
+
## OpenAPI spec generation
|
|
433
|
+
|
|
434
|
+
→ Full reference: [docs/openapi.md](openapi.md)
|
|
435
|
+
|
|
436
|
+
The router returned by `apiBuilder` can introspect its own definition — controllers included, producing **one** document:
|
|
437
|
+
|
|
438
|
+
```ts
|
|
439
|
+
import { describe } from 'expediate';
|
|
440
|
+
|
|
441
|
+
GET: {
|
|
442
|
+
'/todos/:id': describe(
|
|
443
|
+
function (this: State, ctx) { return this.findOrThrow(ctx.params.id); },
|
|
444
|
+
{ summary: 'Get a todo by ID', tags: ['todos'] },
|
|
445
|
+
),
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const api = apiBuilder(service);
|
|
449
|
+
app.use('/api', api);
|
|
450
|
+
app.get('/openapi.json', api.specHandler({ title: 'Todo API', version: '1.0.0' }));
|
|
451
|
+
app.get('/openapi.yaml', api.specHandler({ title: 'Todo API', version: '1.0.0' }, 'yaml'));
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
`openApiSpec(service, opts)` is the underlying free function when you have the definition but not the router — it also accepts an array of sources to merge in routes that have no `ServiceDefinition` at all (e.g. JWT auth endpoints). See [docs/openapi.md](openapi.md) for `describe()`, security output, `serializeSpec()`, and multi-source merging.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Benchmark
|
|
2
|
+
|
|
3
|
+
Les chiffres sont très bons. Tu bats Express partout (+30 % environ), et sur `json-echo` tu dépasses même Fastify (73k vs 70k). L'écart ne porte que sur les scénarios à dominante *routing* (hello, route-param, chain), où Fastify mène d'~25 %. Laisse-moi regarder ton dispatch pour t'expliquer précisément d'où vient l'écart.Bonne nouvelle d'abord : ton intuition est en partie juste. Là où le travail dominant n'est pas le routing — `json-echo`, où le coût est `JSON.parse`/`stringify` que les deux paient à l'identique (on a désactivé le fast-path de sérialisation de Fastify) — tu passes **devant** Fastify (73k vs 70k). L'écart ne vit que sur le chemin de dispatch. Donc Fastify n'est pas « plus léger » que toi ; il a un chemin par-requête mieux taillé pour V8. J'ai lu ton `router.ts`, voici concrètement d'où vient l'écart.
|
|
4
|
+
|
|
5
|
+
**1. L'algorithme de routeur : scan regex linéaire vs arbre radix.** Ta boucle `next()` fait `while (idx < routes.length)` et exécute `layer.regex.exec(path)` / `.test(path)` sur chaque layer jusqu'à match (lignes 985, 1469, 1698-1702). C'est O(n) exécutions de regex par requête, et chaque `.exec` avec groupes nommés alloue un objet match + un objet groups. Fastify utilise `find-my-way`, un arbre radix (trie compressé) : O(longueur du path), **zéro regex** pour les routes statiques et paramétrées, zéro allocation de match. Avec peu de routes l'écart reste modéré, mais un parcours de trie est intrinsèquement moins cher qu'un `RegExp.exec`.
|
|
6
|
+
|
|
7
|
+
**2. Tu fais du travail eager sur *chaque* requête dans `updateHttpObjects`**, même quand le handler n'en a pas besoin :
|
|
8
|
+
|
|
9
|
+
- `new URL(\`http://${host}${req.url}\`)` (ligne 1144) — le constructeur WHATWG URL est l'un des appels les plus lourds du chemin : parsing/validation complète + allocation d'un objet URL et de son `searchParams`. Fastify fait un simple split sur `?` et ne parse la query que si on y accède.
|
|
10
|
+
- Tu construis plusieurs objets et fais **deux passes** sur les paramètres (`searchParams.entries()` puis `Object.entries`) même sans query string.
|
|
11
|
+
- Tu alloues **3 closures par requête** — `rReq.json`, `rReq.text`, `rReq.formData` (lignes 1206-1254) plus `resolvedReqOpts` — créées même si le body n'est jamais lu. Plus ~8 helpers `res.*` (send/json/status/redirect/cookie…). Fastify n'attache pas de closures par requête.
|
|
12
|
+
- Tu parses les cookies et poses `X-Powered-By` systématiquement.
|
|
13
|
+
|
|
14
|
+
Chacun est minuscule, mais à 90k req/s ce sont autant d'allocations qui nourrissent le GC.
|
|
15
|
+
|
|
16
|
+
**3. Hidden classes / mégamorphisme.** Tu greffes dynamiquement une quinzaine de propriétés (`ip`, `path`, `queries`, `params`, `cookies`, `json`, `text`…) sur les objets `IncomingMessage`/`ServerResponse` bruts, dont V8 a déjà figé la forme. Ces ajouts forcent des transitions de hidden-class et un accès propriété plus lent. Fastify crée **ses propres** objets Request/Reply à forme fixe et déclarée par route (factory + prototype), donc V8 les garde monomorphes.
|
|
17
|
+
|
|
18
|
+
**4. La boucle de dispatch elle-même** alloue des closures par hop (`invoke`, `next`, l'arrow strip-path ligne 1710) et enveloppe chaque appel dans try/catch + `ret instanceof Promise`. Fastify compile la chaîne hooks/handler à l'avance, une fois par route.
|
|
19
|
+
|
|
20
|
+
En clair : tu ne fais pas « trop », tu paies des coûts peu V8-friendly (dispatch regex, URL parsée eager, closures par requête, augmentation dynamique d'objets) qu'un design trie + objets à forme stable évite.
|
|
21
|
+
|
|
22
|
+
Si tu veux refermer l'écart, sans ajouter de dépendance, par ordre de rendement/risque :
|
|
23
|
+
|
|
24
|
+
1. **Tout rendre lazy** — ne crée `json/text/formData`, ne parse cookies et query qu'au premier accès (getters définis une fois sur un prototype). Retire ~4 allocations du chemin chaud. Gros gain, risque faible.
|
|
25
|
+
2. **Remplacer `new URL()`** par un split manuel sur le premier `?` ; parser la query seulement si lue. C'est l'appel le plus cher isolé.
|
|
26
|
+
3. **Forme de requête stable** — initialiser tous les champs augmentés dans un ordre fixe (idéalement via une classe/prototype) pour garder une seule hidden-class.
|
|
27
|
+
4. **Routeur radix** pour le fast-path statique/paramétré, en retombant sur la regex uniquement pour les globs/RegExp/contraintes `:id(\d+)`. C'est le gain structurel majeur — mais le plus de travail et le plus de risque pour ta suite de tests. À garder en phase 2.
|