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/openapi.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ServiceMethod, ServiceInstance, ServiceDefinition } from './apis.js';
1
+ import type { ServiceMethod, ServiceInstance, ServiceDefinition, Guard, AuthBinding, ApiBuilderOptions } from './apis.js';
2
2
  /**
3
3
  * A JSON Schema object (subset of draft-07 / OpenAPI 3.1 Schema Object).
4
4
  *
@@ -96,6 +96,21 @@ export interface OperationMeta {
96
96
  responses?: Record<string, ResponseObject>;
97
97
  /** Mark as deprecated in the generated spec. */
98
98
  deprecated?: boolean;
99
+ /**
100
+ * Guards run before this handler, after the API-level and controller-level
101
+ * guards. Ignored by spec generation — this is the natural per-route
102
+ * metadata slot for the request pipeline.
103
+ */
104
+ guards?: Guard[];
105
+ /**
106
+ * Permission(s) required to call this operation.
107
+ *
108
+ * Overrides the controller-level `permission`. When set, the pipeline
109
+ * runs `auth.check(ctx, required)` before the guards, and the generated
110
+ * spec emits `security: [{ bearerAuth: [] }]` plus an
111
+ * `x-required-permissions` vendor extension on the operation.
112
+ */
113
+ permission?: string | string[];
99
114
  /** Additional vendor extensions (keys should start with `x-`). */
100
115
  [key: string]: unknown;
101
116
  }
@@ -125,6 +140,101 @@ export interface OpenApiServiceMeta {
125
140
  */
126
141
  responses?: Record<string, ResponseObject>;
127
142
  }
143
+ /**
144
+ * A route map for spec-only documentation: keys are Express-style path
145
+ * patterns, values are {@link OperationMeta} objects directly. There is no
146
+ * handler to call here — only something to describe.
147
+ */
148
+ export type RouteOpenApi = Record<string, OperationMeta>;
149
+ /**
150
+ * A spec-only counterpart to `ControllerDefinition`: groups documented
151
+ * routes under a shared path prefix, default tags, and a default permission
152
+ * requirement, without any of the request-handling fields (`guards`).
153
+ */
154
+ export interface ControllerOpenApi {
155
+ /** Path prefix prepended to every route in this controller (may contain params). */
156
+ prefix?: string;
157
+ /** Default OpenAPI tags applied to routes that do not declare their own. */
158
+ tags?: string[];
159
+ /** Default permission requirement for every route of this controller. */
160
+ permission?: string | string[];
161
+ /** Documented `GET` operations (paths relative to `prefix`). */
162
+ GET?: RouteOpenApi;
163
+ /** Documented `POST` operations (paths relative to `prefix`). */
164
+ POST?: RouteOpenApi;
165
+ /** Documented `PUT` operations (paths relative to `prefix`). */
166
+ PUT?: RouteOpenApi;
167
+ /** Documented `DELETE` operations (paths relative to `prefix`). */
168
+ DELETE?: RouteOpenApi;
169
+ /** Documented `PATCH` operations (paths relative to `prefix`). */
170
+ PATCH?: RouteOpenApi;
171
+ }
172
+ /**
173
+ * A spec-only counterpart to {@link ServiceDefinition}, for documenting
174
+ * routes that have no `ServiceDefinition` of their own — most notably the
175
+ * JWT plugin's `/auth/login`, `/auth/refresh`, and `/auth/logout` endpoints,
176
+ * which are mounted directly with `app.post(...)` rather than through
177
+ * `apiBuilder`.
178
+ *
179
+ * It carries the same OpenAPI-relevant shape as `ServiceDefinition`
180
+ * (controllers, root route maps, schemas, auth binding, service-level
181
+ * `openapi` metadata) but route map values are {@link OperationMeta} objects
182
+ * instead of handler functions, and there is no instance lifecycle
183
+ * (`scope` / `data` / `setup` / `methods`) to run.
184
+ *
185
+ * `guards` and `validate` are accepted only for structural parity with
186
+ * {@link ServiceDefinition}; spec generation ignores both — there is no
187
+ * request pipeline here to run them against.
188
+ */
189
+ export interface ServiceOpenApi {
190
+ /** Sub-controllers merged into this source, same merge rules as `ServiceDefinition.controllers`. */
191
+ controllers?: ControllerOpenApi[];
192
+ /** Ignored by spec generation; accepted for structural parity with `ServiceDefinition.guards`. */
193
+ guards?: Guard[];
194
+ /** Authentication binding — `scheme` and `permissionsExtension` affect the generated spec. */
195
+ auth?: AuthBinding;
196
+ /** Ignored by spec generation; accepted for structural parity with `ServiceDefinition.validate`. */
197
+ validate?: boolean | ApiBuilderOptions;
198
+ /**
199
+ * Reusable JSON Schema components merged into `components.schemas`
200
+ * (see {@link ServiceDefinition.schemas}).
201
+ */
202
+ schemas?: Record<string, JsonSchema>;
203
+ /** Service-level OpenAPI metadata (default tag, shared schemas/responses). */
204
+ openapi?: OpenApiServiceMeta;
205
+ /** Documented `GET` operations. */
206
+ GET?: RouteOpenApi;
207
+ /** Documented `POST` operations. */
208
+ POST?: RouteOpenApi;
209
+ /** Documented `PUT` operations. */
210
+ PUT?: RouteOpenApi;
211
+ /** Documented `DELETE` operations. */
212
+ DELETE?: RouteOpenApi;
213
+ /** Documented `PATCH` operations. */
214
+ PATCH?: RouteOpenApi;
215
+ }
216
+ /**
217
+ * Anything {@link openApiSpec} can document: a real {@link ServiceDefinition}
218
+ * (the kind built by `apiBuilder`) or a spec-only {@link ServiceOpenApi}
219
+ * describing routes that aren't backed by a service at all.
220
+ *
221
+ * `openApiSpec` accepts a single source or an array of sources, so a real API
222
+ * and hand-documented routes can be merged into one document:
223
+ *
224
+ * ```ts
225
+ * const authDocs: ServiceOpenApi = {
226
+ * openapi: { tag: 'auth' },
227
+ * POST: {
228
+ * '/auth/login': { summary: 'Log in', requestBody: loginBody },
229
+ * '/auth/refresh': { summary: 'Refresh a token', requestBody: refreshBody },
230
+ * '/auth/logout': { summary: 'Log out' },
231
+ * },
232
+ * };
233
+ *
234
+ * const spec = openApiSpec([authDocs, todoService], { title: 'Todo API', version: '1.0.0' });
235
+ * ```
236
+ */
237
+ export type OpenApiSource = ServiceDefinition<any> | ServiceOpenApi;
128
238
  /**
129
239
  * Top-level options passed to {@link openApiSpec}.
130
240
  *
@@ -144,10 +254,10 @@ export interface SpecOptions {
144
254
  */
