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.
- package/LICENSE +21 -0
- package/README.md +943 -0
- package/dist/index.cjs +7078 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +4262 -0
- package/dist/index.d.ts +4262 -0
- package/dist/index.js +6963 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -0
- package/src/api-key/middleware.ts +157 -0
- package/src/api-key/types.ts +37 -0
- package/src/app/scope.ts +392 -0
- package/src/app.ts +1752 -0
- package/src/body/limit.ts +21 -0
- package/src/body/middleware.ts +30 -0
- package/src/body/multipart-types.ts +40 -0
- package/src/body/multipart.ts +254 -0
- package/src/context/body.ts +324 -0
- package/src/context/context.ts +650 -0
- package/src/context/cookies.ts +282 -0
- package/src/context/pool.ts +32 -0
- package/src/cors/middleware.ts +182 -0
- package/src/cors/types.ts +79 -0
- package/src/cron/parser.ts +311 -0
- package/src/cron/registry.ts +49 -0
- package/src/cron/scheduler.ts +153 -0
- package/src/csrf/middleware.ts +224 -0
- package/src/csrf/types.ts +65 -0
- package/src/errors.ts +148 -0
- package/src/idempotency/middleware.ts +197 -0
- package/src/idempotency/store.ts +70 -0
- package/src/idempotency/types.ts +87 -0
- package/src/index.ts +328 -0
- package/src/jobs/queue.ts +306 -0
- package/src/jobs/registry.ts +82 -0
- package/src/jobs/store-memory.ts +113 -0
- package/src/jobs/types.ts +135 -0
- package/src/jwt/jwks.ts +143 -0
- package/src/jwt/middleware.ts +313 -0
- package/src/jwt/types.ts +137 -0
- package/src/jwt/verify.ts +370 -0
- package/src/middleware/compose.ts +94 -0
- package/src/middleware/types.ts +37 -0
- package/src/negotiation/accept.ts +159 -0
- package/src/negotiation/etag.ts +30 -0
- package/src/negotiation/format.ts +88 -0
- package/src/negotiation/fresh.ts +89 -0
- package/src/negotiation/json-etag.ts +122 -0
- package/src/negotiation/negotiate.ts +97 -0
- package/src/openapi/describe.ts +79 -0
- package/src/openapi/extract-params.ts +62 -0
- package/src/openapi/generate.ts +251 -0
- package/src/openapi/handler.ts +73 -0
- package/src/openapi/types.ts +145 -0
- package/src/plugin/decorators.ts +100 -0
- package/src/plugin/hooks.ts +114 -0
- package/src/plugin/types.ts +189 -0
- package/src/problem/middleware.ts +55 -0
- package/src/problem/serialize.ts +121 -0
- package/src/problem/types.ts +68 -0
- package/src/proxy/trust.ts +247 -0
- package/src/rate-limit/middleware.ts +72 -0
- package/src/rate-limit/store.ts +129 -0
- package/src/rate-limit/types.ts +60 -0
- package/src/response/reflect.ts +93 -0
- package/src/router/router.ts +284 -0
- package/src/router/trie.ts +309 -0
- package/src/router/types.ts +54 -0
- package/src/schema/standard.ts +67 -0
- package/src/session/middleware.ts +379 -0
- package/src/session/store-memory.ts +79 -0
- package/src/session/types.ts +95 -0
- package/src/sinatra/filters.ts +129 -0
- package/src/sinatra/top-level.ts +151 -0
- package/src/sse/keep-alive.ts +52 -0
- package/src/sse/sse.ts +115 -0
- package/src/static/middleware.ts +254 -0
- package/src/static/types.ts +31 -0
- package/src/transport/http2-helpers.ts +242 -0
- package/src/transport/http2.ts +316 -0
- package/src/transport/node.ts +261 -0
- package/src/transport/shutdown.ts +86 -0
- package/src/transport/types.ts +72 -0
- package/src/util/safe-json.ts +66 -0
- package/src/ws/index.ts +164 -0
- package/src/ws/middleware.ts +178 -0
- package/src/ws/types.ts +52 -0
- 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
|
+
}
|