expediate 1.0.4 → 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 (69) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/CONTRIBUTING.md +150 -0
  3. package/LICENSE +16 -16
  4. package/README.md +330 -444
  5. package/dist/apis.d.ts +504 -27
  6. package/dist/apis.d.ts.map +1 -1
  7. package/dist/apis.js +618 -107
  8. package/dist/apis.js.map +1 -1
  9. package/dist/cjs/index.js +4066 -0
  10. package/dist/cjs/package.json +1 -0
  11. package/dist/git.d.ts +72 -9
  12. package/dist/git.d.ts.map +1 -1
  13. package/dist/git.js +129 -74
  14. package/dist/git.js.map +1 -1
  15. package/dist/http-objects.d.ts +26 -0
  16. package/dist/http-objects.d.ts.map +1 -0
  17. package/dist/http-objects.js +588 -0
  18. package/dist/http-objects.js.map +1 -0
  19. package/dist/index.d.ts +18 -13
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +15 -24
  22. package/dist/index.js.map +1 -1
  23. package/dist/jwt-auth.d.ts +158 -57
  24. package/dist/jwt-auth.d.ts.map +1 -1
  25. package/dist/jwt-auth.js +447 -207
  26. package/dist/jwt-auth.js.map +1 -1
  27. package/dist/middleware.d.ts +476 -0
  28. package/dist/middleware.d.ts.map +1 -0
  29. package/dist/middleware.js +647 -0
  30. package/dist/middleware.js.map +1 -0
  31. package/dist/mimetypes.json +882 -1
  32. package/dist/misc.d.ts +268 -25
  33. package/dist/misc.d.ts.map +1 -1
  34. package/dist/misc.js +449 -168
  35. package/dist/misc.js.map +1 -1
  36. package/dist/openapi.d.ts +433 -0
  37. package/dist/openapi.d.ts.map +1 -0
  38. package/dist/openapi.js +624 -0
  39. package/dist/openapi.js.map +1 -0
  40. package/dist/router-types.d.ts +760 -0
  41. package/dist/router-types.d.ts.map +1 -0
  42. package/dist/router-types.js +23 -0
  43. package/dist/router-types.js.map +1 -0
  44. package/dist/router.d.ts +37 -201
  45. package/dist/router.d.ts.map +1 -1
  46. package/dist/router.js +502 -244
  47. package/dist/router.js.map +1 -1
  48. package/dist/static.d.ts +3 -3
  49. package/dist/static.d.ts.map +1 -1
  50. package/dist/static.js +164 -105
  51. package/dist/static.js.map +1 -1
  52. package/docs/THREAT_MODEL.md +52 -0
  53. package/docs/api-builder-v2-design.md +644 -0
  54. package/docs/api-builder-v3-design.md +397 -0
  55. package/docs/api-builder.md +454 -0
  56. package/docs/benchmark.md +27 -0
  57. package/docs/body-parsing.md +223 -0
  58. package/docs/errors.md +359 -0
  59. package/docs/expediate.png +0 -0
  60. package/docs/git.md +139 -0
  61. package/docs/jwt-auth.md +251 -0
  62. package/docs/logo.svg +12 -0
  63. package/docs/middleware.md +264 -0
  64. package/docs/openapi.md +180 -0
  65. package/docs/router.md +356 -0
  66. package/docs/static.md +128 -0
  67. package/docs/wiki.json +123 -0
  68. package/package.json +47 -8
  69. package/.npmignore +0 -16
package/dist/apis.js CHANGED
@@ -19,40 +19,394 @@
19
19
  * DEALINGS IN THE SOFTWARE.
20
20
  */
21
21
  'use strict';
22
- var __importDefault = (this && this.__importDefault) || function (mod) {
23
- return (mod && mod.__esModule) ? mod : { "default": mod };
24
- };
25
- Object.defineProperty(exports, "__esModule", { value: true });
26
- exports.apiBuilder = apiBuilder;
27
- const router_js_1 = __importDefault(require("./router.js"));
22
+ import createRouter from './router.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
+ }
28
380
  // ---------------------------------------------------------------------------
29
381
  // Internal helpers
30
382
  // ---------------------------------------------------------------------------
