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
package/dist/apis.js
CHANGED
|
@@ -20,7 +20,363 @@
|
|
|
20
20
|
*/
|
|
21
21
|
'use strict';
|
|
22
22
|
import createRouter from './router.js';
|
|
23
|
-
import { openApiSpec, serializeSpec } from './openapi.js';
|
|
23
|
+
import { openApiSpec, serializeSpec, DESCRIBE_META } from './openapi.js';
|
|
24
|
+
/**
|
|
25
|
+
* Identity helper for type inference and discoverability when declaring a
|
|
26
|
+
* {@link ControllerDefinition} in its own file.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```ts
|
|
30
|
+
* export const wikiController = defineController({
|
|
31
|
+
* prefix: '/p/:proj/wiki',
|
|
32
|
+
* tags: ['Wiki'],
|
|
33
|
+
* permission: 'wiki.read',
|
|
34
|
+
* GET: { '/tree': (ctx) => listPages(ctx.params.proj) },
|
|
35
|
+
* });
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export function defineController(c) { return c; }
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Route collection (controller merge)
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
/** The five HTTP verbs supported by `apiBuilder`. */
|
|
43
|
+
const VERBS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
|
|
44
|
+
/**
|
|
45
|
+
* Join a controller prefix and a route path into a single normalised pattern.
|
|
46
|
+
*
|
|
47
|
+
* Duplicate slashes are collapsed and a trailing slash is stripped (except
|
|
48
|
+
* for the root path), so `joinPath('/p/:proj/wiki', '/')` → `'/p/:proj/wiki'`.
|
|
49
|
+
*
|
|
50
|
+
* Exported for use by `openapi.ts` (multi-definition spec merging); not part
|
|
51
|
+
* of the public package API.
|
|
52
|
+
*
|
|
53
|
+
* @param prefix - The controller prefix (may be empty).
|
|
54
|
+
* @param path - The route path relative to the prefix.
|
|
55
|
+
*/
|
|
56
|
+
export function joinPath(prefix, path) {
|
|
57
|
+
const joined = `/${prefix}/${path}`.replace(/\/+/g, '/');
|
|
58
|
+
return joined.length > 1 ? joined.replace(/\/$/, '') : joined;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Compute the specificity score of a route pattern.
|
|
62
|
+
*
|
|
63
|
+
* `score = (segment count × 100) − (parameter count × 10)`. Higher scores
|
|
64
|
+
* are registered first so that more precise patterns (more segments, fewer
|
|
65
|
+
* parameters) cannot be shadowed by prefix matches.
|
|
66
|
+
*
|
|
67
|
+
* Exported for use by `openapi.ts` (multi-definition spec merging); not part
|
|
68
|
+
* of the public package API.
|
|
69
|
+
*/
|
|
70
|
+
export function routeScore(path) {
|
|
71
|
+
const segs = path.split('/').filter(s => s.length > 0);
|
|
72
|
+
return segs.length * 100 - segs.filter(s => s.startsWith(':')).length * 10;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Normalise a `permission` declaration (`string | string[]`) to an array,
|
|
76
|
+
* or `undefined` when absent.
|
|
77
|
+
*
|
|
78
|
+
* Exported for use by `openapi.ts` (multi-definition spec merging); not part
|
|
79
|
+
* of the public package API.
|
|
80
|
+
*/
|
|
81
|
+
export function normalizePermission(permission) {
|
|
82
|
+
if (permission === undefined)
|
|
83
|
+
return undefined;
|
|
84
|
+
return Array.isArray(permission) ? permission : [permission];
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Build the merged route table for a service definition.
|
|
88
|
+
*
|
|
89
|
+
* The algorithm (see `docs/api-builder-v2-design.md` §4):
|
|
90
|
+
* 1. Normalises the root-level route maps into an anonymous controller
|
|
91
|
+
* (`prefix: ''`) so v1 single-definition services keep working.
|
|
92
|
+
* 2. Rewrites each controller route to `joinPath(prefix, path)`.
|
|
93
|
+
* 3. Concatenates all routes of all controllers, then sorts them by
|
|
94
|
+
* decreasing specificity **globally** (the score is computed on the
|
|
95
|
+
* joined path, so prefix parameters are accounted for).
|
|
96
|
+
* 4. **Throws** on a duplicate `(verb, joined path)` pair, naming both
|
|
97
|
+
* declaring controllers.
|
|
98
|
+
* 5. Records per-route provenance (tags, guards, permission) consumed by
|
|
99
|
+
* both the request pipeline and `openApiSpec()`.
|
|
100
|
+
*
|
|
101
|
+
* Exported for use by `openapi.ts`; not part of the public package API.
|
|
102
|
+
*
|
|
103
|
+
* @param service - The service definition to collect routes from.
|
|
104
|
+
* @returns The merged, globally sorted route table.
|
|
105
|
+
* @throws Error on duplicate `(verb, path)` declarations.
|
|
106
|
+
*/
|
|
107
|
+
export function collectRoutes(service) {
|
|
108
|
+
// 1. Root route maps form an implicit, anonymous controller.
|
|
109
|
+
const rootController = {
|
|
110
|
+
prefix: '',
|
|
111
|
+
GET: service.GET,
|
|
112
|
+
POST: service.POST,
|
|
113
|
+
PUT: service.PUT,
|
|
114
|
+
DELETE: service.DELETE,
|
|
115
|
+
PATCH: service.PATCH,
|
|
116
|
+
};
|
|
117
|
+
const controllers = [rootController, ...(service.controllers ?? [])];
|
|
118
|
+
/** Human-readable controller name for diagnostics. */
|
|
119
|
+
const nameOf = (c, index) => index === 0 ? '<root>' : (c.tags?.[0] ?? c.prefix ?? `#${index}`);
|
|
120
|
+
const routes = [];
|
|
121
|
+
/** Duplicate detection: `"VERB /joined/path"` → declaring controller name. */
|
|
122
|
+
const seen = new Map();
|
|
123
|
+
controllers.forEach((controller, index) => {
|
|
124
|
+
const controllerName = nameOf(controller, index);
|
|
125
|
+
const prefix = controller.prefix ?? '';
|
|
126
|
+
for (const verb of VERBS) {
|
|
127
|
+
const routeMap = controller[verb];
|
|
128
|
+
if (!routeMap)
|
|
129
|
+
continue;
|
|
130
|
+
for (const [pattern, handler] of Object.entries(routeMap)) {
|
|
131
|
+
const path = joinPath(prefix, pattern);
|
|
132
|
+
// 4. Loud failure on duplicates — silent shadowing is unacceptable
|
|
133
|
+
// with multi-file composition.
|
|
134
|
+
const dupKey = `${verb} ${path}`;
|
|
135
|
+
const declarer = seen.get(dupKey);
|
|
136
|
+
if (declarer !== undefined) {
|
|
137
|
+
throw new Error(`apiBuilder: duplicate route ${verb} ${path}\n` +
|
|
138
|
+
` declared by controllers '${declarer}' and '${controllerName}'`);
|
|
139
|
+
}
|
|
140
|
+
seen.set(dupKey, controllerName);
|
|
141
|
+
const meta = handler[DESCRIBE_META];
|
|
142
|
+
routes.push({
|
|
143
|
+
verb,
|
|
144
|
+
path,
|
|
145
|
+
handler,
|
|
146
|
+
meta,
|
|
147
|
+
tags: meta?.tags ?? controller.tags,
|
|
148
|
+
guards: [
|
|
149
|
+
...(service.guards ?? []),
|
|
150
|
+
...(controller.guards ?? []),
|
|
151
|
+
...(meta?.guards ?? []),
|
|
152
|
+
],
|
|
153
|
+
permission: normalizePermission(meta?.permission ?? controller.permission),
|
|
154
|
+
controller: controllerName,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
// 3. Global specificity sort across all controllers.
|
|
160
|
+
routes.sort((a, b) => routeScore(b.path) - routeScore(a.path) || b.path.localeCompare(a.path));
|
|
161
|
+
return routes;
|
|
162
|
+
}
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Request-body validation (JSON Schema subset)
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
/**
|
|
167
|
+
* Resolve a `$ref` of the form `#/components/schemas/Name` against the
|
|
168
|
+
* service's schema components, following chained references with a small
|
|
169
|
+
* depth guard against accidental reference cycles.
|
|
170
|
+
*
|
|
171
|
+
* Unknown references resolve to the empty schema `{}` (accepts anything),
|
|
172
|
+
* mirroring the permissive behaviour of the spec generator.
|
|
173
|
+
*/
|
|
174
|
+
function resolveRef(schema, components) {
|
|
175
|
+
let current = schema;
|
|
176
|
+
for (let depth = 0; depth < 16 && current.$ref; depth++) {
|
|
177
|
+
const match = /^#\/components\/schemas\/(.+)$/.exec(current.$ref);
|
|
178
|
+
const next = match ? components[match[1]] : undefined;
|
|
179
|
+
if (!next)
|
|
180
|
+
return {};
|
|
181
|
+
current = next;
|
|
182
|
+
}
|
|
183
|
+
return current;
|
|
184
|
+
}
|
|
185
|
+
/** Append a field error, keeping the first message reported for each path. */
|
|
186
|
+
function addError(errors, path, message) {
|
|
187
|
+
const key = path || '$';
|
|
188
|
+
if (!(key in errors))
|
|
189
|
+
errors[key] = message;
|
|
190
|
+
}
|
|
191
|
+
/** Join a parent path and a child key into a dotted field-error path. */
|
|
192
|
+
function childPath(path, key) {
|
|
193
|
+
return path ? `${path}.${key}` : String(key);
|
|
194
|
+
}
|
|
195
|
+
/** Map a runtime value to its JSON Schema type name. */
|
|
196
|
+
function jsonTypeOf(value) {
|
|
197
|
+
if (value === null)
|
|
198
|
+
return 'null';
|
|
199
|
+
if (Array.isArray(value))
|
|
200
|
+
return 'array';
|
|
201
|
+
return typeof value;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Validate a value against a JSON Schema subset, collecting field errors.
|
|
205
|
+
*
|
|
206
|
+
* Supported keywords: `type`, `required`, `properties`, `items`, `enum`,
|
|
207
|
+
* `pattern`, `minLength` / `maxLength`, `minimum` / `maximum`,
|
|
208
|
+
* `additionalProperties`, `allOf` / `anyOf` / `oneOf`, and `$ref` (resolved
|
|
209
|
+
* against `components`, i.e. `ServiceDefinition.schemas`).
|
|
210
|
+
*
|
|
211
|
+
* Field-error paths are dotted (`name`, `address.city`, `tags.0`); errors on
|
|
212
|
+
* the value itself are keyed `'$'`.
|
|
213
|
+
*
|
|
214
|
+
* Exported for testing; not part of the public package API.
|
|
215
|
+
*
|
|
216
|
+
* @param value - The value to validate.
|
|
217
|
+
* @param schema - The schema to validate against.
|
|
218
|
+
* @param components - Reusable schemas for `$ref` resolution.
|
|
219
|
+
* @param path - Current field path (used in recursion; omit at the root).
|
|
220
|
+
* @param errors - Accumulator (used in recursion; omit at the root).
|
|
221
|
+
* @returns A map of field path → first error message (empty when valid).
|
|
222
|
+
*/
|
|
223
|
+
export function validateSchema(value, schema, components = {}, path = '', errors = {}) {
|
|
224
|
+
const s = resolveRef(schema, components);
|
|
225
|
+
// ── Combinators ───────────────────────────────────────────────────────────
|
|
226
|
+
if (s.allOf) {
|
|
227
|
+
for (const sub of s.allOf)
|
|
228
|
+
validateSchema(value, sub, components, path, errors);
|
|
229
|
+
}
|
|
230
|
+
if (s.anyOf) {
|
|
231
|
+
const passes = s.anyOf.some(sub => Object.keys(validateSchema(value, sub, components, path, {})).length === 0);
|
|
232
|
+
if (!passes)
|
|
233
|
+
addError(errors, path, 'does not match any of the expected schemas (anyOf)');
|
|
234
|
+
}
|
|
235
|
+
if (s.oneOf) {
|
|
236
|
+
const matches = s.oneOf.filter(sub => Object.keys(validateSchema(value, sub, components, path, {})).length === 0).length;
|
|
237
|
+
if (matches !== 1)
|
|
238
|
+
addError(errors, path, `must match exactly one schema (oneOf), matched ${matches}`);
|
|
239
|
+
}
|
|
240
|
+
// ── type ─────────────────────────────────────────────────────────────────
|
|
241
|
+
if (s.type !== undefined) {
|
|
242
|
+
const actual = jsonTypeOf(value);
|
|
243
|
+
const ok = s.type === 'integer'
|
|
244
|
+
? actual === 'number' && Number.isInteger(value)
|
|
245
|
+
: actual === s.type;
|
|
246
|
+
if (!ok) {
|
|
247
|
+
addError(errors, path, `must be of type ${s.type}`);
|
|
248
|
+
return errors; // further keyword checks would be meaningless
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
// ── enum ─────────────────────────────────────────────────────────────────
|
|
252
|
+
if (s.enum && !s.enum.some(e => e === value ||
|
|
253
|
+
(typeof e === 'object' && JSON.stringify(e) === JSON.stringify(value)))) {
|
|
254
|
+
addError(errors, path, `must be one of: ${s.enum.map(e => JSON.stringify(e)).join(', ')}`);
|
|
255
|
+
}
|
|
256
|
+
// ── string keywords ──────────────────────────────────────────────────────
|
|
257
|
+
if (typeof value === 'string') {
|
|
258
|
+
if (typeof s.pattern === 'string' && !new RegExp(s.pattern).test(value))
|
|
259
|
+
addError(errors, path, `does not match pattern ${s.pattern}`);
|
|
260
|
+
if (typeof s.minLength === 'number' && value.length < s.minLength)
|
|
261
|
+
addError(errors, path, `must be at least ${s.minLength} characters`);
|
|
262
|
+
if (typeof s.maxLength === 'number' && value.length > s.maxLength)
|
|
263
|
+
addError(errors, path, `must be at most ${s.maxLength} characters`);
|
|
264
|
+
}
|
|
265
|
+
// ── number keywords ──────────────────────────────────────────────────────
|
|
266
|
+
if (typeof value === 'number') {
|
|
267
|
+
if (typeof s.minimum === 'number' && value < s.minimum)
|
|
268
|
+
addError(errors, path, `must be >= ${s.minimum}`);
|
|
269
|
+
if (typeof s.maximum === 'number' && value > s.maximum)
|
|
270
|
+
addError(errors, path, `must be <= ${s.maximum}`);
|
|
271
|
+
}
|
|
272
|
+
// ── array keywords ───────────────────────────────────────────────────────
|
|
273
|
+
if (Array.isArray(value) && s.items) {
|
|
274
|
+
value.forEach((item, i) => validateSchema(item, s.items, components, childPath(path, i), errors));
|
|
275
|
+
}
|
|
276
|
+
// ── object keywords ──────────────────────────────────────────────────────
|
|
277
|
+
if (jsonTypeOf(value) === 'object') {
|
|
278
|
+
const obj = value;
|
|
279
|
+
if (s.required) {
|
|
280
|
+
for (const prop of s.required) {
|
|
281
|
+
if (obj[prop] === undefined)
|
|
282
|
+
addError(errors, childPath(path, prop), 'is required');
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (s.properties) {
|
|
286
|
+
for (const [prop, sub] of Object.entries(s.properties)) {
|
|
287
|
+
if (obj[prop] !== undefined)
|
|
288
|
+
validateSchema(obj[prop], sub, components, childPath(path, prop), errors);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (s.additionalProperties !== undefined && s.additionalProperties !== true) {
|
|
292
|
+
const known = new Set(Object.keys(s.properties ?? {}));
|
|
293
|
+
for (const prop of Object.keys(obj)) {
|
|
294
|
+
if (known.has(prop))
|
|
295
|
+
continue;
|
|
296
|
+
if (s.additionalProperties === false)
|
|
297
|
+
addError(errors, childPath(path, prop), 'unknown property');
|
|
298
|
+
else
|
|
299
|
+
validateSchema(obj[prop], s.additionalProperties, components, childPath(path, prop), errors);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return errors;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Validate a request body against a route's declared `requestBody` schema.
|
|
307
|
+
*
|
|
308
|
+
* - Missing body + `requestBody.required` → `400`.
|
|
309
|
+
* - Missing body, not required → skipped.
|
|
310
|
+
* - Schema violations → `400` with `{ message, fieldErrors }` (see
|
|
311
|
+
* {@link validateSchema} for the `fieldErrors` shape).
|
|
312
|
+
*
|
|
313
|
+
* The `application/json` content entry is preferred; the first declared
|
|
314
|
+
* content entry is used as a fallback.
|
|
315
|
+
*
|
|
316
|
+
* @throws An {@link ApiError} (`status: 400`) on validation failure.
|
|
317
|
+
*/
|
|
318
|
+
function validateRequestBody(requestBody, body, components) {
|
|
319
|
+
const content = requestBody.content?.['application/json']
|
|
320
|
+
?? Object.values(requestBody.content ?? {})[0];
|
|
321
|
+
const schema = content?.schema;
|
|
322
|
+
if (body === undefined || body === null) {
|
|
323
|
+
if (requestBody.required) {
|
|
324
|
+
throw {
|
|
325
|
+
status: 400,
|
|
326
|
+
data: {
|
|
327
|
+
message: 'Request body validation failed',
|
|
328
|
+
fieldErrors: { $: 'request body is required' },
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (!schema)
|
|
335
|
+
return;
|
|
336
|
+
const fieldErrors = validateSchema(body, schema, components);
|
|
337
|
+
if (Object.keys(fieldErrors).length > 0) {
|
|
338
|
+
throw {
|
|
339
|
+
status: 400,
|
|
340
|
+
data: { message: 'Request body validation failed', fieldErrors },
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Validate a handler's return value against the route's declared response
|
|
346
|
+
* schema for `status`.
|
|
347
|
+
*
|
|
348
|
+
* Only acts when the route declares a response for that status with a content
|
|
349
|
+
* schema (the `application/json` entry is preferred, otherwise the first
|
|
350
|
+
* declared content entry). A violation means the server is about to emit a body
|
|
351
|
+
* that breaks its own published contract:
|
|
352
|
+
*
|
|
353
|
+
* - `mode === true` → raise a `500` {@link ApiError} (the off-spec body is not
|
|
354
|
+
* sent).
|
|
355
|
+
* - `mode === 'warn'` → log via `console.warn` and return so the response is
|
|
356
|
+
* sent unchanged.
|
|
357
|
+
*
|
|
358
|
+
* @throws An {@link ApiError} (`status: 500`) when `mode === true` and the
|
|
359
|
+
* value fails validation.
|
|
360
|
+
*/
|
|
361
|
+
function validateResponseBody(responses, status, value, components, mode) {
|
|
362
|
+
const response = responses[String(status)] ?? responses.default;
|
|
363
|
+
const content = response?.content?.['application/json']
|
|
364
|
+
?? Object.values(response?.content ?? {})[0];
|
|
365
|
+
const schema = content?.schema;
|
|
366
|
+
if (!schema)
|
|
367
|
+
return;
|
|
368
|
+
const fieldErrors = validateSchema(value, schema, components);
|
|
369
|
+
if (Object.keys(fieldErrors).length === 0)
|
|
370
|
+
return;
|
|
371
|
+
if (mode === 'warn') {
|
|
372
|
+
console.warn('[apiBuilder] response body validation failed:', fieldErrors);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
throw {
|
|
376
|
+
status: 500,
|
|
377
|
+
data: { message: 'Response body validation failed', fieldErrors },
|
|
378
|
+
};
|
|
379
|
+
}
|
|
24
380
|
// ---------------------------------------------------------------------------
|
|
25
381
|
// Internal helpers
|
|
26
382
|
// ---------------------------------------------------------------------------
|
|
@@ -88,7 +444,7 @@ async function buildModule(service, key) {
|
|
|
88
444
|
async function resolveInstance(service, modules, building, req) {
|
|
89
445
|
if (typeof service.scope !== 'function') {
|
|
90
446
|
// Singleton — routes only run after setup is complete, so this is safe.
|
|
91
|
-
return modules
|
|
447
|
+
return modules.singleton;
|
|
92
448
|
}
|
|
93
449
|
const key = service.scope(req);
|
|
94
450
|
if (key === null) {
|
|
@@ -98,13 +454,11 @@ async function resolveInstance(service, modules, building, req) {
|
|
|
98
454
|
// Keyed — retrieve from resolved cache or initiate (and deduplicate) a build.
|
|
99
455
|
if (modules[key])
|
|
100
456
|
return modules[key];
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
});
|
|
107
|
-
}
|
|
457
|
+
building[key] ??= buildModule(service, key).then(instance => {
|
|
458
|
+
modules[key] = instance;
|
|
459
|
+
delete building[key];
|
|
460
|
+
return instance;
|
|
461
|
+
});
|
|
108
462
|
return building[key];
|
|
109
463
|
}
|
|
110
464
|
// ---------------------------------------------------------------------------
|
|
@@ -131,7 +485,6 @@ function sendJson(res, data) {
|
|
|
131
485
|
* @param err - The caught value.
|
|
132
486
|
*/
|
|
133
487
|
function sendError(res, err) {
|
|
134
|
-
// console.error('Api Err', err)
|
|
135
488
|
const e = err;
|
|
136
489
|
const status = e?.status ?? 500;
|
|
137
490
|
if (e?.data !== undefined) {
|
|
@@ -190,81 +543,147 @@ function sendError(res, err) {
|
|
|
190
543
|
* as a JSON body.
|
|
191
544
|
* - Any other thrown value produces `500 Internal Server Error`.
|
|
192
545
|
*
|
|
546
|
+
* **Validation:**
|
|
547
|
+
* - With no `options`, validation follows the {@link ServiceDefinition.validate}
|
|
548
|
+
* field.
|
|
549
|
+
* - With `options`, request validation defaults **on** (cancel via
|
|
550
|
+
* `{ validateRequests: false }`) and response validation can be enabled with
|
|
551
|
+
* `{ validateResponses: true }` (500 on mismatch) or `{ validateResponses:
|
|
552
|
+
* 'warn' }` (log only). See {@link ApiBuilderOptions}.
|
|
553
|
+
*
|
|
193
554
|
* @param service - The service definition (see {@link ServiceDefinition}).
|
|
555
|
+
* @param options - Optional validation controls (see {@link ApiBuilderOptions}).
|
|
556
|
+
* When provided, it overrides the legacy `service.validate` field.
|
|
194
557
|
* @returns A router instance pre-configured with all declared routes.
|
|
195
558
|
*/
|
|
196
|
-
export function apiBuilder(service) {
|
|
559
|
+
export function apiBuilder(service, options) {
|
|
197
560
|
const api = createRouter();
|
|
198
561
|
/** Resolved instance cache: populated once setup completes for a given key. */
|
|
199
562
|
const modules = {};
|
|
200
563
|
/** In-flight build promises: deduplicates concurrent keyed instance builds. */
|
|
201
564
|
const building = {};
|
|
565
|
+
// Merge controllers and root route maps into the global route table.
|
|
566
|
+
// Throws here — at build time — on duplicate (verb, path) declarations.
|
|
567
|
+
const routes = collectRoutes(service);
|
|
568
|
+
// ── Auth binding ─────────────────────────────────────────────────────────
|
|
569
|
+
// Register the authenticate middleware first so it runs before the 503
|
|
570
|
+
// readiness guard, every guard, and every handler.
|
|
571
|
+
if (service.auth?.authenticate)
|
|
572
|
+
api.use('/', service.auth.authenticate);
|
|
573
|
+
/**
|
|
574
|
+
* Default permission check: mirrors `jwtPlugin.requirePermission` semantics
|
|
575
|
+
* in the `ctx` world — `401` when unauthenticated, `403` when any required
|
|
576
|
+
* permission is missing from `ctx.user.permissions`.
|
|
577
|
+
*/
|
|
578
|
+
const defaultCheck = (ctx, required) => {
|
|
579
|
+
const user = ctx.user;
|
|
580
|
+
if (!user)
|
|
581
|
+
throw { status: 401, message: 'Authentication required' };
|
|
582
|
+
const perms = user.permissions ?? [];
|
|
583
|
+
if (!required.every(p => perms.includes(p))) {
|
|
584
|
+
throw {
|
|
585
|
+
status: 403,
|
|
586
|
+
message: `Insufficient permissions. Required: ${required.join(', ')}`,
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
const check = service.auth?.check ?? defaultCheck;
|
|
591
|
+
// ── Validation configuration ─────────────────────────────────────────────
|
|
592
|
+
// The second argument, when given, is authoritative; otherwise fall back to
|
|
593
|
+
// the `service.validate` field. Both share the ApiBuilderOptions shape:
|
|
594
|
+
// request validation defaults ON for an options object, responses default OFF.
|
|
595
|
+
// A bare `service.validate: true` enables requests only; absent means neither.
|
|
596
|
+
const validation = options ?? service.validate;
|
|
597
|
+
const validateRequests = validation === true ||
|
|
598
|
+
(typeof validation === 'object' && validation.validateRequests !== false);
|
|
599
|
+
const validateResponses = typeof validation === 'object' ? validation.validateResponses ?? false : false;
|
|
600
|
+
/** Schema components shared by the validator and the spec generator. */
|
|
601
|
+
const schemaComponents = {
|
|
602
|
+
...service.openapi?.schemas,
|
|
603
|
+
...service.schemas,
|
|
604
|
+
};
|
|
202
605
|
/**
|
|
203
|
-
* Register
|
|
606
|
+
* Register a set of collected routes for one HTTP verb.
|
|
204
607
|
*
|
|
205
|
-
* Each handler:
|
|
608
|
+
* Each registered handler runs the full pipeline:
|
|
206
609
|
* 1. Resolves the correct service instance (awaiting setup when needed).
|
|
207
|
-
* 2. Builds an {@link ApiContext} from the incoming request
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
610
|
+
* 2. Builds an {@link ApiContext} from the incoming request
|
|
611
|
+
* (`params` aliases `query.route`; `state` starts empty).
|
|
612
|
+
* 3. Runs `auth.check(ctx, required)` when the route declares a `permission`.
|
|
613
|
+
* 4. Validates the request body against the declared schema (when enabled).
|
|
614
|
+
* 5. Runs the guard chain (API → controller → route), shallow-merging any
|
|
615
|
+
* returned objects into `ctx.state`.
|
|
616
|
+
* 6. Invokes the service method with `(ctx, body)`.
|
|
617
|
+
* 7. Sends the return value as JSON (or 201 if falsy).
|
|
618
|
+
* 8. Catches thrown / rejected {@link ApiError} objects from any stage and
|
|
619
|
+
* translates them into the appropriate HTTP error response.
|
|
212
620
|
*
|
|
213
|
-
* @param
|
|
214
|
-
* @param register
|
|
621
|
+
* @param verbRoutes - Pre-sorted routes for the verb being registered.
|
|
622
|
+
* @param register - Registers a handler on the router for that verb.
|
|
215
623
|
*/
|
|
216
|
-
function buildRoutes(
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
// (more segments, fewer parameters) are registered first in the router.
|
|
221
|
-
// Without this, a plain path like '/items' would match '/items/1' as a
|
|
222
|
-
// prefix and steal requests intended for '/items/:id'.
|
|
223
|
-
// Specificity = (segment count * 100) - (parameter count * 10).
|
|
224
|
-
const sortedPaths = Object.keys(routeMap).sort((a, b) => {
|
|
225
|
-
const score = (p) => {
|
|
226
|
-
const segs = p.split('/').filter(s => s.length > 0);
|
|
227
|
-
return segs.length * 100 - segs.filter(s => s.startsWith(':')).length * 10;
|
|
228
|
-
};
|
|
229
|
-
return score(b) - score(a) || b.localeCompare(a);
|
|
230
|
-
});
|
|
231
|
-
for (const path of sortedPaths) {
|
|
232
|
-
const method = routeMap[path];
|
|
233
|
-
register(path, (req, res) => {
|
|
624
|
+
function buildRoutes(verbRoutes, register) {
|
|
625
|
+
for (const route of verbRoutes) {
|
|
626
|
+
register(route.path, (req, res, next) => {
|
|
627
|
+
const routeParams = req.queries?.route ?? {};
|
|
234
628
|
const ctx = {
|
|
235
629
|
query: {
|
|
236
|
-
route:
|
|
630
|
+
route: routeParams,
|
|
237
631
|
url: req.queries?.url ?? {},
|
|
238
632
|
},
|
|
633
|
+
params: routeParams,
|
|
239
634
|
path: req.path,
|
|
240
635
|
user: req.user,
|
|
636
|
+
state: {},
|
|
241
637
|
};
|
|
242
638
|
const body = req.body;
|
|
243
639
|
// Await instance resolution (no-op microtask for singletons; may
|
|
244
640
|
// trigger async buildModule for keyed / ephemeral instances).
|
|
245
641
|
resolveInstance(service, modules, building, req)
|
|
246
|
-
.then(instance => {
|
|
247
|
-
|
|
248
|
-
if (
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
});
|
|
642
|
+
.then(async (instance) => {
|
|
643
|
+
// 3. Declarative authorization (opt-in per route / controller).
|
|
644
|
+
if (route.permission)
|
|
645
|
+
await check(ctx, route.permission);
|
|
646
|
+
// 4. Request-body validation from the declared schema.
|
|
647
|
+
if (validateRequests && route.meta?.requestBody)
|
|
648
|
+
validateRequestBody(route.meta.requestBody, body, schemaComponents);
|
|
649
|
+
// 5. Guard chain — outermost first; returned objects accumulate
|
|
650
|
+
// into ctx.state for downstream guards and the handler.
|
|
651
|
+
for (const guard of route.guards) {
|
|
652
|
+
const produced = await guard(ctx, req);
|
|
653
|
+
if (produced && typeof produced === 'object')
|
|
654
|
+
Object.assign(ctx.state, produced);
|
|
260
655
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
656
|
+
// 6 + 7. Handler invocation and response conventions.
|
|
657
|
+
const val = await route.handler.apply(instance, [ctx, body]);
|
|
658
|
+
if (val !== undefined && val !== null && val !== false && val !== 0 && val !== '') {
|
|
659
|
+
// Optional response-schema validation (server-contract check).
|
|
660
|
+
if (validateResponses && route.meta?.responses)
|
|
661
|
+
validateResponseBody(route.meta.responses, 200, val, schemaComponents, validateResponses);
|
|
662
|
+
sendJson(res, val);
|
|
663
|
+
}
|
|
664
|
+
else {
|
|
264
665
|
res.status(201).end();
|
|
666
|
+
}
|
|
265
667
|
})
|
|
266
668
|
.catch(err => {
|
|
267
|
-
//
|
|
669
|
+
// Optional service-level hook: inspect/log and optionally remap the
|
|
670
|
+
// error before the default ApiError → HTTP translation.
|
|
671
|
+
if (service.onError) {
|
|
672
|
+
let override;
|
|
673
|
+
try {
|
|
674
|
+
override = service.onError(err, ctx, req);
|
|
675
|
+
}
|
|
676
|
+
catch (hookErr) {
|
|
677
|
+
// The hook re-threw → escalate to the surrounding app's error
|
|
678
|
+
// channel (router.error() / onError) instead of answering here.
|
|
679
|
+
next(hookErr);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
if (override !== undefined) {
|
|
683
|
+
sendError(res, override);
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
268
687
|
sendError(res, err);
|
|
269
688
|
});
|
|
270
689
|
});
|
|
@@ -272,11 +691,11 @@ export function apiBuilder(service) {
|
|
|
272
691
|
}
|
|
273
692
|
/** Convenience wrapper to register routes for all five HTTP verbs. */
|
|
274
693
|
function registerAllRoutes() {
|
|
275
|
-
buildRoutes(
|
|
276
|
-
buildRoutes(
|
|
277
|
-
buildRoutes(
|
|
278
|
-
buildRoutes(
|
|
279
|
-
buildRoutes(
|
|
694
|
+
buildRoutes(routes.filter(r => r.verb === 'GET'), (path, h) => api.get(path, h));
|
|
695
|
+
buildRoutes(routes.filter(r => r.verb === 'POST'), (path, h) => api.post(path, h));
|
|
696
|
+
buildRoutes(routes.filter(r => r.verb === 'PUT'), (path, h) => api.put(path, h));
|
|
697
|
+
buildRoutes(routes.filter(r => r.verb === 'DELETE'), (path, h) => api.delete(path, h));
|
|
698
|
+
buildRoutes(routes.filter(r => r.verb === 'PATCH'), (path, h) => api.patch(path, h));
|
|
280
699
|
}
|
|
281
700
|
if (typeof service.scope !== 'function') {
|
|
282
701
|
// ── Singleton ─────────────────────────────────────────────────────────
|
|
@@ -298,7 +717,7 @@ export function apiBuilder(service) {
|
|
|
298
717
|
// time the first HTTP request can be processed.
|
|
299
718
|
buildModule(service, 'singleton')
|
|
300
719
|
.then(instance => {
|
|
301
|
-
modules
|
|
720
|
+
modules.singleton = instance;
|
|
302
721
|
ready = true;
|
|
303
722
|
registerAllRoutes();
|
|
304
723
|
})
|
|
@@ -331,8 +750,7 @@ export function apiBuilder(service) {
|
|
|
331
750
|
? 'application/yaml; charset=utf-8'
|
|
332
751
|
: 'application/json; charset=utf-8';
|
|
333
752
|
return function (_req, res) {
|
|
334
|
-
|
|
335
|
-
cached = serializeSpec(openApiSpec(service, opts), format);
|
|
753
|
+
cached ??= serializeSpec(openApiSpec(service, opts), format);
|
|
336
754
|
res.setHeader('Content-Type', contentType);
|
|
337
755
|
res.end(cached);
|
|
338
756
|
};
|