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,644 @@
|
|
|
1
|
+
# API Builder v2 — Composition, Guards, and Auth Binding
|
|
2
|
+
|
|
3
|
+
**Status:** Draft / design discussion
|
|
4
|
+
**Date:** 2026-06-10
|
|
5
|
+
**Scope:** `src/apis.ts`, `src/openapi.ts`, touch points with `src/jwt-auth.ts`
|
|
6
|
+
**Compatibility:** breaking changes allowed (target: v2.0)
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## 1. Context
|
|
11
|
+
|
|
12
|
+
A real consumer of `apiBuilder` (the DevLab project) was reviewed as a field
|
|
13
|
+
study. Its single `api-service.ts` is **3,480 lines** and contains:
|
|
14
|
+
|
|
15
|
+
- **~92 routes** in one `ServiceDefinition` across `GET` / `POST` / `PUT` / `DELETE`
|
|
16
|
+
- **13 injected services** passed as positional factory arguments
|
|
17
|
+
- ~70 routes sharing the `/p/:proj` prefix
|
|
18
|
+
- hand-rolled authorization helpers (`requireAdmin`, `requirePerm`) called
|
|
19
|
+
manually at the top of nearly every handler
|
|
20
|
+
- manual body validation duplicating the JSON Schemas already declared in
|
|
21
|
+
`describe()` metadata
|
|
22
|
+
- the `scope` / `data` / `methods` service-instance model entirely unused —
|
|
23
|
+
every dependency is a closure capture
|
|
24
|
+
|
|
25
|
+
None of this is bad client code. It is the *only* shape the current API
|
|
26
|
+
allows: `ServiceDefinition` is the sole unit of grouping, and it owns the
|
|
27
|
+
whole API. When the API grows, the file grows with it. The framework offers
|
|
28
|
+
no seam along which to cut.
|
|
29
|
+
|
|
30
|
+
## 2. Pain points (evidence)
|
|
31
|
+
|
|
32
|
+
### PP1 — No composition unit
|
|
33
|
+
|
|
34
|
+
`apiBuilder(service)` accepts exactly one definition. There is no supported
|
|
35
|
+
way to define the Wiki routes in `wiki.controller.ts` and the Issues routes
|
|
36
|
+
in `issues.controller.ts` and merge them into one router with one OpenAPI
|
|
37
|
+
document. Mounting several `apiBuilder()` routers under `app.use()` *almost*
|
|
38
|
+
works, but each router produces its own spec, owns its own 503 guard and
|
|
39
|
+
instance cache, and route specificity sorting is no longer global.
|
|
40
|
+
|
|
41
|
+
### PP2 — Prefix repetition
|
|
42
|
+
|
|
43
|
+
`'/p/:proj/...'` is typed ~70 times. Any rename of the URL scheme is a
|
|
44
|
+
70-site edit.
|
|
45
|
+
|
|
46
|
+
### PP3 — Repeated cross-cutting preamble
|
|
47
|
+
|
|
48
|
+
The same four lines open almost every handler. The Wiki group repeats this
|
|
49
|
+
block six times, Snippets four times, Issues/MRs use a variant ~15 times:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
'/p/:proj/wiki/pages/:slug': describe(async function (ctx: ApiContext): Promise<WikiPage> {
|
|
53
|
+
const projectName = ctx.query.route.proj;
|
|
54
|
+
await requirePerm(ctx, projectName, 'wiki.read')
|
|
55
|
+
const proj = await projectService.openProject(projectName);
|
|
56
|
+
if (!proj.wikiEnabled) throw HttpErr.NotFound('Wiki is not enabled for this project');
|
|
57
|
+
return wikiService.readPage(projectName, ctx.query.route.slug);
|
|
58
|
+
}, { ... }),
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Authorization, resource loading, and feature-flag checks are cross-cutting
|
|
62
|
+
concerns, but the API Builder gives handlers no "before" hook — the only
|
|
63
|
+
middleware seam is the raw router level, *outside* the `ctx` world.
|
|
64
|
+
|
|
65
|
+
### PP4 — Permissions are disconnected from the JWT plugin
|
|
66
|
+
|
|
67
|
+
`createJwtPlugin` ships `requireRole` / `requirePermission` middleware, but
|
|
68
|
+
they are unusable inside `apiBuilder` handlers: they live in the
|
|
69
|
+
`(req, res, next)` world while service methods live in the `(ctx, body)`
|
|
70
|
+
world. The client reimplements the entire authorization layer
|
|
71
|
+
(`requireAdmin`, `requirePerm`, `authService`) and types `ctx.user` as
|
|
72
|
+
`any`, with `(ctx.user as any).ipAddress` casts scattered around.
|
|
73
|
+
|
|
74
|
+
### PP5 — Declared schemas are write-only
|
|
75
|
+
|
|
76
|
+
Every route carries a `requestBody` JSON Schema for the OpenAPI spec — and
|
|
77
|
+
then validates the body *again* by hand (`applySettingsBody` is 150 lines of
|
|
78
|
+
manual field checks; milestone creation re-implements `pattern` and
|
|
79
|
+
`required`). The framework already holds machine-readable validation rules
|
|
80
|
+
and never executes them.
|
|
81
|
+
|
|
82
|
+
### PP6 — Context verbosity
|
|
83
|
+
|
|
84
|
+
`ctx.query.route.proj` is the most-typed expression in the file. The
|
|
85
|
+
namespacing is correct (route vs URL params must not collide) but the common
|
|
86
|
+
case deserves a shorthand.
|
|
87
|
+
|
|
88
|
+
## 3. Goals and non-goals
|
|
89
|
+
|
|
90
|
+
**Goals**
|
|
91
|
+
|
|
92
|
+
1. Let a client split one API into per-domain files that merge into a single
|
|
93
|
+
router and a single OpenAPI document, with global route sorting and
|
|
94
|
+
build-time conflict detection.
|
|
95
|
+
2. Provide a declarative authorization seam inside the `ctx` world, bridged
|
|
96
|
+
to `jwt-auth` out of the box and overridable for resource-scoped checks.
|
|
97
|
+
3. Execute the JSON Schemas the client already writes.
|
|
98
|
+
4. Keep zero runtime dependencies and the data-first, no-decorator style.
|
|
99
|
+
|
|
100
|
+
**Non-goals**
|
|
101
|
+
|
|
102
|
+
- A dependency-injection container. Closure capture works well; the missing
|
|
103
|
+
piece is file-level splitting, not DI.
|
|
104
|
+
- TypeScript decorators (`@Get('/path')`). They impose compiler options on
|
|
105
|
+
consumers and break the "plain objects" philosophy of the framework.
|
|
106
|
+
- App-specific conveniences (pagination parsing, dry-run protocol, audit-log
|
|
107
|
+
hooks). These belong in client helper functions; the framework only needs
|
|
108
|
+
to make such helpers easy to apply (guards, see P2).
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## 4. P1 — Controllers and composition
|
|
113
|
+
|
|
114
|
+
### New types
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
/** A group of routes sharing a path prefix, tags, and guards. */
|
|
118
|
+
export interface ControllerDefinition<TInstance extends ServiceInstance = ServiceInstance> {
|
|
119
|
+
/** Path prefix prepended to every route in this controller (may contain params). */
|
|
120
|
+
prefix?: string;
|
|
121
|
+
/** Default OpenAPI tags applied to routes that do not declare their own. */
|
|
122
|
+
tags?: string[];
|
|
123
|
+
/** Guards run before every handler of this controller (see P2). */
|
|
124
|
+
guards?: Guard[];
|
|
125
|
+
/** Default permission requirement for every route (see P3). Route-level meta overrides. */
|
|
126
|
+
permission?: string | string[];
|
|
127
|
+
|
|
128
|
+
GET?: RouteMap<TInstance>;
|
|
129
|
+
POST?: RouteMap<TInstance>;
|
|
130
|
+
PUT?: RouteMap<TInstance>;
|
|
131
|
+
DELETE?: RouteMap<TInstance>;
|
|
132
|
+
PATCH?: RouteMap<TInstance>;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Identity helper for type inference and discoverability. */
|
|
136
|
+
export function defineController<TInstance extends ServiceInstance = ServiceInstance>(
|
|
137
|
+
c: ControllerDefinition<TInstance>,
|
|
138
|
+
): ControllerDefinition<TInstance> { return c; }
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Extended `ServiceDefinition`
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
export interface ServiceDefinition<TInstance extends ServiceInstance = ServiceInstance> {
|
|
145
|
+
scope?: (req: RouterRequest) => string | null;
|
|
146
|
+
data?: (key: string | null) => Partial<TInstance>;
|
|
147
|
+
setup?: (this: TInstance) => void | Promise<void>;
|
|
148
|
+
methods?: ServiceMethods<TInstance>;
|
|
149
|
+
|
|
150
|
+
/** NEW — sub-controllers merged into this API. */
|
|
151
|
+
controllers?: ControllerDefinition<TInstance>[];
|
|
152
|
+
/** NEW — guards run before every handler of the whole API (see P2). */
|
|
153
|
+
guards?: Guard[];
|
|
154
|
+
/** NEW — authentication/authorization binding (see P3). */
|
|
155
|
+
auth?: AuthBinding;
|
|
156
|
+
/** NEW — runtime validation of declared request schemas (see P4). */
|
|
157
|
+
validate?: boolean | ApiBuilderOptions;
|
|
158
|
+
/** NEW — schema components, shared by validation and spec generation. */
|
|
159
|
+
schemas?: Record<string, JsonSchema>;
|
|
160
|
+
|
|
161
|
+
// Root-level route maps remain valid — they form an implicit controller
|
|
162
|
+
// with no prefix. Existing v1 definitions therefore still compile.
|
|
163
|
+
GET?: RouteMap<TInstance>; POST?: ...; PUT?: ...; DELETE?: ...; PATCH?: ...;
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The instance model (`scope` / `data` / `setup` / `methods`) stays at the top
|
|
168
|
+
level: one instance lifecycle for the whole API, shared by all controllers.
|
|
169
|
+
Controllers are *route organisation*, not isolation boundaries — handlers in
|
|
170
|
+
every controller run with `this` bound to the same service instance.
|
|
171
|
+
|
|
172
|
+
### Merge algorithm
|
|
173
|
+
|
|
174
|
+
At build time, `apiBuilder`:
|
|
175
|
+
|
|
176
|
+
1. Normalises the root route maps into an anonymous controller
|
|
177
|
+
(`prefix: ''`).
|
|
178
|
+
2. For each controller and verb, rewrites each path to
|
|
179
|
+
`joinPath(prefix, path)` (normalising duplicate slashes; `'/'` route +
|
|
180
|
+
prefix `/p/:proj/wiki` → `/p/:proj/wiki`).
|
|
181
|
+
3. Concatenates all routes of all controllers per verb, then applies the
|
|
182
|
+
existing specificity sort **globally**. The current score
|
|
183
|
+
(`segments * 100 − params * 10`) already accounts for prefix parameters
|
|
184
|
+
since scoring happens on the joined path.
|
|
185
|
+
4. **Throws at build time** on a duplicate `(verb, joined path)` pair:
|
|
186
|
+
|
|
187
|
+
```
|
|
188
|
+
Error: apiBuilder: duplicate route GET /p/:proj/settings
|
|
189
|
+
declared by controllers 'Settings' and 'Projects'
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Today a duplicate silently shadows; with multi-file composition, loud
|
|
193
|
+
failure becomes essential.
|
|
194
|
+
5. Records per-route provenance (controller tags, guards, permission) in a
|
|
195
|
+
merged metadata table consumed by both the request pipeline and
|
|
196
|
+
`openApiSpec()`.
|
|
197
|
+
|
|
198
|
+
`openApiSpec()` operates on the merged table, so `api.spec()` /
|
|
199
|
+
`api.specHandler()` keep producing **one** document. Controller `tags` fill
|
|
200
|
+
in `OperationMeta.tags` when a route declares none.
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## 5. P2 — Guards and `ctx.state`
|
|
205
|
+
|
|
206
|
+
A *guard* is a pre-handler hook living in the `ctx` world:
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
/**
|
|
210
|
+
* Runs before the route handler. May:
|
|
211
|
+
* - throw / reject an ApiError → translated to an HTTP error response;
|
|
212
|
+
* - return an object → shallow-merged into `ctx.state`;
|
|
213
|
+
* - return void → pure check.
|
|
214
|
+
*/
|
|
215
|
+
export type Guard = (
|
|
216
|
+
ctx: ApiContext,
|
|
217
|
+
req: RouterRequest,
|
|
218
|
+
) => void | Record<string, unknown> | Promise<void | Record<string, unknown>>;
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Guards attach at three levels and run outermost-first:
|
|
222
|
+
|
|
223
|
+
```
|
|
224
|
+
auth.authenticate → auth.check → api.guards → controller.guards → route meta.guards → handler
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Per-route guards ride on `OperationMeta` (the natural per-route metadata
|
|
228
|
+
slot we already have; spec generation simply ignores the field):
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
describe(handler, { summary: '...', guards: [onlyOwner] })
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
`ApiContext` gains a `state` bag for guard-produced values:
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
export interface ApiContext<TUser = unknown, TState = Record<string, unknown>> {
|
|
238
|
+
query: { route: Record<string, string>; url: Record<string, string | string[]> };
|
|
239
|
+
/** Shorthand for `query.route`. */
|
|
240
|
+
params: Record<string, string>;
|
|
241
|
+
path: string;
|
|
242
|
+
user?: TUser;
|
|
243
|
+
/** Values produced by guards (loaded resources, resolved roles, …). */
|
|
244
|
+
state: TState;
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
This is the feature that erases PP3: "check the permission, load the
|
|
249
|
+
project, check the feature flag" becomes three small reusable guards, and
|
|
250
|
+
handlers shrink to their actual business logic. Because a guard can *load
|
|
251
|
+
and share* a resource via `ctx.state`, it also removes the duplicated
|
|
252
|
+
`openProject` call that today runs once in `requirePerm` and once in the
|
|
253
|
+
handler.
|
|
254
|
+
|
|
255
|
+
Failure mode is the existing `ApiError` contract — guards need no new error
|
|
256
|
+
channel.
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## 6. P3 — Auth binding: connecting `jwt-auth` to the API Builder
|
|
261
|
+
|
|
262
|
+
### The binding
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
export interface AuthBinding<TUser = unknown> {
|
|
266
|
+
/**
|
|
267
|
+
* Router middleware run before any guard or handler — typically
|
|
268
|
+
* `jwtPlugin.authenticate`. Registered by apiBuilder on its internal
|
|
269
|
+
* router, so the client no longer wires it per-mount.
|
|
270
|
+
*/
|
|
271
|
+
authenticate?: Middleware;
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Enforce a permission requirement for the current request.
|
|
275
|
+
* Default implementation: require `ctx.user` and check
|
|
276
|
+
* `ctx.user.permissions` contains all required entries — i.e. the exact
|
|
277
|
+
* semantics of `jwtPlugin.requirePermission`, but in the ctx world.
|
|
278
|
+
* Override for resource-scoped models (per-project roles, ownership, …).
|
|
279
|
+
*/
|
|
280
|
+
check?: (ctx: ApiContext<TUser>, required: string[]) => void | Promise<void>;
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* OpenAPI security scheme emitted into `components.securitySchemes`.
|
|
284
|
+
* Default: `{ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }`.
|
|
285
|
+
*/
|
|
286
|
+
scheme?: Record<string, unknown>;
|
|
287
|
+
}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
### Declarative requirement
|
|
291
|
+
|
|
292
|
+
`OperationMeta` and `ControllerDefinition` gain `permission`:
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
describe(handler, { summary: '...', permission: 'wiki.read' })
|
|
296
|
+
// or for a whole controller:
|
|
297
|
+
defineController({ prefix: '/admin', permission: 'manage_users', ... })
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
When a route (or its controller) declares `permission`, the pipeline runs
|
|
301
|
+
`auth.check(ctx, required)` before the guards. Routes without a
|
|
302
|
+
`permission` stay public — authorization is opt-in per route, matching the
|
|
303
|
+
`authenticate`/`authorize` split already used by the JWT plugin.
|
|
304
|
+
|
|
305
|
+
### Out-of-the-box JWT integration
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
const jwt = createJwtPlugin({ accessTokenSecret: SECRET });
|
|
309
|
+
|
|
310
|
+
const api = apiBuilder({
|
|
311
|
+
auth: { authenticate: jwt.authenticate }, // default check() reads ctx.user.permissions
|
|
312
|
+
controllers: [ ... ],
|
|
313
|
+
});
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
For DevLab's resource-scoped model the client overrides `check` **once**,
|
|
317
|
+
replacing the per-handler `requirePerm` calls:
|
|
318
|
+
|
|
319
|
+
```ts
|
|
320
|
+
auth: {
|
|
321
|
+
authenticate: jwt.authenticate,
|
|
322
|
+
check: async (ctx, required) => {
|
|
323
|
+
const proj = await projectService.openProject(ctx.params.proj);
|
|
324
|
+
for (const p of required)
|
|
325
|
+
authService.requirePermission(ctx.user?.sub ?? null, proj, p as Permission);
|
|
326
|
+
ctx.state.proj = proj; // share the loaded project with guards/handler
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### OpenAPI output
|
|
332
|
+
|
|
333
|
+
Routes carrying a `permission` automatically receive
|
|
334
|
+
`security: [{ bearerAuth: [] }]` and a vendor extension
|
|
335
|
+
`x-required-permissions: [...]` in the generated document, and
|
|
336
|
+
`components.securitySchemes.bearerAuth` is emitted once. Today the spec
|
|
337
|
+
says nothing about auth at all.
|
|
338
|
+
|
|
339
|
+
### Typed user
|
|
340
|
+
|
|
341
|
+
`ApiContext<TUser>` replaces `user?: any`. `jwt-auth` exports
|
|
342
|
+
`TokenPayload`, so DevLab writes `ApiContext<TokenPayload>` and the
|
|
343
|
+
`(ctx.user as any)` casts disappear.
|
|
344
|
+
|
|
345
|
+
---
|
|
346
|
+
|
|
347
|
+
## 7. P4 — Request validation from declared schemas
|
|
348
|
+
|
|
349
|
+
```ts
|
|
350
|
+
export interface ApiBuilderOptions {
|
|
351
|
+
/** Validate request bodies against `meta.requestBody` schemas (400 on failure). Default true. */
|
|
352
|
+
validateRequests?: boolean;
|
|
353
|
+
/** Validate handler returns against `meta.responses['200']`: true → 500, 'warn' → log only. Default false. */
|
|
354
|
+
validateResponses?: boolean | 'warn';
|
|
355
|
+
}
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
When `validate` is enabled and a route's `OperationMeta.requestBody`
|
|
359
|
+
declares a schema, the body is checked before the handler runs. Failures
|
|
360
|
+
produce `400` with a body mirroring the client's existing `fieldErrors`
|
|
361
|
+
convention:
|
|
362
|
+
|
|
363
|
+
```json
|
|
364
|
+
{ "message": "Request body validation failed",
|
|
365
|
+
"fieldErrors": { "name": "does not match pattern ^[a-z0-9][a-z0-9.\\-]*$" } }
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
Implementation: a small internal validator covering the pragmatic subset of
|
|
369
|
+
the already-exported `JsonSchema` type — `type`, `required`, `properties`,
|
|
370
|
+
`items`, `enum`, `pattern`, `minLength`/`maxLength`, `minimum`/`maximum`,
|
|
371
|
+
`additionalProperties`, `allOf`/`anyOf`/`oneOf`, and `$ref` resolved against
|
|
372
|
+
`ServiceDefinition.schemas`. Roughly 150 lines, zero dependencies, shared
|
|
373
|
+
with nothing else (no ajv-style compilation needed at these traffic levels).
|
|
374
|
+
|
|
375
|
+
`schemas` moves the component map from `SpecOptions` (doc-generation time)
|
|
376
|
+
to the service definition (build time), so one declaration feeds both the
|
|
377
|
+
spec and the validator; `openApiSpec()` reads it from the service when
|
|
378
|
+
present.
|
|
379
|
+
|
|
380
|
+
This deletes most of the client's hand-rolled checks: the milestone
|
|
381
|
+
`NAME_RE` test, the `applySettingsBody` type/enum/length checks, the
|
|
382
|
+
"must be a string array" guards — all already expressible in the schemas
|
|
383
|
+
the client writes anyway.
|
|
384
|
+
|
|
385
|
+
---
|
|
386
|
+
|
|
387
|
+
## 8. P5 — Context ergonomics
|
|
388
|
+
|
|
389
|
+
Small, mechanical additions to `ApiContext` (all shown in §5):
|
|
390
|
+
|
|
391
|
+
- `ctx.params` — alias of `ctx.query.route`. The dominant access pattern
|
|
392
|
+
deserves the short name; the namespaced form remains for collisions.
|
|
393
|
+
- `ctx.state` — guard-produced values (see P2).
|
|
394
|
+
- `ApiContext<TUser, TState>` generics — typed `user` and `state`.
|
|
395
|
+
|
|
396
|
+
Rejected: built-in pagination parsing (`start`/`count` conventions are
|
|
397
|
+
app-specific), a response-shaping hook (the `Edition`/dry-run protocol is
|
|
398
|
+
DevLab domain logic), and `ctx.req`/`ctx.res` escape hatches (handlers that
|
|
399
|
+
need the raw objects should be plain router middleware).
|
|
400
|
+
|
|
401
|
+
---
|
|
402
|
+
|
|
403
|
+
## 9. Worked example — refactoring the DevLab monolith
|
|
404
|
+
|
|
405
|
+
Target layout (one file per domain, ~150–400 lines each):
|
|
406
|
+
|
|
407
|
+
```
|
|
408
|
+
api/
|
|
409
|
+
index.ts — composition root (apiBuilder call, auth binding)
|
|
410
|
+
guards.ts — featureEnabled(), loadProject(), requireAdmin
|
|
411
|
+
auth.controller.ts — /auth/*
|
|
412
|
+
projects.controller.ts — /projects, /p/:proj, /p/:proj/settings
|
|
413
|
+
git.controller.ts — commits, refs, files, git-update hooks
|
|
414
|
+
wiki.controller.ts — /p/:proj/wiki/*
|
|
415
|
+
snippets.controller.ts — /p/:proj/snippets/*
|
|
416
|
+
issues.controller.ts — issues, comments, reactions
|
|
417
|
+
merge-requests.controller.ts
|
|
418
|
+
pipelines.controller.ts — pipelines + CI runner helpers
|
|
419
|
+
releases.controller.ts — releases, milestones promote
|
|
420
|
+
admin.controller.ts — /admin/namespaces, /users
|
|
421
|
+
notifications.controller.ts
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
### `guards.ts`
|
|
425
|
+
|
|
426
|
+
```ts
|
|
427
|
+
import type { Guard } from 'expediate';
|
|
428
|
+
import { HttpErr } from './utils';
|
|
429
|
+
|
|
430
|
+
/** Require a project feature flag; expects ctx.state.proj loaded by auth.check(). */
|
|
431
|
+
export const featureEnabled = (flag: 'wikiEnabled' | 'snippetsEnabled'): Guard =>
|
|
432
|
+
(ctx) => {
|
|
433
|
+
const proj = ctx.state.proj as DbProject;
|
|
434
|
+
if (!proj?.[flag]) throw HttpErr.NotFound('Feature is not enabled for this project');
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
/** Require a system administrator. */
|
|
438
|
+
export const requireAdmin: Guard = (ctx) => {
|
|
439
|
+
const username = ctx.user?.sub;
|
|
440
|
+
if (!username) throw HttpErr.Unauthorized('Authentication required');
|
|
441
|
+
const user = userService.listUsers().find(u => u.username === username);
|
|
442
|
+
if (!user?.isAdmin) throw HttpErr.Forbidden('Admin access required');
|
|
443
|
+
return { username }; // → ctx.state.username
|
|
444
|
+
};
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
### `wiki.controller.ts` — before vs after
|
|
448
|
+
|
|
449
|
+
Before (current code, **one of six** near-identical handlers):
|
|
450
|
+
|
|
451
|
+
```ts
|
|
452
|
+
'/p/:proj/wiki/pages/:slug': describe(async function (ctx: ApiContext): Promise<WikiPage> {
|
|
453
|
+
const projectName = ctx.query.route.proj;
|
|
454
|
+
await requirePerm(ctx, projectName, 'wiki.read')
|
|
455
|
+
const proj = await projectService.openProject(projectName);
|
|
456
|
+
if (!proj.wikiEnabled) throw HttpErr.NotFound('Wiki is not enabled for this project');
|
|
457
|
+
return wikiService.readPage(projectName, ctx.query.route.slug);
|
|
458
|
+
}, {
|
|
459
|
+
summary: 'Read a wiki page',
|
|
460
|
+
operationId: 'getWikiPage',
|
|
461
|
+
tags: ['Wiki'],
|
|
462
|
+
responses: ok(ref('WikiPage')),
|
|
463
|
+
}),
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
After (whole controller):
|
|
467
|
+
|
|
468
|
+
```ts
|
|
469
|
+
import { defineController, describe } from 'expediate';
|
|
470
|
+
import { featureEnabled } from './guards';
|
|
471
|
+
|
|
472
|
+
export const wikiController = (wikiService: WikiService) => defineController({
|
|
473
|
+
prefix: '/p/:proj/wiki',
|
|
474
|
+
tags: ['Wiki'],
|
|
475
|
+
permission: 'wiki.read', // auth.check() runs for every route
|
|
476
|
+
guards: [featureEnabled('wikiEnabled')], // proj already loaded by auth.check()
|
|
477
|
+
|
|
478
|
+
GET: {
|
|
479
|
+
'/tree': describe(
|
|
480
|
+
(ctx) => wikiService.listPages(ctx.params.proj),
|
|
481
|
+
{ summary: 'List all wiki pages', operationId: 'getWikiTree',
|
|
482
|
+
responses: ok(arrayOf('WikiTreeItem')) }),
|
|
483
|
+
|
|
484
|
+
'/pages/:slug': describe(
|
|
485
|
+
(ctx) => wikiService.readPage(ctx.params.proj, ctx.params.slug),
|
|
486
|
+
{ summary: 'Read a wiki page', operationId: 'getWikiPage',
|
|
487
|
+
responses: ok(ref('WikiPage')) }),
|
|
488
|
+
|
|
489
|
+
'/pages/:slug/revisions': describe(
|
|
490
|
+
(ctx) => wikiService.pageRevisions(ctx.params.proj, ctx.params.slug),
|
|
491
|
+
{ summary: 'List revision history', operationId: 'getWikiPageRevisions',
|
|
492
|
+
responses: ok(arrayOf('WtRevision')) }),
|
|
493
|
+
|
|
494
|
+
'/pages/:slug/revisions/:version': describe(
|
|
495
|
+
(ctx) => wikiService.pageAtRevision(ctx.params.proj, ctx.params.slug, ctx.params.version),
|
|
496
|
+
{ summary: 'Read a page at a revision', operationId: 'getWikiPageAtRevision',
|
|
497
|
+
responses: ok({ type: 'string' }) }),
|
|
498
|
+
},
|
|
499
|
+
|
|
500
|
+
POST: {
|
|
501
|
+
'/search': describe(
|
|
502
|
+
(ctx, body: WikiSearchRequest) =>
|
|
503
|
+
wikiService.searchPages(ctx.params.proj, body.query ?? '', body.caseSensitive ?? false),
|
|
504
|
+
{ summary: 'Full-text wiki search', operationId: 'searchWiki',
|
|
505
|
+
permission: 'wiki.read',
|
|
506
|
+
requestBody: jsonBody(ref('WikiSearchRequest')),
|
|
507
|
+
responses: ok(arrayOf('WikiSearchResult')) }),
|
|
508
|
+
},
|
|
509
|
+
|
|
510
|
+
PUT: {
|
|
511
|
+
'/pages/:slug': describe(
|
|
512
|
+
(ctx, body) => wikiService.writePage(ctx.params.proj, ctx.params.slug, body, ctx.user!.sub),
|
|
513
|
+
{ summary: 'Create or update a wiki page', operationId: 'putWikiPage',
|
|
514
|
+
permission: 'wiki.write', // route-level override
|
|
515
|
+
requestBody: jsonBody(ref('WikiPageUpdate')),
|
|
516
|
+
responses: ok(ref('WikiPage')) }),
|
|
517
|
+
},
|
|
518
|
+
});
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
Each handler is now one expression. The six-line preamble exists exactly
|
|
522
|
+
once — in the auth binding and one guard.
|
|
523
|
+
|
|
524
|
+
### `issues.controller.ts` (excerpt)
|
|
525
|
+
|
|
526
|
+
The mutation routes keep their domain logic (edit service, notifications,
|
|
527
|
+
audit logs are app concerns) but lose the auth/loading preamble:
|
|
528
|
+
|
|
529
|
+
```ts
|
|
530
|
+
export const issuesController = (deps: Deps) => defineController({
|
|
531
|
+
prefix: '/p/:proj/issues',
|
|
532
|
+
tags: ['Issues'],
|
|
533
|
+
|
|
534
|
+
POST: {
|
|
535
|
+
'/:uid/comments': describe(async (ctx, body) => {
|
|
536
|
+
const { proj } = ctx.state; // loaded by auth.check()
|
|
537
|
+
const user = await deps.userService.readUser(ctx.user!.sub);
|
|
538
|
+
const edit = await itemEdition<DataIssue>(ctx.params.proj, 'issue', ctx.params.uid,
|
|
539
|
+
!!body.dryRun, (item, ec) => deps.editService.editAddComment(item, body, user, nowISO(), ec));
|
|
540
|
+
if (edit.success && edit.data)
|
|
541
|
+
deps.notificationService.onComment(ctx.params.proj, 'issue',
|
|
542
|
+
edit.data.uid, edit.data.title, user, edit.data.participants);
|
|
543
|
+
return { ...edit, data: edit.data ? deps.mapper.mapDtoIssueFull(edit.data, proj, []) : null };
|
|
544
|
+
}, {
|
|
545
|
+
summary: 'Add a comment to an issue', operationId: 'commentIssue',
|
|
546
|
+
permission: 'issue.comment',
|
|
547
|
+
requestBody: jsonBody(ref('CommentUpdate')),
|
|
548
|
+
responses: ok(editionOf('DtoIssueFull')),
|
|
549
|
+
}),
|
|
550
|
+
},
|
|
551
|
+
});
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
### `index.ts` — composition root
|
|
555
|
+
|
|
556
|
+
```ts
|
|
557
|
+
export function useApiService(deps: Deps, jwt: JwtPlugin): ApiRouter {
|
|
558
|
+
return apiBuilder({
|
|
559
|
+
auth: {
|
|
560
|
+
authenticate: jwt.authenticate,
|
|
561
|
+
check: async (ctx, required) => {
|
|
562
|
+
const proj = await deps.projectService.openProject(ctx.params.proj);
|
|
563
|
+
for (const p of required)
|
|
564
|
+
deps.authService.requirePermission(ctx.user?.sub ?? null, proj, p as Permission);
|
|
565
|
+
ctx.state.proj = proj;
|
|
566
|
+
},
|
|
567
|
+
},
|
|
568
|
+
validate: true,
|
|
569
|
+
schemas: dtoSchemas, // shared by spec + validator
|
|
570
|
+
|
|
571
|
+
controllers: [
|
|
572
|
+
authController(deps),
|
|
573
|
+
projectsController(deps),
|
|
574
|
+
gitController(deps),
|
|
575
|
+
wikiController(deps.wikiService),
|
|
576
|
+
snippetsController(deps),
|
|
577
|
+
issuesController(deps),
|
|
578
|
+
mergeRequestsController(deps),
|
|
579
|
+
pipelinesController(deps),
|
|
580
|
+
releasesController(deps),
|
|
581
|
+
adminController(deps),
|
|
582
|
+
notificationsController(deps),
|
|
583
|
+
],
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
Estimated effect on the monolith: 3,480 lines → ~12 files of 150–400 lines;
|
|
589
|
+
roughly 500 lines of repeated preamble and manual validation deleted
|
|
590
|
+
outright.
|
|
591
|
+
|
|
592
|
+
---
|
|
593
|
+
|
|
594
|
+
## 10. Breaking-changes summary
|
|
595
|
+
|
|
596
|
+
Although breaking changes are allowed, the design lands as a superset:
|
|
597
|
+
|
|
598
|
+
| Change | Impact |
|
|
599
|
+
|---|---|
|
|
600
|
+
| `ApiContext` gains `params`, `state`, generics | additive; `query.route` untouched |
|
|
601
|
+
| `ServiceDefinition` gains `controllers`, `guards`, `auth`, `validate`, `schemas` | additive |
|
|
602
|
+
| `OperationMeta` gains `guards`, `permission` | additive |
|
|
603
|
+
| Duplicate `(verb, path)` now **throws** at build time | behavioural break (was silent shadowing) — intended |
|
|
604
|
+
| `SpecOptions.components.schemas` superseded by `ServiceDefinition.schemas` | soft break; spec options form kept as fallback |
|
|
605
|
+
| Default `ApiContext.user` type `unknown` instead of `any` | compile-time break for sloppy accesses — intended |
|
|
606
|
+
|
|
607
|
+
The v1 single-definition style keeps working; v2 is the same API with seams.
|
|
608
|
+
|
|
609
|
+
## 11. Implementation order
|
|
610
|
+
|
|
611
|
+
1. **`ctx.params` + generics** — trivial, isolated (`apis.ts`).
|
|
612
|
+
2. **Controller merge + conflict detection** — `apis.ts` route collection
|
|
613
|
+
refactor; `openapi.ts` reads the merged table. Tests: prefix joining,
|
|
614
|
+
global sort across controllers, duplicate-route throw, merged spec tags.
|
|
615
|
+
3. **Guards + `ctx.state`** — pipeline change in `buildRoutes`. Tests:
|
|
616
|
+
ordering (api → controller → route), state merging, ApiError from guard.
|
|
617
|
+
4. **Auth binding** — default `check` against `ctx.user.permissions`
|
|
618
|
+
(jwt-auth semantics), `authenticate` auto-registration, OpenAPI
|
|
619
|
+
`security` emission. Tests with `createJwtPlugin` end-to-end.
|
|
620
|
+
5. **Validation** — internal JSON-Schema subset validator + `$ref`
|
|
621
|
+
resolution. Tests: each keyword, `fieldErrors` shape, opt-out.
|
|
622
|
+
|
|
623
|
+
Per the development checklist: full JSDoc on all new exports, re-export
|
|
624
|
+
from `src/index.ts`, tests per step in `tests/apis.test.ts` (and a new
|
|
625
|
+
`tests/api-guards.test.ts` if the file grows unwieldy), README and
|
|
626
|
+
`docs/api-builder.md` updates after implementation.
|
|
627
|
+
|
|
628
|
+
## 12. Others implementation decisions
|
|
629
|
+
|
|
630
|
+
1. **Guard typing of `ctx.state`** — full inference (accumulating state
|
|
631
|
+
types across the guard chain) is possible with tuple types but costly in
|
|
632
|
+
complexity. `TState` must declared explicitly by the client via
|
|
633
|
+
the `ApiContext<TUser, TState>` annotation; guards stay loosely typed.
|
|
634
|
+
2. **Controllers don't need their own `scope`?**
|
|
635
|
+
instance lifecycle stays API-wide. A controller needing its own state is
|
|
636
|
+
a sign it should be a separately mounted API.
|
|
637
|
+
3. **Response validation** — useful in dev, but doubles validator surface.
|
|
638
|
+
Implemented as opt-in via the `apiBuilder(service, { responses: true })`
|
|
639
|
+
options argument (default off); a return that violates the route's declared
|
|
640
|
+
`200` schema yields `500` (server-contract breach).
|
|
641
|
+
4. **`x-required-permissions` naming** — vendor extension vs. OpenAPI
|
|
642
|
+
`security` scopes on a custom scheme. Extension is simpler and honest
|
|
643
|
+
(these are not OAuth scopes); the name of the header can be overwrite
|
|
644
|
+
with option provided to the apiBuilder.
|