145
255
  basePath?: string;
146
256
  /** Server list (defaults to `[{ url: '/' }]` when absent). */
147
- servers?: Array<{
257
+ servers?: {
148
258
  url: string;
149
259
  description?: string;
150
- }>;
260
+ }[];
151
261
  /**
152
262
  * Additional schemas merged into `components.schemas` (takes precedence
153
263
  * over service-level `openapi.schemas` of the same name).
@@ -167,18 +277,24 @@ export interface OpenApiDocument {
167
277
  version: string;
168
278
  description?: string;
169
279
  };
170
- servers?: Array<{
280
+ servers?: {
171
281
  url: string;
172
282
  description?: string;
173
- }>;
174
- tags?: Array<{
283
+ }[];
284
+ tags?: {
175
285
  name: string;
176
286
  description?: string;
177
- }>;
287
+ }[];
178
288
  paths: Record<string, Record<string, unknown>>;
179
289
  components: {
180
290
  schemas: Record<string, JsonSchema>;
181
291
  responses: Record<string, ResponseObject>;
292
+ /**
293
+ * Security schemes — emitted when at least one operation declares a
294
+ * `permission` (the scheme comes from `AuthBinding.scheme`, defaulting
295
+ * to HTTP bearer / JWT).
296
+ */
297
+ securitySchemes?: Record<string, Record<string, unknown>>;
182
298
  };
183
299
  }
184
300
  /**
@@ -240,22 +356,35 @@ export declare const DESCRIBE_META: unique symbol;
240
356
  */
241
357
  export declare function describe<TInstance extends ServiceInstance = ServiceInstance>(handler: ServiceMethod<TInstance>, meta: OperationMeta): ServiceMethod<TInstance>;
