ingenium 0.0.1

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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +943 -0
  3. package/dist/index.cjs +7078 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +4262 -0
  6. package/dist/index.d.ts +4262 -0
  7. package/dist/index.js +6963 -0
  8. package/dist/index.js.map +1 -0
  9. package/package.json +47 -0
  10. package/src/api-key/middleware.ts +157 -0
  11. package/src/api-key/types.ts +37 -0
  12. package/src/app/scope.ts +392 -0
  13. package/src/app.ts +1752 -0
  14. package/src/body/limit.ts +21 -0
  15. package/src/body/middleware.ts +30 -0
  16. package/src/body/multipart-types.ts +40 -0
  17. package/src/body/multipart.ts +254 -0
  18. package/src/context/body.ts +324 -0
  19. package/src/context/context.ts +650 -0
  20. package/src/context/cookies.ts +282 -0
  21. package/src/context/pool.ts +32 -0
  22. package/src/cors/middleware.ts +182 -0
  23. package/src/cors/types.ts +79 -0
  24. package/src/cron/parser.ts +311 -0
  25. package/src/cron/registry.ts +49 -0
  26. package/src/cron/scheduler.ts +153 -0
  27. package/src/csrf/middleware.ts +224 -0
  28. package/src/csrf/types.ts +65 -0
  29. package/src/errors.ts +148 -0
  30. package/src/idempotency/middleware.ts +197 -0
  31. package/src/idempotency/store.ts +70 -0
  32. package/src/idempotency/types.ts +87 -0
  33. package/src/index.ts +328 -0
  34. package/src/jobs/queue.ts +306 -0
  35. package/src/jobs/registry.ts +82 -0
  36. package/src/jobs/store-memory.ts +113 -0
  37. package/src/jobs/types.ts +135 -0
  38. package/src/jwt/jwks.ts +143 -0
  39. package/src/jwt/middleware.ts +313 -0
  40. package/src/jwt/types.ts +137 -0
  41. package/src/jwt/verify.ts +370 -0
  42. package/src/middleware/compose.ts +94 -0
  43. package/src/middleware/types.ts +37 -0
  44. package/src/negotiation/accept.ts +159 -0
  45. package/src/negotiation/etag.ts +30 -0
  46. package/src/negotiation/format.ts +88 -0
  47. package/src/negotiation/fresh.ts +89 -0
  48. package/src/negotiation/json-etag.ts +122 -0
  49. package/src/negotiation/negotiate.ts +97 -0
  50. package/src/openapi/describe.ts +79 -0
  51. package/src/openapi/extract-params.ts +62 -0
  52. package/src/openapi/generate.ts +251 -0
  53. package/src/openapi/handler.ts +73 -0
  54. package/src/openapi/types.ts +145 -0
  55. package/src/plugin/decorators.ts +100 -0
  56. package/src/plugin/hooks.ts +114 -0
  57. package/src/plugin/types.ts +189 -0
  58. package/src/problem/middleware.ts +55 -0
  59. package/src/problem/serialize.ts +121 -0
  60. package/src/problem/types.ts +68 -0
  61. package/src/proxy/trust.ts +247 -0
  62. package/src/rate-limit/middleware.ts +72 -0
  63. package/src/rate-limit/store.ts +129 -0
  64. package/src/rate-limit/types.ts +60 -0
  65. package/src/response/reflect.ts +93 -0
  66. package/src/router/router.ts +284 -0
  67. package/src/router/trie.ts +309 -0
  68. package/src/router/types.ts +54 -0
  69. package/src/schema/standard.ts +67 -0
  70. package/src/session/middleware.ts +379 -0
  71. package/src/session/store-memory.ts +79 -0
  72. package/src/session/types.ts +95 -0
  73. package/src/sinatra/filters.ts +129 -0
  74. package/src/sinatra/top-level.ts +151 -0
  75. package/src/sse/keep-alive.ts +52 -0
  76. package/src/sse/sse.ts +115 -0
  77. package/src/static/middleware.ts +254 -0
  78. package/src/static/types.ts +31 -0
  79. package/src/transport/http2-helpers.ts +242 -0
  80. package/src/transport/http2.ts +316 -0
  81. package/src/transport/node.ts +261 -0
  82. package/src/transport/shutdown.ts +86 -0
  83. package/src/transport/types.ts +72 -0
  84. package/src/util/safe-json.ts +66 -0
  85. package/src/ws/index.ts +164 -0
  86. package/src/ws/middleware.ts +178 -0
  87. package/src/ws/types.ts +52 -0
  88. package/src/ws/ws-node-adapter.ts +162 -0