31
383
  /**
32
- * Instantiate a new service module for the given scope `key`.
384
+ * Instantiate a new service module for the given scope `key` and await its
385
+ * setup lifecycle hook.
33
386
  *
34
387
  * The lifecycle is:
35
- * 1. **`data(key)`** — create the initial state object (or use `{ $key: key }`
36
- * when `data` is not defined).
388
+ * 1. **`data(key)`** — create the initial data object (`Partial<TInstance>`);
389
+ * methods are mixed in next to complete the shape.
37
390
  * 2. **Mix in methods** — each entry in `service.methods` is copied onto the
38
- * instance as a regular function bound to `this`.
39
- * 3. **`setup()`** — called once on the new instance; may be async.
391
+ * instance as a regular function bound to `this = instance`.
392
+ * 3. **`await setup()`** — if `setup` returns a `Promise`, it is fully
393
+ * awaited before the instance is returned. Any rejection propagates to
394
+ * the caller.
40
395
  *
41
396
  * @param service - The service definition.
42
397
  * @param key - The scope key (`'singleton'`, `null`, or a session ID).
43
- * @returns A fully initialised service instance.
398
+ * @returns A Promise resolving to a fully initialised service instance.
44
399
  */
45
- function buildModule(service, key) {
400
+ async function buildModule(service, key) {
401
+ // `data()` returns Partial<TInstance>; methods are mixed in below to complete
402
+ // the instance shape. The cast is intentional and safe — by the time this
403
+ // function returns the instance IS a full TInstance.
46
404
  const instance = service.data
47
405
  ? service.data(key)
48
406
  : { $key: key };
49
407
  // Mix service methods into the instance, bound to `this = instance`.
50
- // BUG FIX: the original used arrow functions `() => method.apply(module, arguments)`.
51
- // Arrow functions do NOT have their own `arguments` object they inherit it
52
- // from the enclosing `buildModule` scope (which holds `(service, key)`).
53
- // Any arguments forwarded to the method were therefore silently dropped.
54
- // Corrected to a regular function expression that captures its own `arguments`
55
- // via a rest parameter spread.
408
+ // Regular function expressions are used (not arrow functions) so that each
409
+ // method has its own `arguments` object and `this` binding works correctly.
56
410
  if (service.methods) {
57
411
  for (const methodName of Object.keys(service.methods)) {
58
412
  const method = service.methods[methodName];
@@ -61,8 +415,10 @@ function buildModule(service, key) {
61
415
  };
62
416
  }
63
417
  }
418
+ // Await setup so that async initialisation (DB connections, config fetches,
419
+ // etc.) completes before the instance is considered ready.
64
420
  if (service.setup)
65
- service.setup.apply(instance, []);
421
+ await service.setup.apply(instance, []);
66
422
  return instance;
67
423
  }
68
424
  /**
@@ -70,32 +426,40 @@ function buildModule(service, key) {
70
426
  *
71
427
  * Instance lifecycle by scope:
72
428
  * - **Singleton** (`scope` absent): always returns `modules['singleton']`.
73
- * - **Keyed** (`scope` returns a string): look up `modules[key]`; create and
74
- * cache a new instance on first access.
75
- * - **Ephemeral** (`scope` returns `null`): create a fresh instance every time;
76
- * never cached.
429
+ * The singleton is guaranteed to be fully initialised before routes run,
430
+ * so this path is synchronous within the async wrapper.
431
+ * - **Keyed** (`scope` returns a string): look up `modules[key]`; on first
432
+ * access, build and cache the instance (in-flight builds for the same key
433
+ * are deduplicated via `building` to avoid concurrent double-builds).
434
+ * - **Ephemeral** (`scope` returns `null`): create a fresh, uncached instance
435
+ * for every request.
77
436
  *
78
- * @param service - The service definition.
79
- * @param modules - The instance cache (mutated when a new keyed instance is created).
80
- * @param req - The incoming request.
81
- * @returns The service instance to use for this request.
437
+ * @param service - The service definition.
438
+ * @param modules - The resolved-instance cache (mutated on first keyed access).
439
+ * @param building - In-flight build-promise cache; prevents duplicate builds
440
+ * for the same key under concurrent requests.
441
+ * @param req - The incoming request.
442
+ * @returns A Promise resolving to the service instance for this request.
82
443
  */
83
- function resolveInstance(service, modules, req) {
444
+ async function resolveInstance(service, modules, building, req) {
84
445
  if (typeof service.scope !== 'function') {
85
- // Singleton — always the same global instance.
86
- return modules['singleton'];
446
+ // Singleton — routes only run after setup is complete, so this is safe.
447
+ return modules.singleton;
87
448
  }
88
- // BUG FIX: the original called `service.key(req)` which does not exist on
89
- // the service definition. The correct method is `service.scope(req)`.
90
449
  const key = service.scope(req);
91
450
  if (key === null) {
92
451
  // Ephemeral — create a fresh, uncached instance for every request.
93
452
  return buildModule(service, null);
94
453
  }
95
- // Keyed — retrieve from cache or create and store.
96
- if (!modules[key])
97
- modules[key] = buildModule(service, key);
98
- return modules[key];
454
+ // Keyed — retrieve from resolved cache or initiate (and deduplicate) a build.
455
+ if (modules[key])
456
+ return modules[key];
457
+ building[key] ??= buildModule(service, key).then(instance => {
458
+ modules[key] = instance;
459
+ delete building[key];
460
+ return instance;
461
+ });
462
+ return building[key];
99
463
  }
100
464
  // ---------------------------------------------------------------------------
101
465
  // Response helpers
@@ -107,9 +471,6 @@ function resolveInstance(service, modules, req) {
107
471
  * @param data - Any JSON-serialisable value.
108
472
  */
109
473
  function sendJson(res, data) {
110
- // BUG FIX: the original called `res.send(JSON.stringify(val))` without
111
- // setting a `Content-Type` header. Clients had no way to detect that the
112
- // response body was JSON. Corrected by setting the header explicitly.
113
474
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
114
475
  res.send(JSON.stringify(data));
115
476
  }
@@ -117,7 +478,7 @@ function sendJson(res, data) {
117
478
  * Translate a caught error (thrown or rejected by a service method) into an
118
479
  * HTTP error response.
119
480
  *
120
- * Expected shape: `{ httpStatus?, data?, message? }` (see {@link ApiError}).
481
+ * Expected shape: `{ status?, data?, message? }` (see {@link ApiError}).
121
482
  * Any other thrown value is treated as an opaque 500 Internal Server Error.
122
483
  *
123
484
  * @param res - The outgoing response.
@@ -125,7 +486,7 @@ function sendJson(res, data) {
125
486
  */
126
487
  function sendError(res, err) {
127
488
  const e = err;
128
- const status = e?.httpStatus ?? 500;
489
+ const status = e?.status ?? 500;
129
490
  if (e?.data !== undefined) {
130
491
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
131
492
  res.status(status).send(JSON.stringify(e.data));
@@ -148,11 +509,25 @@ function sendError(res, err) {
148
509
  * app.use('/api', apiBuilder(myService));
149
510
  * ```
150
511
  *
512
+ * **Singleton setup lifecycle:**
513
+ * When `service.scope` is absent the service is a singleton. The framework
514
+ * pre-builds the instance eagerly and registers a **503 Service not ready**
515
+ * guard middleware on the router immediately. Route handlers are registered
516
+ * only once `setup()` resolves (or immediately, for sync setup). Any request
517
+ * that arrives before setup completes receives a 503 response.
518
+ *
519
+ * **Keyed / ephemeral setup lifecycle:**
520
+ * Route handlers are registered immediately. For each request,
521
+ * `resolveInstance()` is awaited inside the handler, so `setup()` is always
522
+ * complete before the service method is invoked.
523
+ *
151
524
  * **Route handlers** declared in `service.GET`, `service.POST`, etc. are
152
525
  * called with `this` bound to the service instance. They receive two
153
526
  * arguments:
154
- * 1. `params` merged route + query-string parameters from `req.params`.
155
- * 2. `body` — the parsed request body from `req.body` (requires a
527
+ * 1. `ctx` an {@link ApiContext} object containing `ctx.query.route`
528
+ * (named route parameters), `ctx.query.url` (URL query-string parameters),
529
+ * `ctx.path` (the request path), and `ctx.user` (optional auth data).
530
+ * 2. `body` — the parsed request body from `req.body` (requires a
156
531
  * body-parsing middleware such as `json()` to run first).
157
532
  *
158
533
  * **Return values:**
@@ -162,89 +537,225 @@ function sendError(res, err) {
162
537
  * `Promise` resolving to one → `201 No Content` (useful for mutations).
163
538
  *
164
539
  * **Error handling:**
165
- * - Throwing or rejecting with `{ httpStatus, message }` sends the
540
+ * - Throwing or rejecting with `{ status, message }` sends the
166
541
  * corresponding HTTP error.
167
- * - Throwing or rejecting with `{ httpStatus, data }` sends the `data` object
542
+ * - Throwing or rejecting with `{ status, data }` sends the `data` object
168
543
  * as a JSON body.
169
544
  * - Any other thrown value produces `500 Internal Server Error`.
170
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
+ *
171
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.
172
557
  * @returns A router instance pre-configured with all declared routes.
173
558
  */
174
- function apiBuilder(service) {
175
- const api = (0, router_js_1.default)();
559
+ export function apiBuilder(service, options) {
560
+ const api = createRouter();
561
+ /** Resolved instance cache: populated once setup completes for a given key. */
176
562
  const modules = {};
177
- // Pre-build the singleton instance eagerly so `setup()` runs at startup.
178
- if (typeof service.scope !== 'function')
179
- // BUG FIX: the original called `buildModule(service)` without a key,
180
- // passing `undefined` to `data()` and leaving `$key` as `undefined`.
181
- // Corrected to pass `'singleton'` as the canonical key.
182
- modules['singleton'] = buildModule(service, 'singleton');
563
+ /** In-flight build promises: deduplicates concurrent keyed instance builds. */
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);
183
573
  /**
184
- * Register all route handlers from a route map for a given HTTP method.
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
+ };
605
+ /**
606
+ * Register a set of collected routes for one HTTP verb.
185
607
  *
186
- * Each handler:
187
- * 1. Resolves the correct service instance (singleton / keyed / ephemeral).
188
- * 2. Invokes the service method with `(params, body)`.
189
- * 3. Sends the return value as JSON (or 201 if falsy).
190
- * 4. Catches thrown / rejected {@link ApiError} objects and translates them
191
- * into the appropriate HTTP error response.
608
+ * Each registered handler runs the full pipeline:
609
+ * 1. Resolves the correct service instance (awaiting setup when needed).
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.
192
620
  *
193
- * @param routeMap - Map of path patterns to service methods (`undefined` = skip).
194
- * @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.
195
623
  */
196
- function buildRoutes(routeMap, register) {
197
- if (!routeMap)
198
- return;
199
- // Sort routes by decreasing specificity so that more precise patterns
200
- // (more segments, fewer parameters) are registered first in the router.
201
- // Without this, a plain path like '/items' would match '/items/1' as a
202
- // prefix and steal requests intended for '/items/:id'.
203
- // Specificity = (segment count * 100) - (parameter count * 10).
204
- const sortedPaths = Object.keys(routeMap).sort((a, b) => {
205
- const score = (p) => {
206
- const segs = p.split('/').filter(s => s.length > 0);
207
- return segs.length * 100 - segs.filter(s => s.startsWith(':')).length * 10;
208
- };
209
- return score(b) - score(a) || b.localeCompare(a);
210
- });
211
- for (const path of sortedPaths) {
212
- const method = routeMap[path];
213
- register(path, (req, res) => {
214
- const params = req.params;
624
+ function buildRoutes(verbRoutes, register) {
625
+ for (const route of verbRoutes) {
626
+ register(route.path, (req, res, next) => {
627
+ const routeParams = req.queries?.route ?? {};
628
+ const ctx = {
629
+ query: {
630
+ route: routeParams,
631
+ url: req.queries?.url ?? {},
632
+ },
633
+ params: routeParams,
634
+ path: req.path,
635
+ user: req.user,
636
+ state: {},
637
+ };
215
638
  const body = req.body;
216
- try {
217
- const instance = resolveInstance(service, modules, req);
218
- const ret = method.apply(instance, [params, body]);
219
- if (ret instanceof Promise) {
220
- ret
221
- .then((val) => {
222
- if (val !== undefined && val !== null && val !== false && val !== 0 && val !== '')
223
- sendJson(res, val);
224
- else
225
- res.status(201).end();
226
- })
227
- .catch((err) => sendError(res, err));
639
+ // Await instance resolution (no-op microtask for singletons; may
640
+ // trigger async buildModule for keyed / ephemeral instances).
641
+ resolveInstance(service, modules, building, req)
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);
655
+ }
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);
228
663
  }
229
664
  else {
230
- if (ret !== undefined && ret !== null && ret !== false && ret !== 0 && ret !== '')
231
- sendJson(res, ret);
232
- else
233
- res.status(201).end();
665
+ res.status(201).end();
666
+ }
667
+ })
668
+ .catch(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
+ }
234
686
  }
235
- }
236
- catch (err) {
237
687
  sendError(res, err);
238
- }
688
+ });
239
689
  });
240
690
  }
241
691
  }
242
- buildRoutes(service.GET, (path, h) => api.get(path, h));
243
- buildRoutes(service.POST, (path, h) => api.post(path, h));
244
- buildRoutes(service.PUT, (path, h) => api.put(path, h));
245
- buildRoutes(service.DELETE, (path, h) => api.delete(path, h));
246
- buildRoutes(service.PATCH, (path, h) => api.patch(path, h));
692
+ /** Convenience wrapper to register routes for all five HTTP verbs. */
693
+ function registerAllRoutes() {
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));
699
+ }
700
+ if (typeof service.scope !== 'function') {
701
+ // ── Singleton ─────────────────────────────────────────────────────────
702
+ // Register a "not ready" guard first so that requests arriving while
703
+ // setup is in progress receive 503 rather than 404.
704
+ let ready = false;
705
+ api.use('/', (_req, res, next) => {
706
+ if (!ready) {
707
+ res.statusCode = 503;
708
+ res.end('Service not ready');
709
+ return;
710
+ }
711
+ next();
712
+ });
713
+ // Build the singleton asynchronously, then flip the guard and register
714
+ // routes. Even for synchronous setup() functions, buildModule() is async
715
+ // (it uses await internally), so route registration happens in a microtask
716
+ // that runs before any I/O callbacks — routes are always in place by the
717
+ // time the first HTTP request can be processed.
718
+ buildModule(service, 'singleton')
719
+ .then(instance => {
720
+ modules.singleton = instance;
721
+ ready = true;
722
+ registerAllRoutes();
723
+ })
724
+ .catch(err => {
725
+ // setup() rejected — log the error; the guard permanently returns 503.
726
+ console.error('[apiBuilder] singleton setup() rejected:', err);
727
+ });
728
+ }
729
+ else {
730
+ // ── Keyed / ephemeral ─────────────────────────────────────────────────
731
+ // Register routes immediately. Each handler awaits resolveInstance(),
732
+ // which in turn awaits buildModule() for first-time keyed instances.
733
+ registerAllRoutes();
734
+ }
735
+ // ── OpenAPI introspection ──────────────────────────────────────────────────
736
+ /**
737
+ * Generate an OpenAPI 3.1.0 document from the service definition.
738
+ * Delegates to {@link openApiSpec} from `openapi.ts`.
739
+ */
740
+ api.spec = function (opts) {
741
+ return openApiSpec(service, opts);
742
+ };
743
+ /**
744
+ * Return a middleware handler that serves the OpenAPI spec as JSON or YAML.
745
+ * The spec is generated once and cached on the first call to the returned handler.
746
+ */
747
+ api.specHandler = function (opts, format = 'json') {
748
+ let cached = null;
749
+ const contentType = format === 'yaml'
750
+ ? 'application/yaml; charset=utf-8'
751
+ : 'application/json; charset=utf-8';
752
+ return function (_req, res) {
753
+ cached ??= serializeSpec(openApiSpec(service, opts), format);
754
+ res.setHeader('Content-Type', contentType);
755
+ res.end(cached);
756
+ };
757
+ };
247
758
  return api;
248
759
  }
249
- exports.default = apiBuilder;
760
+ export default apiBuilder;
250
761
  //# sourceMappingURL=apis.js.map