242
358
  /**
243
- * Generate an OpenAPI 3.1.0 document from a {@link ServiceDefinition}.
359
+ * Generate an OpenAPI 3.1.0 document from one or more {@link OpenApiSource}
360
+ * values — real {@link ServiceDefinition}s, spec-only {@link ServiceOpenApi}
361
+ * descriptions, or a mix of both in a single array.
244
362
  *
245
363
  * Route handlers that have been annotated with {@link describe} contribute
246
364
  * rich operation metadata (summary, description, parameters, requestBody,
247
365
  * responses). Unannotated handlers receive sensible defaults automatically.
366
+ * `ServiceOpenApi` route maps provide that same {@link OperationMeta} shape
367
+ * directly, since there is no handler to annotate.
248
368
  *
249
369
  * The generated document always includes:
250
370
  * - An `ApiError` schema (shape: `{ status?, message?, data? }`) in
251
371
  * `components.schemas`.
252
372
  * - An `ApiError` response (`500` reference) in `components.responses`.
253
373
  *
254
- * Caller-supplied `opts.schemas` and service-level `openapi.schemas` are
255
- * deep-merged on top of the built-in components (caller schemas take
256
- * precedence over service schemas, which take precedence over built-ins).
374
+ * Caller-supplied `opts.schemas` and each source's `openapi.schemas` /
375
+ * `schemas` are merged on top of the built-in components, source by source
376
+ * in array order for a single source this preserves the original
377
+ * precedence exactly: built-ins ← `openapi.schemas` ← `opts.schemas` ←
378
+ * `schemas`.
379
+ *
380
+ * Routes are merged and duplicate-checked **across all sources** (see
381
+ * {@link collectOpenApiRoutes}), so passing several sources still produces
382
+ * ONE document. Each route's default tag and permissions vendor-extension
383
+ * name are resolved from its own originating source; the
384
+ * `components.securitySchemes.bearerAuth` value comes from the first source
385
+ * in array order that declares a custom `auth.scheme`, else the default.
257
386
  *
258
- * @param service - The service definition to document.
387
+ * @param service - The source(s) to document.
259
388
  * @param opts - Top-level spec options (title, version, basePath, …).
260
389
  * @returns A fully-formed OpenAPI 3.1.0 document object.
261
390
  *
@@ -271,8 +400,22 @@ export declare function describe<TInstance extends ServiceInstance = ServiceInst
271
400
  * res.json(spec);
272
401
  * });
273
402
  * ```
403
+ *
404
+ * @example Merging a real service with hand-documented auth routes
405
+ * ```ts
406
+ * const authDocs: ServiceOpenApi = {
407
+ * openapi: { tag: 'auth' },
408
+ * POST: {
409
+ * '/auth/login': { summary: 'Log in' },
410
+ * '/auth/refresh': { summary: 'Refresh a token' },
411
+ * '/auth/logout': { summary: 'Log out' },
412
+ * },
413
+ * };
414
+ *
415
+ * const spec = openApiSpec([authDocs, todoDefinition], { title: 'Todo API', version: '1.0.0' });
416
+ * ```
274
417
  */
