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
@@ -1,485 +0,0 @@
1
- /* Copyright 2021 Fabien Bavent
2
- *
3
- * Permission is hereby granted, free of charge, to any person obtaining a
4
- * copy of this software and associated documentation files (the "Software"),
5
- * to deal in the Software without restriction, including without limitation
6
- * the rights to use, copy, modify, merge, publish, distribute, sublicense,
7
- * and/or sell copies of the Software, and to permit persons to whom the
8
- * Software is furnished to do so, subject to the following conditions:
9
- *
10
- * The above copyright notice and this permission notice shall be included
11
- * in all copies or substantial portions of the Software.
12
- *
13
- * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
14
- * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
16
- * THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
18
- * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
19
- * DEALINGS IN THE SOFTWARE.
20
- */
21
- 'use strict';
22
- Object.defineProperty(exports, "__esModule", { value: true });
23
- exports.DESCRIBE_META = void 0;
24
- exports.serializeSpec = serializeSpec;
25
- exports.describe = describe;
26
- exports.openApiSpec = openApiSpec;
27
- // ── YAML serialiser (zero-dependency, block-style) ────────────────────────────
28
- /**
29
- * YAML reserved keywords that must be quoted as scalars so that YAML parsers
30
- * do not interpret them as the corresponding typed values.
31
- */
32
- const YAML_KW = new Set([
33
- 'true', 'false', 'yes', 'no', 'on', 'off', 'null', '~',
34
- ]);
35
- /**
36
- * Pattern for "safe" plain scalars: they can be represented without quoting.
37
- * A scalar is safe when it:
38
- * - contains only letters, digits, `_`, `-`, `.` or `/`
39
- * - starts with a letter, `_`, `/`, or `$`
40
- * - is not a YAML reserved keyword
41
- * - does not look like a number
42
- */
43
- const SAFE_SCALAR = /^[a-zA-Z$_/][a-zA-Z0-9$_\-./]*$/;
44
- /** Pattern matching integer and floating-point number strings. */
45
- const LOOKS_LIKE_NUMBER = /^[-+]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$|^0x[0-9a-fA-F]+$|^0o[0-7]+$/;
46
- /**
47
- * Serialise a string as a YAML scalar value or key.
48
- *
49
- * The string is returned unquoted when it is a valid YAML plain scalar —
50
- * i.e. it cannot be misinterpreted as another type and does not contain
51
- * characters that would confuse a YAML parser.
52
- *
53
- * Otherwise the value is wrapped in double quotes with control characters,
54
- * backslashes, and `"` escaped so the output is always valid YAML 1.2.
55
- *
56
- * @param s - The string to serialise.
57
- */
58
- function yamlString(s) {
59
- if (s === '')
60
- return '""';
61
- const needsQuote =
62
- // Would be misinterpreted as a YAML typed value.
63
- YAML_KW.has(s.toLowerCase()) ||
64
- LOOKS_LIKE_NUMBER.test(s) ||
65
- // Starts with a YAML indicator that has special meaning at the start of a
66
- // plain scalar (block context).
67
- /^[-?:,[\]{}#&*!|>'"%@`~]/.test(s) ||
68
- // Inline sequences that break block-mapping parsing.
69
- s.includes(': ') ||
70
- s.endsWith(':') ||
71
- s.includes(' #') ||
72
- // Leading / trailing whitespace.
73
- s !== s.trim() ||
74
- // Flow indicator characters anywhere — present in path patterns such as
75
- // `/items/{id}` and must be quoted to avoid flow-collection ambiguity.
76
- /[{}\[\]]/.test(s) ||
77
- // Control characters.
78
- /[\x00-\x1f\x7f]/.test(s);
79
- if (!needsQuote)
80
- return s;
81
- // Double-quoted style: always valid, handles all edge cases.
82
- return '"' +
83
- s
84
- .replace(/\\/g, '\\\\')
85
- .replace(/"/g, '\\"')
86
- .replace(/\n/g, '\\n')
87
- .replace(/\r/g, '\\r')
88
- .replace(/\t/g, '\\t')
89
- .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, c => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`)
90
- + '"';
91
- }
92
- /**
93
- * Serialise a mapping key as a YAML scalar.
94
- *
95
- * Uses the same quoting rules as {@link yamlString}. Numbers as keys
96
- * (e.g. HTTP status codes `200`, `500`) are always quoted so that YAML
97
- * parsers do not interpret them as integer keys.
98
- */
99
- function yamlKey(key) {
100
- return yamlString(key);
101
- }
102
- /**
103
- * Recursively serialise a JSON-compatible value into an array of YAML block
104
- * notation lines.
105
- *
106
- * Each returned line has **no leading indentation** — it is the caller's
107
- * responsibility to prefix nested lines with `' '` (two spaces) when
108
- * embedding them inside a mapping value or sequence item.
109
- *
110
- * @param value - The value to serialise.
111
- */
112
- function toYamlLines(value) {
113
- // ── Scalars ─────────────────────────────────────────────────────────────────
114
- if (value === null || value === undefined)
115
- return ['null'];
116
- if (typeof value === 'boolean')
117
- return [String(value)];
118
- if (typeof value === 'number')
119
- return [isFinite(value) ? String(value) : '.inf'];
120
- if (typeof value === 'string')
121
- return [yamlString(value)];
122
- // ── Sequences ────────────────────────────────────────────────────────────────
123
- if (Array.isArray(value)) {
124
- if (value.length === 0)
125
- return ['[]'];
126
- const lines = [];
127
- for (const item of value) {
128
- const itemLines = toYamlLines(item);
129
- // First line of the item goes on the same line as the dash.
130
- lines.push(`- ${itemLines[0]}`);
131
- // Subsequent lines are indented by two spaces (relative to the `-`).
132
- for (const l of itemLines.slice(1))
133
- lines.push(` ${l}`);
134
- }
135
- return lines;
136
- }
137
- // ── Mappings ─────────────────────────────────────────────────────────────────
138
- if (typeof value === 'object') {
139
- const obj = value;
140
- const entries = Object.entries(obj).filter(([, v]) => v !== undefined);
141
- if (entries.length === 0)
142
- return ['{}'];
143
- const lines = [];
144
- for (const [key, val] of entries) {
145
- const k = yamlKey(key);
146
- const valLines = toYamlLines(val);
147
- // Inline only when the value is a scalar, null, or an empty collection
148
- // (`{}` / `[]`). Non-empty objects and arrays — even when they happen
149
- // to serialise to a single line (e.g. `$ref: "#/..."`) — must go block
150
- // style; inlining them produces ambiguous YAML like `key: $ref: "..."`.
151
- const isComplex = typeof val === 'object' && val !== null;
152
- const isEmptyCollection = valLines.length === 1 &&
153
- (valLines[0] === '{}' || valLines[0] === '[]');
154
- if (!isComplex || isEmptyCollection) {
155
- // Scalar or empty collection: fits on the same line as the key.
156
- lines.push(`${k}: ${valLines[0]}`);
157
- }
158
- else {
159
- // Block style: key on its own line, value indented below.
160
- lines.push(`${k}:`);
161
- for (const l of valLines)
162
- lines.push(` ${l}`);
163
- }
164
- }
165
- return lines;
166
- }
167
- return [String(value)];
168
- }
169
- /**
170
- * Serialise an {@link OpenApiDocument} to either JSON or YAML.
171
- *
172
- * - `'json'` — pretty-printed with 2-space indentation.
173
- * - `'yaml'` — block-style YAML 1.2, produced by a zero-dependency serialiser
174
- * built into expediate.
175
- *
176
- * @param doc - The document to serialise.
177
- * @param format - Output format (`'json'` by default).
178
- */
179
- function serializeSpec(doc, format = 'json') {
180
- if (format === 'yaml')
181
- return toYamlLines(doc).join('\n') + '\n';
182
- return JSON.stringify(doc, null, 2);
183
- }
184
- // ---------------------------------------------------------------------------
185
- // Symbol for attaching metadata to handler functions
186
- // ---------------------------------------------------------------------------
187
- /**
188
- * Unique symbol used as a non-enumerable property key on handler functions
189
- * that have been annotated via {@link describe}.
190
- *
191
- * Using a `unique symbol` (rather than a plain string key) prevents accidental
192
- * collisions with user-defined properties on handler objects.
193
- */
194
- exports.DESCRIBE_META = Symbol('expediate.openapi.meta');
195
- // ---------------------------------------------------------------------------
196
- // Public API
197
- // ---------------------------------------------------------------------------
198
- /**
199
- * Annotate a service method handler with OpenAPI operation metadata.
200
- *
201
- * The metadata is attached to the returned function via a non-enumerable
202
- * property keyed by {@link DESCRIBE_META}. The returned function is otherwise
203
- * identical to `handler` — it can be used directly in a `ServiceDefinition`
204
- * route map.
205
- *
206
- * ```ts
207
- * GET: {
208
- * '/items/:id': describe(
209
- * function (this: TodoService, p) {
210
- * return this.findOrFail(p.id);
211
- * },
212
- * {
213
- * summary: 'Get a single item by ID',
214
- * tags: ['items'],
215
- * parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
216
- * responses: {
217
- * '200': { description: 'The item', content: { 'application/json': { schema: { $ref: '#/components/schemas/Item' } } } },
218
- * '404': { description: 'Not found' },
219
- * },
220
- * },
221
- * ),
222
- * }
223
- * ```
224
- *
225
- * @param handler - The service method to annotate.
226
- * @param meta - OpenAPI operation metadata.
227
- * @returns The same handler function, with metadata attached.
228
- */
229
- function describe(handler, meta) {
230
- // Wrap the handler so we have a fresh function object to attach metadata to,
231
- // avoiding unexpected mutations of functions shared across route maps.
232
- const described = function (params, body) {
233
- return handler.apply(this, [params, body]);
234
- };
235
- // Attach metadata as a non-enumerable property so it is invisible to
236
- // Object.keys() / JSON.stringify() and does not pollute the function's
237
- // "own" enumerable surface.
238
- Object.defineProperty(described, exports.DESCRIBE_META, {
239
- value: meta,
240
- enumerable: false,
241
- configurable: true,
242
- writable: false,
243
- });
244
- return described;
245
- }
246
- // ---------------------------------------------------------------------------
247
- // Internal path-translation helpers
248
- // ---------------------------------------------------------------------------
249
- /**
250
- * Convert an Express-style path pattern to an OpenAPI path pattern.
251
- *
252
- * - `:param` segments become `{param}`.
253
- * - An optional `basePath` prefix is prepended (with duplicate-slash guards).
254
- *
255
- * @example
256
- * ```ts
257
- * toOpenApiPath('/items/:id', '/api/v1') // → '/api/v1/items/{id}'
258
- * toOpenApiPath('/items', '') // → '/items'
259
- * ```
260
- */
261
- function toOpenApiPath(pattern, basePath) {
262
- const converted = pattern.replace(/:([A-Za-z_][A-Za-z0-9_]*)/g, '{$1}');
263
- if (!basePath)
264
- return converted;
265
- const base = basePath.replace(/\/+$/, '');
266
- const path = converted.replace(/^\/+/, '/');
267
- return base + (path.startsWith('/') ? path : `/${path}`);
268
- }
269
- /**
270
- * Extract named path parameters from an Express-style pattern as
271
- * {@link ParameterObject} entries with `in: 'path'` and `required: true`.
272
- *
273
- * Parameters already listed in `annotated` (by name) are skipped to avoid
274
- * duplicates when the caller has provided explicit metadata for them.
275
- *
276
- * @param pattern - Express-style route pattern (e.g. `/items/:id`).
277
- * @param annotated - Explicit parameters provided by the caller (may be empty).
278
- */
279
- function extractPathParams(pattern, annotated) {
280
- const annotatedNames = new Set(annotated.map(p => p.name));
281
- const params = [];
282
- for (const match of pattern.matchAll(/:([A-Za-z_][A-Za-z0-9_]*)/g)) {
283
- const name = match[1];
284
- if (!annotatedNames.has(name)) {
285
- params.push({
286
- name,
287
- in: 'path',
288
- required: true,
289
- schema: { type: 'string' },
290
- });
291
- }
292
- }
293
- return params;
294
- }
295
- /**
296
- * Build an `operationId` string from an HTTP verb and a path pattern.
297
- *
298
- * The algorithm:
299
- * 1. Lowercase the verb (e.g. `'GET'` → `'get'`).
300
- * 2. Split the pattern on `/` and `:` (and `{` / `}` for pre-translated paths).
301
- * 3. CamelCase each non-empty segment (first-letter uppercase).
302
- * 4. Prefix each path-param segment with `'By'`.
303
- * 5. Join everything into a single camelCase string.
304
- *
305
- * @example
306
- * ```ts
307
- * buildOperationId('GET', '/items') // → 'getItems'
308
- * buildOperationId('GET', '/items/:id') // → 'getItemsById'
309
- * buildOperationId('DELETE', '/a/:b/:c') // → 'deleteAByBByC'
310
- * ```
311
- */
312
- function buildOperationId(verb, pattern) {
313
- const parts = pattern.split(/[/:{} ]+/).filter(Boolean);
314
- let result = verb.toLowerCase();
315
- for (const part of parts) {
316
- const isParam = pattern.includes(`:${part}`) || pattern.includes(`{${part}}`);
317
- const pascal = part.charAt(0).toUpperCase() + part.slice(1);
318
- result += isParam ? `By${pascal}` : pascal;
319
- }
320
- return result;
321
- }
322
- /**
323
- * Build the default responses for an operation when none are provided by the
324
- * caller.
325
- *
326
- * - `POST` → `201 No Content` (successful write with no response body).
327
- * - All other verbs → `200 OK` with a generic JSON response.
328
- * - `500` → always added as a reference to the built-in `ApiError` component.
329
- */
330
- function buildDefaultResponses(verb) {
331
- const ok = verb === 'POST'
332
- ? { description: 'Created' }
333
- : { description: 'OK', content: { 'application/json': {} } };
334
- return {
335
- [verb === 'POST' ? '201' : '200']: ok,
336
- '500': { $ref: '#/components/responses/ApiError' },
337
- };
338
- }
339
- /**
340
- * Convert caller-provided `responses` metadata into the OpenAPI responses
341
- * map, injecting the built-in `ApiError` reference for `500` unless the
342
- * caller has explicitly provided one.
343
- */
344
- function buildAnnotatedResponses(responses) {
345
- const result = { ...responses };
346
- if (!result['500']) {
347
- result['500'] = { $ref: '#/components/responses/ApiError' };
348
- }
349
- return result;
350
- }
351
- // ---------------------------------------------------------------------------
352
- // Core spec generator
353
- // ---------------------------------------------------------------------------
354
- /** The five HTTP verbs supported by `apiBuilder`. */
355
- const VERBS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
356
- /**
357
- * Generate an OpenAPI 3.1.0 document from a {@link ServiceDefinition}.
358
- *
359
- * Route handlers that have been annotated with {@link describe} contribute
360
- * rich operation metadata (summary, description, parameters, requestBody,
361
- * responses). Unannotated handlers receive sensible defaults automatically.
362
- *
363
- * The generated document always includes:
364
- * - An `ApiError` schema (shape: `{ status?, message?, data? }`) in
365
- * `components.schemas`.
366
- * - An `ApiError` response (`500` reference) in `components.responses`.
367
- *
368
- * Caller-supplied `opts.schemas` and service-level `openapi.schemas` are
369
- * deep-merged on top of the built-in components (caller schemas take
370
- * precedence over service schemas, which take precedence over built-ins).
371
- *
372
- * @param service - The service definition to document.
373
- * @param opts - Top-level spec options (title, version, basePath, …).
374
- * @returns A fully-formed OpenAPI 3.1.0 document object.
375
- *
376
- * @example
377
- * ```ts
378
- * const spec = openApiSpec(todoDefinition, {
379
- * title: 'Todo API',
380
- * version: '1.0.0',
381
- * basePath: '/api',
382
- * });
383
- *
384
- * app.get('/openapi.json', (_req, res) => {
385
- * res.json(spec);
386
- * });
387
- * ```
388
- */
389
- function openApiSpec(service, opts) {
390
- const basePath = opts.basePath ?? '';
391
- const svcMeta = service.openapi;
392
- const defaultTag = svcMeta?.tag;
393
- // ── Components ──────────────────────────────────────────────────────────────
394
- const builtinSchemas = {
395
- ApiError: {
396
- type: 'object',
397
- properties: {
398
- status: { type: 'integer', description: 'HTTP status code' },
399
- message: { type: 'string', description: 'Human-readable error message' },
400
- data: { description: 'Structured error payload (overrides message when present)' },
401
- },
402
- },
403
- };
404
- const builtinResponses = {
405
- ApiError: {
406
- description: 'API error response',
407
- content: {
408
- 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } },
409
- },
410
- },
411
- };
412
- // Merge schemas: built-ins ← service-level ← caller-level (last wins).
413
- const schemas = {
414
- ...builtinSchemas,
415
- ...svcMeta?.schemas,
416
- ...opts.schemas,
417
- };
418
- const responses = {
419
- ...builtinResponses,
420
- ...svcMeta?.responses,
421
- };
422
- // ── Tags ─────────────────────────────────────────────────────────────────────
423
- const tags = [];
424
- if (defaultTag) {
425
- tags.push({ name: defaultTag, description: svcMeta?.tagDescription });
426
- }
427
- // ── Paths ────────────────────────────────────────────────────────────────────
428
- const paths = {};
429
- for (const verb of VERBS) {
430
- const routeMap = service[verb];
431
- if (!routeMap)
432
- continue;
433
- for (const [pattern, handler] of Object.entries(routeMap)) {
434
- const openApiPath = toOpenApiPath(pattern, basePath);
435
- if (!paths[openApiPath])
436
- paths[openApiPath] = {};
437
- // Retrieve attached metadata (if any).
438
- const meta = handler[exports.DESCRIBE_META];
439
- // ── Parameters ─────────────────────────────────────────────────────────
440
- const annotatedParams = meta?.parameters ?? [];
441
- const inferredParams = extractPathParams(pattern, annotatedParams);
442
- const parameters = [...annotatedParams, ...inferredParams];
443
- // ── Responses ──────────────────────────────────────────────────────────
444
- const operationResponses = meta?.responses
445
- ? buildAnnotatedResponses(meta.responses)
446
- : buildDefaultResponses(verb);
447
- // ── Operation object ───────────────────────────────────────────────────
448
- const operation = {
449
- operationId: meta?.operationId ?? buildOperationId(verb, pattern),
450
- ...(meta?.summary && { summary: meta.summary }),
451
- ...(meta?.description && { description: meta.description }),
452
- ...(meta?.deprecated && { deprecated: true }),
453
- ...(parameters.length > 0 && { parameters }),
454
- ...(meta?.requestBody && { requestBody: meta.requestBody }),
455
- responses: operationResponses,
456
- };
457
- // Apply default tag when the operation has no explicit tags.
458
- const opTags = meta?.tags ?? (defaultTag ? [defaultTag] : undefined);
459
- if (opTags)
460
- operation['tags'] = opTags;
461
- // Carry through any vendor extensions (x-* keys).
462
- if (meta) {
463
- for (const [k, v] of Object.entries(meta)) {
464
- if (k.startsWith('x-'))
465
- operation[k] = v;
466
- }
467
- }
468
- paths[openApiPath][verb.toLowerCase()] = operation;
469
- }
470
- }
471
- // ── Assemble document ────────────────────────────────────────────────────────
472
- const doc = {
473
- openapi: '3.1.0',
474
- info: {
475
- title: opts.title,
476
- version: opts.version,
477
- ...(opts.description && { description: opts.description }),
478
- },
479
- ...(opts.servers && { servers: opts.servers }),
480
- ...(tags.length > 0 && { tags }),
481
- paths,
482
- components: { schemas, responses },
483
- };
484
- return doc;
485
- }