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.
- package/CHANGELOG.md +138 -0
- package/CONTRIBUTING.md +150 -0
- package/LICENSE +16 -16
- package/README.md +330 -444
- package/dist/apis.d.ts +504 -27
- package/dist/apis.d.ts.map +1 -1
- package/dist/apis.js +618 -107
- package/dist/apis.js.map +1 -1
- package/dist/cjs/index.js +4066 -0
- package/dist/cjs/package.json +1 -0
- package/dist/git.d.ts +72 -9
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +129 -74
- package/dist/git.js.map +1 -1
- package/dist/http-objects.d.ts +26 -0
- package/dist/http-objects.d.ts.map +1 -0
- package/dist/http-objects.js +588 -0
- package/dist/http-objects.js.map +1 -0
- package/dist/index.d.ts +18 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -24
- package/dist/index.js.map +1 -1
- package/dist/jwt-auth.d.ts +158 -57
- package/dist/jwt-auth.d.ts.map +1 -1
- package/dist/jwt-auth.js +447 -207
- package/dist/jwt-auth.js.map +1 -1
- package/dist/middleware.d.ts +476 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +647 -0
- package/dist/middleware.js.map +1 -0
- package/dist/mimetypes.json +882 -1
- package/dist/misc.d.ts +268 -25
- package/dist/misc.d.ts.map +1 -1
- package/dist/misc.js +449 -168
- package/dist/misc.js.map +1 -1
- package/dist/openapi.d.ts +433 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/openapi.js +624 -0
- package/dist/openapi.js.map +1 -0
- package/dist/router-types.d.ts +760 -0
- package/dist/router-types.d.ts.map +1 -0
- package/dist/router-types.js +23 -0
- package/dist/router-types.js.map +1 -0
- package/dist/router.d.ts +37 -201
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +502 -244
- package/dist/router.js.map +1 -1
- package/dist/static.d.ts +3 -3
- package/dist/static.d.ts.map +1 -1
- package/dist/static.js +164 -105
- package/dist/static.js.map +1 -1
- package/docs/THREAT_MODEL.md +52 -0
- package/docs/api-builder-v2-design.md +644 -0
- package/docs/api-builder-v3-design.md +397 -0
- package/docs/api-builder.md +454 -0
- package/docs/benchmark.md +27 -0
- package/docs/body-parsing.md +223 -0
- package/docs/errors.md +359 -0
- package/docs/expediate.png +0 -0
- package/docs/git.md +139 -0
- package/docs/jwt-auth.md +251 -0
- package/docs/logo.svg +12 -0
- package/docs/middleware.md +264 -0
- package/docs/openapi.md +180 -0
- package/docs/router.md +356 -0
- package/docs/static.md +128 -0
- package/docs/wiki.json +123 -0
- package/package.json +47 -8
- 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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
36
|
-
*
|
|
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()`** —
|
|
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
|
-
//
|
|
51
|
-
//
|
|
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
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* - **
|
|
76
|
-
*
|
|
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
|
|
79
|
-
* @param modules
|
|
80
|
-
* @param
|
|
81
|
-
*
|
|
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 —
|
|
86
|
-
return modules
|
|
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
|
|
96
|
-
if (
|
|
97
|
-
modules[key]
|
|
98
|
-
|
|
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: `{
|
|
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?.
|
|
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. `
|
|
155
|
-
*
|
|
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 `{
|
|
540
|
+
* - Throwing or rejecting with `{ status, message }` sends the
|
|
166
541
|
* corresponding HTTP error.
|
|
167
|
-
* - Throwing or rejecting with `{
|
|
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 = (
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
*
|
|
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 (
|
|
188
|
-
* 2.
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
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
|
|
194
|
-
* @param register
|
|
621
|
+
* @param verbRoutes - Pre-sorted routes for the verb being registered.
|
|
622
|
+
* @param register - Registers a handler on the router for that verb.
|
|
195
623
|
*/
|
|
196
|
-
function buildRoutes(
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
760
|
+
export default apiBuilder;
|
|
250
761
|
//# sourceMappingURL=apis.js.map
|