275
- export declare function openApiSpec<TInstance extends ServiceInstance = ServiceInstance>(service: ServiceDefinition<TInstance>, opts: SpecOptions): OpenApiDocument;
418
+ export declare function openApiSpec(service: OpenApiSource | OpenApiSource[], opts: SpecOptions): OpenApiDocument;
276
419
  declare module './apis.js' {
277
420
  interface ServiceDefinition<TInstance extends ServiceInstance> {
278
421
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"openapi.d.ts","sourceRoot":"","sources":["../src/openapi.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"openapi.d.ts","sourceRoot":"","sources":["../src/openapi.ts"],"names":[],"mappings":"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"}
package/dist/openapi.js CHANGED
@@ -19,6 +19,7 @@
19
19
  * DEALINGS IN THE SOFTWARE.
20
20
  */
21
21
  'use strict';
22
+ import { joinPath, routeScore, normalizePermission } from './apis.js';
22
23
  // ── YAML serialiser (zero-dependency, block-style) ────────────────────────────
23
24
  /**
24
25
  * YAML reserved keywords that must be quoted as scalars so that YAML parsers
@@ -27,15 +28,6 @@
27
28
  const YAML_KW = new Set([
28
29
  'true', 'false', 'yes', 'no', 'on', 'off', 'null', '~',
29
30
  ]);
30
- /**
31
- * Pattern for "safe" plain scalars: they can be represented without quoting.
32
- * A scalar is safe when it:
33
- * - contains only letters, digits, `_`, `-`, `.` or `/`
34
- * - starts with a letter, `_`, `/`, or `$`
35
- * - is not a YAML reserved keyword
36
- * - does not look like a number
37
- */
38
- const SAFE_SCALAR = /^[a-zA-Z$_/][a-zA-Z0-9$_\-./]*$/;
39
31
  /** Pattern matching integer and floating-point number strings. */
40
32
  const LOOKS_LIKE_NUMBER = /^[-+]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$|^0x[0-9a-fA-F]+$|^0o[0-7]+$/;
41
33
  /**
@@ -68,7 +60,7 @@ function yamlString(s) {
68
60
  s !== s.trim() ||
69
61
  // Flow indicator characters anywhere — present in path patterns such as
70
62
  // `/items/{id}` and must be quoted to avoid flow-collection ambiguity.
71
- /[{}\[\]]/.test(s) ||
63
+ /[{}[\]]/.test(s) ||
72
64
  // Control characters.
73
65
  /[\x00-\x1f\x7f]/.test(s);
74
66
  if (!needsQuote)
@@ -159,6 +151,10 @@ function toYamlLines(value) {
159
151
  }
160
152
  return lines;
161
153
  }
154
+ // Defensive fallback: every JSON value type (null, boolean, number, string,
155
+ // array, object) is handled above, so `value` here is only reachable for
156
+ // bigint/symbol/function — none of which occur in a JSON-derived spec.
157
+ // eslint-disable-next-line @typescript-eslint/no-base-to-string
162
158
  return [String(value)];
163
159
  }
164
160
  /**
@@ -344,27 +340,130 @@ function buildAnnotatedResponses(responses) {
344
340
  return result;
345
341
  }
346
342
  // ---------------------------------------------------------------------------
347
- // Core spec generator
343
+ // Multi-source route collection (spec-generation counterpart to `collectRoutes`)
348
344
  // ---------------------------------------------------------------------------
349
- /** The five HTTP verbs supported by `apiBuilder`. */
345
+ /**
346
+ * The five HTTP verbs `openApiSpec` looks for route maps under.
347
+ *
348
+ * Kept as a private duplicate of `apis.ts`'s internal `VERBS` (only the
349
+ * derived {@link ApiVerb} type is exported from there) — not worth exporting
350
+ * a const for.
351
+ */
350
352
  const VERBS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
351
353
  /**
352
- * Generate an OpenAPI 3.1.0 document from a {@link ServiceDefinition}.
354
+ * Build the merged, globally-sorted route table for one or more
355
+ * {@link OpenApiSource} values — the spec-generation counterpart to
356
+ * `apis.ts`'s `collectRoutes`.
357
+ *
358
+ * Deliberately kept separate from `collectRoutes` so the real request
359
+ * pipeline (`apiBuilder`) is never affected by spec-only concerns: this
360
+ * function only inspects route *shapes* to produce documentation and never
361
+ * invokes a handler. A route value is resolved as `describe()`-attached
362
+ * metadata when it is a function, or used directly as an {@link OperationMeta}
363
+ * object when it is not (the {@link ServiceOpenApi} case).
364
+ *
365
+ * Duplicate `(verb, path)` pairs are detected **across all sources**, not
366
+ * just within one — extending `collectRoutes`'s single-service duplicate
367
+ * check to the merged multi-source document.
368
+ *
369
+ * @throws Error on a duplicate `(verb, path)` pair across any of the sources.
370
+ */
371
+ function collectOpenApiRoutes(sources) {
372
+ const routes = [];
373
+ /** Duplicate detection across ALL sources: `"VERB /joined/path"` → declarer label. */
374
+ const seen = new Map();
375
+ sources.forEach((source, sourceIndex) => {
376
+ const defaultTag = source.openapi?.tag;
377
+ const permissionsExtension = source.auth?.permissionsExtension ?? 'x-required-permissions';
378
+ // Root route maps form an implicit, anonymous controller — same trick as
379
+ // `collectRoutes`'s `rootController`.
380
+ const rootController = {
381
+ prefix: '',
382
+ GET: source.GET,
383
+ POST: source.POST,
384
+ PUT: source.PUT,
385
+ DELETE: source.DELETE,
386
+ PATCH: source.PATCH,
387
+ };
388
+ const controllers = [rootController, ...(source.controllers ?? [])];
389
+ controllers.forEach((controller, controllerIndex) => {
390
+ const label = controllerIndex === 0
391
+ ? `source #${sourceIndex}`
392
+ : (controller.tags?.[0] ?? controller.prefix ?? `source #${sourceIndex} controller #${controllerIndex}`);
393
+ const prefix = controller.prefix ?? '';
394
+ for (const verb of VERBS) {
395
+ const routeMap = controller[verb];
396
+ if (!routeMap)
397
+ continue;
398
+ for (const [pattern, value] of Object.entries(routeMap)) {
399
+ const path = joinPath(prefix, pattern);
400
+ // Loud failure on duplicates, across the whole merged document.
401
+ const dupKey = `${verb} ${path}`;
402
+ const declarer = seen.get(dupKey);
403
+ if (declarer !== undefined) {
404
+ throw new Error(`openApiSpec: duplicate route ${verb} ${path}\n` +
405
+ ` declared by '${declarer}' and '${label}'`);
406
+ }
407
+ seen.set(dupKey, label);
408
+ const meta = typeof value === 'function'
409
+ ? value[DESCRIBE_META]
410
+ : value;
411
+ routes.push({
412
+ verb,
413
+ path,
414
+ meta,
415
+ tags: meta?.tags ?? controller.tags,
416
+ permission: normalizePermission(meta?.permission ?? controller.permission),
417
+ defaultTag,
418
+ permissionsExtension,
419
+ });
420
+ }
421
+ }
422
+ });
423
+ });
424
+ // Global specificity sort across all sources and controllers.
425
+ routes.sort((a, b) => routeScore(b.path) - routeScore(a.path) || b.path.localeCompare(a.path));
426
+ return routes;
427
+ }
428
+ // ---------------------------------------------------------------------------
429
+ // Core spec generator
430
+ // ---------------------------------------------------------------------------
431
+ /** Default OpenAPI security scheme when `AuthBinding.scheme` is absent. */
432
+ const DEFAULT_SECURITY_SCHEME = {
433
+ type: 'http',
434
+ scheme: 'bearer',
435
+ bearerFormat: 'JWT',
436
+ };
437
+ /**
438
+ * Generate an OpenAPI 3.1.0 document from one or more {@link OpenApiSource}
439
+ * values — real {@link ServiceDefinition}s, spec-only {@link ServiceOpenApi}
440
+ * descriptions, or a mix of both in a single array.
353
441
  *
354
442
  * Route handlers that have been annotated with {@link describe} contribute
355
443
  * rich operation metadata (summary, description, parameters, requestBody,
356
444
  * responses). Unannotated handlers receive sensible defaults automatically.
445
+ * `ServiceOpenApi` route maps provide that same {@link OperationMeta} shape
446
+ * directly, since there is no handler to annotate.
357
447
  *
358
448
  * The generated document always includes:
359
449
  * - An `ApiError` schema (shape: `{ status?, message?, data? }`) in
360
450
  * `components.schemas`.
361
451
  * - An `ApiError` response (`500` reference) in `components.responses`.
362
452
  *
363
- * Caller-supplied `opts.schemas` and service-level `openapi.schemas` are
364
- * deep-merged on top of the built-in components (caller schemas take
365
- * precedence over service schemas, which take precedence over built-ins).
453
+ * Caller-supplied `opts.schemas` and each source's `openapi.schemas` /
454
+ * `schemas` are merged on top of the built-in components, source by source
455
+ * in array order for a single source this preserves the original
456
+ * precedence exactly: built-ins ← `openapi.schemas` ← `opts.schemas` ←
457
+ * `schemas`.
458
+ *
459
+ * Routes are merged and duplicate-checked **across all sources** (see
460
+ * {@link collectOpenApiRoutes}), so passing several sources still produces
461
+ * ONE document. Each route's default tag and permissions vendor-extension
462
+ * name are resolved from its own originating source; the
463
+ * `components.securitySchemes.bearerAuth` value comes from the first source
464
+ * in array order that declares a custom `auth.scheme`, else the default.
366
465
  *
367
- * @param service - The service definition to document.
466
+ * @param service - The source(s) to document.
368
467
  * @param opts - Top-level spec options (title, version, basePath, …).
369
468
  * @returns A fully-formed OpenAPI 3.1.0 document object.
370
469
  *
@@ -380,11 +479,24 @@ const VERBS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
380
479
  * res.json(spec);
381
480
  * });
382
481
  * ```
482
+ *
483
+ * @example Merging a real service with hand-documented auth routes
484
+ * ```ts
485
+ * const authDocs: ServiceOpenApi = {
486
+ * openapi: { tag: 'auth' },
487
+ * POST: {
488
+ * '/auth/login': { summary: 'Log in' },
489
+ * '/auth/refresh': { summary: 'Refresh a token' },
490
+ * '/auth/logout': { summary: 'Log out' },
491
+ * },
492
+ * };
493
+ *
494
+ * const spec = openApiSpec([authDocs, todoDefinition], { title: 'Todo API', version: '1.0.0' });
495
+ * ```
383
496
  */
384
497
  export function openApiSpec(service, opts) {
498
+ const sources = Array.isArray(service) ? service : [service];
385
499
  const basePath = opts.basePath ?? '';
386
- const svcMeta = service.openapi;
387
- const defaultTag = svcMeta?.tag;
388
500
  // ── Components ──────────────────────────────────────────────────────────────
389
501
  const builtinSchemas = {
390
502
  ApiError: {
@@ -404,64 +516,86 @@ export function openApiSpec(service, opts) {
404
516
  },
405
517
  },
406
518
  };
407
- // Merge schemas: built-ins ← service-level caller-level (last wins).
408
- const schemas = {
409
- ...builtinSchemas,
410
- ...svcMeta?.schemas,
411
- ...opts.schemas,
412
- };
413
- const responses = {
414
- ...builtinResponses,
415
- ...svcMeta?.responses,
416
- };
519
+ // Merge schemas: built-ins ← each source's openapi.schemas (array order)
520
+ // ← caller-level opts.schemas each source's own `schemas` (array order,
521
+ // last wins). For a single source this is exactly the original order:
522
+ // built-ins, service-level openapi meta, caller-level, service-definition
523
+ // `schemas` — which supersedes `opts.schemas`.
524
+ let schemas = { ...builtinSchemas };
525
+ for (const source of sources)
526
+ schemas = { ...schemas, ...source.openapi?.schemas };
527
+ schemas = { ...schemas, ...opts.schemas };
528
+ for (const source of sources)
529
+ schemas = { ...schemas, ...source.schemas };
530
+ let responses = { ...builtinResponses };
531
+ for (const source of sources)
532
+ responses = { ...responses, ...source.openapi?.responses };
417
533
  // ── Tags ─────────────────────────────────────────────────────────────────────
418
534
  const tags = [];
419
- if (defaultTag) {
420
- tags.push({ name: defaultTag, description: svcMeta?.tagDescription });
535
+ const seenTags = new Set();
536
+ for (const source of sources) {
537
+ const tag = source.openapi?.tag;
538
+ if (tag && !seenTags.has(tag)) {
539
+ seenTags.add(tag);
540
+ tags.push({ name: tag, description: source.openapi?.tagDescription });
541
+ }
421
542
  }
543
+ // ── Security scheme ──────────────────────────────────────────────────────────
544
+ // First source in array order that declares a custom scheme wins; falls
545
+ // back to the default HTTP bearer/JWT scheme.
546
+ const securityScheme = sources.find(s => s.auth?.scheme)?.auth?.scheme ?? DEFAULT_SECURITY_SCHEME;
422
547
  // ── Paths ────────────────────────────────────────────────────────────────────
548
+ // Operates on the merged route table (all sources' root route maps and
549
+ // controllers), so multiple sources still produce ONE document. Controller
550
+ // `tags` fill in `OperationMeta.tags` when a route declares none.
423
551
  const paths = {};
424
- for (const verb of VERBS) {
425
- const routeMap = service[verb];
426
- if (!routeMap)
427
- continue;
428
- for (const [pattern, handler] of Object.entries(routeMap)) {
429
- const openApiPath = toOpenApiPath(pattern, basePath);
430
- if (!paths[openApiPath])
431
- paths[openApiPath] = {};
432
- // Retrieve attached metadata (if any).
433
- const meta = handler[DESCRIBE_META];
434
- // ── Parameters ─────────────────────────────────────────────────────────
435
- const annotatedParams = meta?.parameters ?? [];
436
- const inferredParams = extractPathParams(pattern, annotatedParams);
437
- const parameters = [...annotatedParams, ...inferredParams];
438
- // ── Responses ──────────────────────────────────────────────────────────
439
- const operationResponses = meta?.responses
440
- ? buildAnnotatedResponses(meta.responses)
441
- : buildDefaultResponses(verb);
442
- // ── Operation object ───────────────────────────────────────────────────
443
- const operation = {
444
- operationId: meta?.operationId ?? buildOperationId(verb, pattern),
445
- ...(meta?.summary && { summary: meta.summary }),
446
- ...(meta?.description && { description: meta.description }),
447
- ...(meta?.deprecated && { deprecated: true }),
448
- ...(parameters.length > 0 && { parameters }),
449
- ...(meta?.requestBody && { requestBody: meta.requestBody }),
450
- responses: operationResponses,
451
- };
452
- // Apply default tag when the operation has no explicit tags.
453
- const opTags = meta?.tags ?? (defaultTag ? [defaultTag] : undefined);
454
- if (opTags)
455
- operation['tags'] = opTags;
456
- // Carry through any vendor extensions (x-* keys).
457
- if (meta) {
458
- for (const [k, v] of Object.entries(meta)) {
459
- if (k.startsWith('x-'))
460
- operation[k] = v;
461
- }
552
+ const routes = collectOpenApiRoutes(sources);
553
+ let securedRoutes = false;
554
+ for (const route of routes) {
555
+ const { verb, path: pattern, meta } = route;
556
+ const openApiPath = toOpenApiPath(pattern, basePath);
557
+ if (!paths[openApiPath])
558
+ paths[openApiPath] = {};
559
+ // ── Parameters ─────────────────────────────────────────────────────────
560
+ const annotatedParams = meta?.parameters ?? [];
561
+ const inferredParams = extractPathParams(pattern, annotatedParams);
562
+ const parameters = [...annotatedParams, ...inferredParams];
563
+ // ── Responses ──────────────────────────────────────────────────────────
564
+ const operationResponses = meta?.responses
565
+ ? buildAnnotatedResponses(meta.responses)
566
+ : buildDefaultResponses(verb);
567
+ // ── Operation object ───────────────────────────────────────────────────
568
+ const operation = {
569
+ operationId: meta?.operationId ?? buildOperationId(verb, pattern),
570
+ ...(meta?.summary && { summary: meta.summary }),
571
+ ...(meta?.description && { description: meta.description }),
572
+ ...(meta?.deprecated && { deprecated: true }),
573
+ ...(parameters.length > 0 && { parameters }),
574
+ ...(meta?.requestBody && { requestBody: meta.requestBody }),
575
+ responses: operationResponses,
576
+ };
577
+ // Apply route tags (meta-level, else controller-level), then this route's
578
+ // source default tag when neither is declared.
579
+ const opTags = route.tags ?? (route.defaultTag ? [route.defaultTag] : undefined);
580
+ if (opTags)
581
+ operation.tags = opTags;
582
+ // Carry through any vendor extensions (x-* keys).
583
+ if (meta) {
584
+ for (const [k, v] of Object.entries(meta)) {
585
+ if (k.startsWith('x-'))
586
+ operation[k] = v;
462
587
  }
463
- paths[openApiPath][verb.toLowerCase()] = operation;
464
588
  }
589
+ // ── Security ───────────────────────────────────────────────────────────
590
+ // Routes carrying a permission requirement (route- or controller-level)
591
+ // are marked with the bearerAuth scheme and the vendor extension listing
592
+ // the required permissions.
593
+ if (route.permission) {
594
+ securedRoutes = true;
595
+ operation.security = [{ bearerAuth: [] }];
596
+ operation[route.permissionsExtension] = route.permission;
597
+ }
598
+ paths[openApiPath][verb.toLowerCase()] = operation;
465
599
  }
466
600
  // ── Assemble document ────────────────────────────────────────────────────────
467
601
  const doc = {
@@ -474,7 +608,16 @@ export function openApiSpec(service, opts) {
474
608
  ...(opts.servers && { servers: opts.servers }),
475
609
  ...(tags.length > 0 && { tags }),
476
610
  paths,
477
- components: { schemas, responses },
611
+ components: {
612
+ schemas,
613
+ responses,
614
+ // Emitted once when at least one operation declares a permission.
615
+ ...(securedRoutes && {
616
+ securitySchemes: {
617
+ bearerAuth: securityScheme,
618
+ },
619
+ }),
620
+ },
478
621
  };
479
622
  return doc;
480
623
  }