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.
Files changed (73) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/CONTRIBUTING.md +150 -0
  3. package/README.md +278 -779
  4. package/dist/apis.d.ts +372 -12
  5. package/dist/apis.d.ts.map +1 -1
  6. package/dist/apis.js +483 -65
  7. package/dist/apis.js.map +1 -1
  8. package/dist/cjs/index.js +2290 -807
  9. package/dist/git.d.ts +1 -1
  10. package/dist/git.d.ts.map +1 -1
  11. package/dist/git.js +5 -5
  12. package/dist/git.js.map +1 -1
  13. package/dist/http-objects.d.ts +26 -0
  14. package/dist/http-objects.d.ts.map +1 -0
  15. package/dist/http-objects.js +588 -0
  16. package/dist/http-objects.js.map +1 -0
  17. package/dist/index.d.ts +6 -5
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +2 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/jwt-auth.d.ts +11 -0
  22. package/dist/jwt-auth.d.ts.map +1 -1
  23. package/dist/jwt-auth.js +9 -9
  24. package/dist/jwt-auth.js.map +1 -1
  25. package/dist/middleware.js +2 -2
  26. package/dist/middleware.js.map +1 -1
  27. package/dist/mimetypes.json +882 -1
  28. package/dist/misc.d.ts +161 -25
  29. package/dist/misc.d.ts.map +1 -1
  30. package/dist/misc.js +228 -80
  31. package/dist/misc.js.map +1 -1
  32. package/dist/openapi.d.ts +156 -13
  33. package/dist/openapi.d.ts.map +1 -1
  34. package/dist/openapi.js +214 -71
  35. package/dist/openapi.js.map +1 -1
  36. package/dist/router-types.d.ts +760 -0
  37. package/dist/router-types.d.ts.map +1 -0
  38. package/dist/router-types.js +23 -0
  39. package/dist/router-types.js.map +1 -0
  40. package/dist/router.d.ts +7 -530
  41. package/dist/router.d.ts.map +1 -1
  42. package/dist/router.js +128 -375
  43. package/dist/router.js.map +1 -1
  44. package/dist/static.d.ts +2 -2
  45. package/dist/static.d.ts.map +1 -1
  46. package/dist/static.js +77 -22
  47. package/dist/static.js.map +1 -1
  48. package/docs/THREAT_MODEL.md +52 -0
  49. package/docs/api-builder-v2-design.md +644 -0
  50. package/docs/api-builder-v3-design.md +397 -0
  51. package/docs/api-builder.md +454 -0
  52. package/docs/benchmark.md +27 -0
  53. package/docs/body-parsing.md +223 -0
  54. package/docs/errors.md +359 -0
  55. package/docs/expediate.png +0 -0
  56. package/docs/git.md +139 -0
  57. package/docs/jwt-auth.md +251 -0
  58. package/docs/logo.svg +12 -0
  59. package/docs/middleware.md +264 -0
  60. package/docs/openapi.md +180 -0
  61. package/docs/router.md +356 -0
  62. package/docs/static.md +128 -0
  63. package/docs/wiki.json +123 -0
  64. package/package.json +30 -8
  65. package/dist/cjs/apis.js +0 -327
  66. package/dist/cjs/git.js +0 -293
  67. package/dist/cjs/jwt-auth.js +0 -532
  68. package/dist/cjs/middleware.js +0 -511
  69. package/dist/cjs/mimetypes.json +0 -1
  70. package/dist/cjs/misc.js +0 -787
  71. package/dist/cjs/openapi.js +0 -485
  72. package/dist/cjs/router.js +0 -898
  73. 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['singleton'];
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
- if (!building[key]) {
102
- building[key] = buildModule(service, key).then(instance => {
103
- modules[key] = instance;
104
- delete building[key];
105
- return instance;
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 all route handlers from a route map for a given HTTP method.
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
- * 3. Invokes the service method with `(ctx, body)`.
209
- * 4. Sends the return value as JSON (or 201 if falsy).
210
- * 5. Catches thrown / rejected {@link ApiError} objects and translates them
211
- * into the appropriate HTTP error response.
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 routeMap - Map of path patterns to service methods (`undefined` = skip).
214
- * @param register - Registers a handler on the router for the current HTTP method.
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(routeMap, register) {
217
- if (!routeMap)
218
- return;
219
- // Sort routes by decreasing specificity so that more precise patterns
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: req.queries?.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
- const ret = method.apply(instance, [ctx, body]);
248
- if (ret instanceof Promise) {
249
- return ret
250
- .then(val => {
251
- if (val !== undefined && val !== null && val !== false && val !== 0 && val !== '')
252
- sendJson(res, val);
253
- else
254
- res.status(201).end();
255
- })
256
- .catch(err => {
257
- // console.error(err)
258
- sendError(res, err);
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
- if (ret !== undefined && ret !== null && ret !== false && ret !== 0 && ret !== '')
262
- sendJson(res, ret);
263
- else
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
- // console.error(err)
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(service.GET, (path, h) => api.get(path, h));
276
- buildRoutes(service.POST, (path, h) => api.post(path, h));
277
- buildRoutes(service.PUT, (path, h) => api.put(path, h));
278
- buildRoutes(service.DELETE, (path, h) => api.delete(path, h));
279
- buildRoutes(service.PATCH, (path, h) => api.patch(path, h));
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['singleton'] = instance;
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
- if (!cached)
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
  };