@@ -0,0 +1,88 @@
1
+ /**
2
+ * `formatResponse(ctx, handlers)` — Express's `res.format` for Ingenium.
3
+ *
4
+ * Picks the best handler key against the request `Accept` header, runs it,
5
+ * sets `Content-Type` to the matched key, and writes the result as the
6
+ * response body. If no handler matches and no `default` key is provided,
7
+ * throws a `IngeniumError(406, 'NOT_ACCEPTABLE')`.
8
+ *
9
+ * Handlers may be sync or async — `formatResponse` always awaits.
10
+ */
11
+
12
+ import { Buffer } from 'node:buffer'
13
+ import { selectBest } from './accept.ts'
14
+ import type { NegotiableCtx } from './negotiate.ts'
15
+ import { IngeniumError } from '../errors.ts'
16
+
17
+ /** Minimal context shape required by `formatResponse` — narrower than full `IngeniumContext`. */
18
+ export interface FormattableCtx extends NegotiableCtx {
19
+ set(name: string, value: string | string[]): unknown
20
+ json(body: unknown, status?: number): void
21
+ send(body: Buffer | string, status?: number): void
22
+ }
23
+
24
+ /** Map of `mime → handler`. The reserved key `default` is the no-match fallback. */
25
+ export type FormatHandlers = Record<string, () => unknown | Promise<unknown>>
26
+
27
+ /**
28
+ * Pick the best handler key for `Accept` and run it.
29
+ *
30
+ * - JSON-shaped result objects are written via `ctx.json`.
31
+ * - String / Buffer results are written via `ctx.send` with the matched
32
+ * content-type preserved (instead of `send`'s default text/plain inference).
33
+ * - `default` handler is used when no explicit key matches.
34
+ * - No match + no default → throws `IngeniumError(406, 'NOT_ACCEPTABLE')`.
35
+ */
36
+ export async function formatResponse(
37
+ ctx: FormattableCtx,
38
+ handlers: FormatHandlers,
39
+ ): Promise<void> {
40
+ const keys = Object.keys(handlers).filter((k) => k !== 'default')
41
+ const acceptHeader = (() => {
42
+ const v = ctx.headers['accept']
43
+ return Array.isArray(v) ? v.join(',') : v
44
+ })()
45
+
46
+ let chosenKey: string | false = selectBest(acceptHeader, keys)
47
+
48
+ // No explicit match — fall back to `default`, else 406.
49
+ if (chosenKey === false) {
50
+ if ('default' in handlers) {
51
+ const result = await handlers['default']!()
52
+ writeResult(ctx, result, undefined)
53
+ return
54
+ }
55
+ throw new IngeniumError(
56
+ 406,
57
+ 'NOT_ACCEPTABLE',
58
+ `None of the offered types [${keys.join(', ')}] satisfy Accept: ${acceptHeader ?? '*/*'}`,
59
+ )
60
+ }
61
+
62
+ const handler = handlers[chosenKey]
63
+ if (!handler) {
64
+ // Defensive — shouldn't happen since selectBest only returns offered keys.
65
+ throw new IngeniumError(406, 'NOT_ACCEPTABLE', 'Internal: matched handler missing')
66
+ }
67
+ const result = await handler()
68
+ writeResult(ctx, result, chosenKey)
69
+ }
70
+
71
+ function writeResult(ctx: FormattableCtx, result: unknown, contentType: string | undefined): void {
72
+ if (contentType) ctx.set('content-type', contentType)
73
+ if (result === undefined || result === null) {
74
+ // Treat as empty body — caller handles 204 elsewhere.
75
+ ctx.send('', undefined)
76
+ return
77
+ }
78
+ if (typeof result === 'string') {
79
+ ctx.send(result, undefined)
80
+ return
81
+ }
82
+ if (Buffer.isBuffer(result) || result instanceof Uint8Array) {
83
+ ctx.send(Buffer.isBuffer(result) ? result : Buffer.from(result))
84
+ return
85
+ }
86
+ // Object → JSON.
87
+ ctx.json(result)
88
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * `isFresh(reqHeaders, resHeaders)` — RFC 7232 conditional-request evaluator.
3
+ *
4
+ * Returns `true` when the response can be considered fresh relative to the
5
+ * client's cached copy, i.e. a `304 Not Modified` is appropriate. This is
6
+ * the engine behind `ctx.fresh` / `ctx.stale`.
7
+ *
8
+ * Decision matrix:
9
+ * - `If-None-Match` present → compare against response `ETag`. Wildcard
10
+ * `*` matches any current representation. Strong/weak prefixes are
11
+ * normalized away (per RFC 7232 §2.3.2 weak-comparison rules).
12
+ * - Else if `If-Modified-Since` present → compare against response
13
+ * `Last-Modified` (or fall back to `Date`). Fresh when the resource has
14
+ * not been modified since.
15
+ * - Otherwise → not fresh (no precondition to evaluate).
16
+ *
17
+ * Methods other than GET/HEAD are not handled here — callers should gate
18
+ * on method themselves (Express does the same in `req.fresh`).
19
+ */
20
+
21
+ /** Header bag shape — accepts both incoming-request and stored-response styles. */
22
+ export type HeaderBag = Record<string, string | string[] | undefined>
23
+
24
+ function getHeader(bag: HeaderBag, name: string): string | undefined {
25
+ const lower = name.toLowerCase()
26
+ const v = bag[lower]
27
+ if (v === undefined) {
28
+ // Try original-case key as fallback.
29
+ const alt = bag[name]
30
+ if (alt === undefined) return undefined
31
+ return Array.isArray(alt) ? alt.join(',') : alt
32
+ }
33
+ return Array.isArray(v) ? v.join(',') : v
34
+ }
35
+
36
+ /** Strip a leading `W/` weak prefix and surrounding double-quotes. */
37
+ function normalizeEtag(tag: string): string {
38
+ let t = tag.trim()
39
+ if (t.startsWith('W/') || t.startsWith('w/')) t = t.slice(2)
40
+ if (t.length >= 2 && t.startsWith('"') && t.endsWith('"')) t = t.slice(1, t.length - 1)
41
+ return t
42
+ }
43
+
44
+ /** Split an `If-None-Match` header value into individual ETag tokens. */
45
+ function splitInm(header: string): string[] {
46
+ return header.split(',').map((s) => s.trim()).filter((s) => s.length > 0)
47
+ }
48
+
49
+ /**
50
+ * Returns `true` when the response is fresh w.r.t. the client's preconditions.
51
+ */
52
+ export function isFresh(reqHeaders: HeaderBag, resHeaders: HeaderBag): boolean {
53
+ const ifNoneMatch = getHeader(reqHeaders, 'if-none-match')
54
+ const ifModifiedSince = getHeader(reqHeaders, 'if-modified-since')
55
+
56
+ // No conditional headers → cannot be fresh.
57
+ if (!ifNoneMatch && !ifModifiedSince) return false
58
+
59
+ // Cache-Control: no-cache on the request explicitly disables 304.
60
+ const reqCacheControl = getHeader(reqHeaders, 'cache-control')
61
+ if (reqCacheControl && /(?:^|,)\s*no-cache\s*(?:,|$)/i.test(reqCacheControl)) {
62
+ return false
63
+ }
64
+
65
+ // ───── If-None-Match takes precedence ─────
66
+ if (ifNoneMatch) {
67
+ if (ifNoneMatch.trim() === '*') return true
68
+ const etag = getHeader(resHeaders, 'etag')
69
+ if (!etag) return false
70
+ const target = normalizeEtag(etag)
71
+ for (const candidate of splitInm(ifNoneMatch)) {
72
+ if (normalizeEtag(candidate) === target) return true
73
+ }
74
+ return false
75
+ }
76
+
77
+ // ───── Fallback: If-Modified-Since ─────
78
+ if (ifModifiedSince) {
79
+ const lastModified = getHeader(resHeaders, 'last-modified') ?? getHeader(resHeaders, 'date')
80
+ if (!lastModified) return false
81
+ const sinceMs = Date.parse(ifModifiedSince)
82
+ const lastMs = Date.parse(lastModified)
83
+ if (!Number.isFinite(sinceMs) || !Number.isFinite(lastMs)) return false
84
+ // Fresh when resource hasn't changed since the client's copy.
85
+ return lastMs <= sinceMs
86
+ }
87
+
88
+ return false
89
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * `respondJsonWithEtag(ctx, body, opts)` — JSON response with auto ETag and
3
+ * 304 short-circuit when `If-None-Match` matches.
4
+ *
5
+ * Behavior:
6
+ * 1. Stringify `body` to JSON exactly once.
7
+ * 2. Compute weak ETag (default) over the stringified bytes.
8
+ * 3. If `If-None-Match` (after weak normalization) matches → set 304,
9
+ * clear body, mark written. Skip writing the JSON.
10
+ * 4. Otherwise: set `ETag` + `Content-Type` headers, write the body via
11
+ * the same internal shape `ctx.json` uses, and mark written.
12
+ *
13
+ * Uses the lower-level shape from `IngeniumContext` directly (rather than
14
+ * calling `ctx.json`) so the JSON.stringify result can be reused without
15
+ * a second pass.
16
+ */
17
+
18
+ import type { IncomingHttpHeaders } from 'node:http'
19
+ import { computeEtag } from './etag.ts'
20
+ import type { ResponseBody } from '../context/context.ts'
21
+ import { IngeniumUnserializableError } from '../errors.ts'
22
+
23
+ /**
24
+ * Strict `JSON.stringify` wrapper mirroring the one in `context.ts`. Kept
25
+ * inline rather than shared to avoid a circular import between
26
+ * `negotiation/` and `context/` (and so this helper stays consumable as a
27
+ * standalone with the lightweight `JsonEtagCtx` shape).
28
+ */
29
+ function strictStringifyForEtag(body: unknown): string {
30
+ try {
31
+ return JSON.stringify(body) as string
32
+ } catch (err) {
33
+ const msg = err instanceof Error ? err.message : String(err)
34
+ let reason: string
35
+ if (/circular/i.test(msg)) reason = `circular structure (${msg})`
36
+ else if (/BigInt/i.test(msg)) reason = `BigInt value (${msg})`
37
+ else reason = msg
38
+ try {
39
+ process.emitWarning(
40
+ `IngeniumUnserializableError: ${reason}`,
41
+ { type: 'IngeniumUnserializableError' },
42
+ )
43
+ } catch {
44
+ // emitWarning unavailable — swallow.
45
+ }
46
+ throw new IngeniumUnserializableError(
47
+ `Response body cannot be serialized: ${reason}`,
48
+ err,
49
+ )
50
+ }
51
+ }
52
+
53
+ /** Options for `respondJsonWithEtag`. */
54
+ export interface JsonEtagOptions {
55
+ /** Prefix the ETag with `W/`. Defaults to `true`. */
56
+ weak?: boolean
57
+ /** HTTP status to use for the success path. Defaults to `200`. */
58
+ status?: number
59
+ }
60
+
61
+ /**
62
+ * Minimal context shape required by `respondJsonWithEtag` — keeps the
63
+ * helper testable with a plain stub and avoids a hard import cycle on
64
+ * the full `IngeniumContext` class.
65
+ */
66
+ export interface JsonEtagCtx {
67
+ headers: IncomingHttpHeaders
68
+ _statusCode: number
69
+ _headers: Record<string, string | string[]>
70
+ _body: ResponseBody
71
+ _written: boolean
72
+ }
73
+
74
+ /** Strip `W/` and quotes for weak-comparison equality. */
75
+ function normalizeEtag(tag: string): string {
76
+ let t = tag.trim()
77
+ if (t.startsWith('W/') || t.startsWith('w/')) t = t.slice(2)
78
+ if (t.length >= 2 && t.startsWith('"') && t.endsWith('"')) t = t.slice(1, t.length - 1)
79
+ return t
80
+ }
81
+
82
+ function ifNoneMatchHas(header: string, target: string): boolean {
83
+ if (header.trim() === '*') return true
84
+ const want = normalizeEtag(target)
85
+ for (const part of header.split(',')) {
86
+ const candidate = part.trim()
87
+ if (candidate.length === 0) continue
88
+ if (normalizeEtag(candidate) === want) return true
89
+ }
90
+ return false
91
+ }
92
+
93
+ export function respondJsonWithEtag(
94
+ ctx: JsonEtagCtx,
95
+ body: unknown,
96
+ opts: JsonEtagOptions = {},
97
+ ): void {
98
+ const weak = opts.weak ?? true
99
+ const status = opts.status ?? 200
100
+ const serialized = strictStringifyForEtag(body)
101
+ const etag = computeEtag(serialized, weak)
102
+
103
+ const inm = ctx.headers['if-none-match']
104
+ const inmStr = Array.isArray(inm) ? inm.join(',') : inm
105
+ if (typeof inmStr === 'string' && inmStr.length > 0 && ifNoneMatchHas(inmStr, etag)) {
106
+ // Short-circuit: cache hit.
107
+ ctx._statusCode = 304
108
+ ctx._headers['etag'] = etag
109
+ // 304 must not carry a body.
110
+ ctx._body = { kind: 'none' }
111
+ ctx._written = true
112
+ return
113
+ }
114
+
115
+ ctx._statusCode = status
116
+ ctx._headers['etag'] = etag
117
+ if (!ctx._headers['content-type']) {
118
+ ctx._headers['content-type'] = 'application/json; charset=utf-8'
119
+ }
120
+ ctx._body = { kind: 'string', data: serialized }
121
+ ctx._written = true
122
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Higher-level `accepts*` helpers, parameterized over a context-like object
3
+ * with a `headers` map. Kept context-agnostic so they're trivially testable
4
+ * with a plain `{ headers: {...} }` stub.
5
+ */
6
+
7
+ import type { IncomingHttpHeaders } from 'node:http'
8
+ import { parseAcceptHeader, selectBest, expandShorthand } from './accept.ts'
9
+
10
+ /** Minimal shape we depend on — `IngeniumContext` satisfies it. */
11
+ export interface NegotiableCtx {
12
+ headers: IncomingHttpHeaders
13
+ }
14
+
15
+ function readHeader(ctx: NegotiableCtx, name: string): string | undefined {
16
+ const v = ctx.headers[name]
17
+ if (Array.isArray(v)) return v.join(',')
18
+ return v
19
+ }
20
+
21
+ /**
22
+ * `accepts(ctx)` → list of accepted media types in preference order
23
+ * (after expanding shorthand inputs is a no-op here — it returns the raw
24
+ * mime strings the client sent).
25
+ *
26
+ * `accepts(ctx, ...types)` → best matching offered type, or `false`.
27
+ * Each `type` may be a shorthand (`'json'`, `'html'`) or full mime
28
+ * (`'application/json'`).
29
+ */
30
+ export function accepts(ctx: NegotiableCtx): string[]
31
+ export function accepts(ctx: NegotiableCtx, ...types: string[]): string | false
32
+ export function accepts(ctx: NegotiableCtx, ...types: string[]): string | false | string[] {
33
+ const header = readHeader(ctx, 'accept')
34
+ if (types.length === 0) {
35
+ return parseAcceptHeader(header).map((e) => e.type)
36
+ }
37
+ const best = selectBest(header, types.map(expandShorthand))
38
+ if (best === false) return false
39
+ // Map the canonical match back to the caller's original token (preserves shorthand).
40
+ for (const t of types) {
41
+ if (expandShorthand(t) === best) return t
42
+ }
43
+ return best
44
+ }
45
+
46
+ /**
47
+ * `acceptsCharsets(ctx)` → all charsets in preference order.
48
+ * `acceptsCharsets(ctx, ...charsets)` → best match or `false`.
49
+ */
50
+ export function acceptsCharsets(ctx: NegotiableCtx): string[]
51
+ export function acceptsCharsets(ctx: NegotiableCtx, ...charsets: string[]): string | false
52
+ export function acceptsCharsets(
53
+ ctx: NegotiableCtx,
54
+ ...charsets: string[]
55
+ ): string | false | string[] {
56
+ const header = readHeader(ctx, 'accept-charset')
57
+ if (charsets.length === 0) return parseAcceptHeader(header).map((e) => e.type)
58
+ return selectBest(header, charsets)
59
+ }
60
+
61
+ /**
62
+ * `acceptsLanguages(ctx)` → all languages in preference order.
63
+ * `acceptsLanguages(ctx, ...langs)` → best match or `false`.
64
+ *
65
+ * Language matching is treated like opaque tokens with `*` as wildcard;
66
+ * partial-tag matching (e.g. `en` matching `en-US`) is **not** performed —
67
+ * use exact tags for predictable behavior, mirroring Express's default.
68
+ */
69
+ export function acceptsLanguages(ctx: NegotiableCtx): string[]
70
+ export function acceptsLanguages(ctx: NegotiableCtx, ...langs: string[]): string | false
71
+ export function acceptsLanguages(
72
+ ctx: NegotiableCtx,
73
+ ...langs: string[]
74
+ ): string | false | string[] {
75
+ const header = readHeader(ctx, 'accept-language')
76
+ if (langs.length === 0) return parseAcceptHeader(header).map((e) => e.type)
77
+ return selectBest(header, langs)
78
+ }
79
+
80
+ /**
81
+ * `acceptsEncodings(ctx)` → all encodings in preference order.
82
+ * `acceptsEncodings(ctx, ...encodings)` → best match or `false`.
83
+ *
84
+ * Per RFC 9110 §12.5.4, when `Accept-Encoding` is absent, the server
85
+ * MAY assume the client accepts any encoding — we follow Express and
86
+ * return the first offered.
87
+ */
88
+ export function acceptsEncodings(ctx: NegotiableCtx): string[]
89
+ export function acceptsEncodings(ctx: NegotiableCtx, ...encodings: string[]): string | false
90
+ export function acceptsEncodings(
91
+ ctx: NegotiableCtx,
92
+ ...encodings: string[]
93
+ ): string | false | string[] {
94
+ const header = readHeader(ctx, 'accept-encoding')
95
+ if (encodings.length === 0) return parseAcceptHeader(header).map((e) => e.type)
96
+ return selectBest(header, encodings)
97
+ }
@@ -0,0 +1,79 @@
1
+ import type { HttpMethod } from '../router/types.ts'
2
+ import type {
3
+ Operation,
4
+ Parameter,
5
+ RequestBody,
6
+ Response,
7
+ SecurityRequirement,
8
+ } from './types.ts'
9
+
10
+ /**
11
+ * Per-route metadata supplied via `app.describe('METHOD', '/path', meta)`.
12
+ * Merged into the generated Operation by `generateOpenApi`.
13
+ *
14
+ * Anything you put here ends up on the operation object verbatim, except:
15
+ * - `hidden: true` skips the route entirely (won't appear in the spec).
16
+ * - `parameters` are *appended* to the path-param parameters extracted from
17
+ * the route syntax, so you typically only put `query`, `header`, or
18
+ * `cookie` parameters here.
19
+ */
20
+ export interface RouteDescriptor {
21
+ summary?: string
22
+ description?: string
23
+ operationId?: string
24
+ tags?: string[]
25
+ deprecated?: boolean
26
+ hidden?: boolean
27
+ parameters?: Parameter[]
28
+ requestBody?: RequestBody
29
+ responses?: Record<string | number, Response>
30
+ security?: SecurityRequirement[]
31
+ /** Extension passthrough — anything starting with `x-` is preserved. */
32
+ [extension: `x-${string}`]: unknown
33
+ }
34
+
35
+ /** Stable lookup key used by the descriptor map. */
36
+ export function descriptorKey(method: HttpMethod, path: string): string {
37
+ return `${method} ${path}`
38
+ }
39
+
40
+ /**
41
+ * Merge a `RouteDescriptor` onto a base `Operation` (which already carries
42
+ * the path parameters extracted from the route syntax). Mutates and returns
43
+ * the base operation for caller convenience.
44
+ *
45
+ * Order rules:
46
+ * - `parameters` are concatenated (path params first, descriptor params after).
47
+ * - `responses` map keys are normalized to strings (200 → '200').
48
+ * - Extensions (`x-*`) are copied verbatim.
49
+ */
50
+ export function mergeDescriptor(
51
+ base: Operation,
52
+ desc: RouteDescriptor | undefined,
53
+ ): Operation {
54
+ if (!desc) return base
55
+ if (desc.summary !== undefined) base.summary = desc.summary
56
+ if (desc.description !== undefined) base.description = desc.description
57
+ if (desc.operationId !== undefined) base.operationId = desc.operationId
58
+ if (desc.tags !== undefined) base.tags = [...desc.tags]
59
+ if (desc.deprecated !== undefined) base.deprecated = desc.deprecated
60
+ if (desc.security !== undefined) base.security = desc.security
61
+ if (desc.requestBody !== undefined) base.requestBody = desc.requestBody
62
+ if (desc.parameters && desc.parameters.length > 0) {
63
+ base.parameters = [...(base.parameters ?? []), ...desc.parameters]
64
+ }
65
+ if (desc.responses) {
66
+ const out: Record<string, Response> = {}
67
+ for (const k of Object.keys(desc.responses)) {
68
+ out[String(k)] = desc.responses[k as keyof typeof desc.responses] as Response
69
+ }
70
+ base.responses = out
71
+ }
72
+ // Copy x-* extensions verbatim.
73
+ for (const k of Object.keys(desc)) {
74
+ if (k.startsWith('x-')) {
75
+ ;(base as Record<string, unknown>)[k] = (desc as Record<string, unknown>)[k]
76
+ }
77
+ }
78
+ return base
79
+ }
@@ -0,0 +1,62 @@
1
+ import type { Parameter } from './types.ts'
2
+
3
+ /**
4
+ * Extract OpenAPI `path` parameter descriptors from a Ingenium route
5
+ * pattern. Mirrors the path syntax documented in `API.md`:
6
+ *
7
+ * - `/users/:id` → required string param `id`
8
+ * - `/users/:id?` → optional string param `id`
9
+ * - `/files/*path` → required string param `path` (greedy tail)
10
+ *
11
+ * All extracted params get `schema: { type: 'string' }` since Ingenium
12
+ * preserves URL segments as raw strings; consumers can override the schema
13
+ * via `app.describe()` if they want a tighter type (e.g. integer ids).
14
+ *
15
+ * Pure function: deterministic, no allocations beyond the result array.
16
+ *
17
+ * @example
18
+ * extractPathParams('/users/:id/posts/:slug?')
19
+ * // [
20
+ * // { name: 'id', in: 'path', required: true, schema: { type: 'string' } },
21
+ * // { name: 'slug', in: 'path', required: false, schema: { type: 'string' } },
22
+ * // ]
23
+ */
24
+ export function extractPathParams(path: string): Parameter[] {
25
+ if (!path) return []
26
+ const params: Parameter[] = []
27
+ const segments = path.split('/')
28
+
29
+ for (const seg of segments) {
30
+ if (!seg) continue
31
+ if (seg[0] === ':') {
32
+ // Trim a single trailing `?` to detect optionality.
33
+ const isOptional = seg.endsWith('?')
34
+ const name = isOptional ? seg.slice(1, -1) : seg.slice(1)
35
+ if (!name) continue
36
+ params.push({
37
+ name,
38
+ in: 'path',
39
+ // OpenAPI 3.1: path parameters MUST be required: true. If the route
40
+ // declares an optional segment, the server actually accepts two
41
+ // distinct paths (with and without the segment). For correctness in
42
+ // generated specs, we still emit required: true and surface the
43
+ // optionality via an `x-rift-optional` extension; tools that need it
44
+ // can split the path themselves.
45
+ required: !isOptional,
46
+ schema: { type: 'string' },
47
+ ...(isOptional ? { 'x-rift-optional': true } : {}),
48
+ })
49
+ } else if (seg[0] === '*') {
50
+ const name = seg.slice(1) || 'wildcard'
51
+ params.push({
52
+ name,
53
+ in: 'path',
54
+ required: true,
55
+ schema: { type: 'string' },
56
+ 'x-rift-wildcard': true,
57
+ })
58
+ }
59
+ }
60
+
61
+ return params
62
+ }