@nextrush/router 3.0.7 → 4.0.0-beta.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/README.md +250 -492
- package/dist/index.d.ts +227 -178
- package/dist/index.js +1075 -657
- package/dist/index.js.map +1 -1
- package/package.json +7 -6
- package/src/__tests__/allowed-methods.test.ts +144 -0
- package/src/__tests__/audit-fixes.test.ts +59 -0
- package/src/__tests__/canonical-path-security.test.ts +198 -0
- package/src/__tests__/canonicalize-memo.test.ts +108 -0
- package/src/__tests__/dispatch-deasync.test.ts +292 -0
- package/src/__tests__/find-node-differential.test.ts +220 -0
- package/src/__tests__/fixtures/match-golden.json +556 -0
- package/src/__tests__/head-auto-registration.test.ts +197 -0
- package/src/__tests__/helpers/differential-corpus.ts +314 -0
- package/src/__tests__/match-differential.test.ts +46 -0
- package/src/__tests__/match-hotpath-guard.test.ts +71 -0
- package/src/__tests__/match-node-indexed-unpooled.test.ts +83 -0
- package/src/__tests__/match-normalize-fastpath.test.ts +64 -0
- package/src/__tests__/match-prenormalized.test.ts +95 -0
- package/src/__tests__/match-safety.test.ts +223 -0
- package/src/__tests__/match-single-alloc.test.ts +82 -0
- package/src/__tests__/match-walk-pool-safety.test.ts +103 -0
- package/src/__tests__/middleware-pipeline.test.ts +306 -0
- package/src/__tests__/param-decoding.test.ts +78 -0
- package/src/__tests__/public-surface.test.ts +72 -0
- package/src/__tests__/registration-max-depth.test.ts +56 -0
- package/src/__tests__/route-metadata.test.ts +172 -0
- package/src/__tests__/router-audit.test.ts +315 -0
- package/src/__tests__/router-edge-cases.test.ts +2 -7
- package/src/__tests__/router.test.ts +148 -7
- package/src/__tests__/static-map-reset.test.ts +48 -0
- package/src/__tests__/walk-pool-sizing.test.ts +72 -0
- package/src/__tests__/walk-pool-undersized-guard.test.ts +59 -0
- package/src/canonicalize.ts +137 -0
- package/src/composition.ts +79 -0
- package/src/constants.ts +43 -0
- package/src/dispatch.ts +145 -0
- package/src/find-node.ts +125 -0
- package/src/group-router.ts +208 -0
- package/src/index.ts +14 -5
- package/src/match-route.ts +241 -0
- package/src/matching.ts +246 -0
- package/src/middleware-adapter.ts +59 -0
- package/src/redirect.ts +97 -0
- package/src/registration.ts +343 -0
- package/src/route-metadata.ts +68 -0
- package/src/router.ts +219 -872
- package/src/segment-trie.ts +227 -0
- package/src/state.ts +53 -0
- package/src/walk-pool.ts +208 -0
- package/src/radix-tree.ts +0 -184
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/router - Route Registration
|
|
3
|
+
*
|
|
4
|
+
* Trie-insertion logic extracted from the `Router` class (T014, design.md D2 —
|
|
5
|
+
* D2 keeps HTTP-verb shortcuts (`get`/`post`/etc.) on `Router` itself, but
|
|
6
|
+
* explicitly permits extracting their shared internal, `addRoute`, "if
|
|
7
|
+
* `addRoute` itself is large enough to warrant it." At 116 lines and the
|
|
8
|
+
* highest cyclomatic/cognitive complexity in the file, it is.
|
|
9
|
+
*
|
|
10
|
+
* `addRoute` touches five pieces of `Router` instance state (the trie root,
|
|
11
|
+
* `caseSensitive`, the `hasParamRoutes` flag, the static-route hash map, and
|
|
12
|
+
* the introspection registry) as both reads and writes. All five are threaded
|
|
13
|
+
* explicitly rather than read/written through an implicit `this`, so this
|
|
14
|
+
* function has no hidden dependency on `Router` beyond what's passed in —
|
|
15
|
+
* same principle as the matching-engine extraction (design.md D1).
|
|
16
|
+
*
|
|
17
|
+
* @packageDocumentation
|
|
18
|
+
* @internal
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { HTTP_METHODS } from '@nextrush/types';
|
|
22
|
+
import type {
|
|
23
|
+
HttpMethod,
|
|
24
|
+
MetadataContribution,
|
|
25
|
+
Middleware,
|
|
26
|
+
RouteDefinition,
|
|
27
|
+
RouteEntry,
|
|
28
|
+
RouteHandler,
|
|
29
|
+
} from '@nextrush/types';
|
|
30
|
+
import {
|
|
31
|
+
compileExecutor,
|
|
32
|
+
createNode,
|
|
33
|
+
NodeType,
|
|
34
|
+
parseSegments,
|
|
35
|
+
type HandlerEntry,
|
|
36
|
+
type StaticRouteMap,
|
|
37
|
+
type TrieNode,
|
|
38
|
+
} from './segment-trie';
|
|
39
|
+
import { mergeContributions, readContribution } from './route-metadata';
|
|
40
|
+
import { createRedirectHandler, type RedirectStatus } from './redirect';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Registration state `addRoute` reads and writes, threaded explicitly.
|
|
44
|
+
*
|
|
45
|
+
* `maxDepth` is the deepest registered route's segment count seen so far —
|
|
46
|
+
* used to size the reused walk-frame pool (`reduce-router-match-allocations`)
|
|
47
|
+
* without guessing a constant. It only ever grows: a request path can never
|
|
48
|
+
* make the tree walk descend past the trie's own real depth (a mismatched
|
|
49
|
+
* segment backtracks rather than pushing a new frame), so this stays a
|
|
50
|
+
* registration-time-only figure, never influenced by request traffic.
|
|
51
|
+
*/
|
|
52
|
+
export interface RegistrationState {
|
|
53
|
+
readonly root: TrieNode;
|
|
54
|
+
readonly caseSensitive: boolean;
|
|
55
|
+
readonly staticRoutes: StaticRouteMap;
|
|
56
|
+
readonly routeDefinitions: RouteDefinition[];
|
|
57
|
+
maxDepth: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Normalize a route path at registration time: join the router prefix,
|
|
62
|
+
* collapse duplicate slashes, drop a trailing slash in non-strict mode, and
|
|
63
|
+
* guarantee a leading slash.
|
|
64
|
+
*
|
|
65
|
+
* @remarks
|
|
66
|
+
* This is registration-time normalization — it joins the router `prefix` and
|
|
67
|
+
* never case-folds (case handling belongs to trie insertion / matching). It is
|
|
68
|
+
* a distinct concern from `normalizePathForMatch` in `matching.ts`, which
|
|
69
|
+
* normalizes an *incoming request* path for lookup; both were extracted from
|
|
70
|
+
* `Router` to keep each single-definition (design.md D2/D3).
|
|
71
|
+
*/
|
|
72
|
+
export function normalizeRegistrationPath(path: string, prefix: string, strict: boolean): string {
|
|
73
|
+
// Handle prefix with trailing slash and path with leading slash
|
|
74
|
+
let joinedPrefix = prefix;
|
|
75
|
+
if (joinedPrefix.endsWith('/') && path.startsWith('/')) {
|
|
76
|
+
joinedPrefix = joinedPrefix.slice(0, -1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let normalized = joinedPrefix + path;
|
|
80
|
+
|
|
81
|
+
// Fast-path: skip regex when no double slashes (99%+ of requests)
|
|
82
|
+
if (normalized.includes('//')) {
|
|
83
|
+
normalized = normalized.replace(/\/+/g, '/');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// For non-strict mode during registration, remove trailing slash
|
|
87
|
+
if (!strict && normalized.length > 1 && normalized.endsWith('/')) {
|
|
88
|
+
normalized = normalized.slice(0, -1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return normalized.startsWith('/') ? normalized : '/' + normalized;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Insert one entry into the method-nested static-route fast-path map,
|
|
96
|
+
* creating the per-method inner map on first use.
|
|
97
|
+
*/
|
|
98
|
+
function setStaticEntry(
|
|
99
|
+
staticRoutes: StaticRouteMap,
|
|
100
|
+
method: HttpMethod,
|
|
101
|
+
key: string,
|
|
102
|
+
entry: HandlerEntry
|
|
103
|
+
): void {
|
|
104
|
+
let methodMap = staticRoutes.get(method);
|
|
105
|
+
if (!methodMap) {
|
|
106
|
+
methodMap = new Map<string, HandlerEntry>();
|
|
107
|
+
staticRoutes.set(method, methodMap);
|
|
108
|
+
}
|
|
109
|
+
methodMap.set(key, entry);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Insert one route into the segment trie, updating every side structure
|
|
114
|
+
* (static-route fast-path map, introspection registry) that
|
|
115
|
+
* `Router.match`/`Router.getRoutes` depend on.
|
|
116
|
+
*
|
|
117
|
+
* @param normalized - Already-normalized path (caller applies `Router`'s own
|
|
118
|
+
* prefix/strict-mode normalization before calling this — normalization
|
|
119
|
+
* itself stays a `Router` method since it only touches `opts`, not the trie).
|
|
120
|
+
* @param recordIntrospection - When `false`, skips pushing this call's row
|
|
121
|
+
* into `state.routeDefinitions` while still inserting the concrete
|
|
122
|
+
* per-method trie handler (matching is completely unaffected either way).
|
|
123
|
+
* `Router.all()` (T016) sets this to `false` on each of its 7 per-method
|
|
124
|
+
* `addRoute` calls, then pushes exactly one consolidated
|
|
125
|
+
* `isAnyMethod: true` row itself — so an any-method route yields a single
|
|
126
|
+
* `getRoutes()` entry instead of one row per enumerated method, without
|
|
127
|
+
* touching how any individual method is matched. Every other call site
|
|
128
|
+
* (`get`/`post`/etc., `redirect`, sub-router mounting) omits this parameter
|
|
129
|
+
* and keeps the original one-row-per-call behavior unchanged.
|
|
130
|
+
* @returns `true` if the registered route has a param or wildcard segment —
|
|
131
|
+
* the caller (`Router.addRoute`) uses this to flip its own `hasParamRoutes`
|
|
132
|
+
* flag. Returned rather than mutated through `state` because it's a
|
|
133
|
+
* primitive: a struct field can't carry a boolean mutation back to the
|
|
134
|
+
* caller by reference the way `root`/`staticRoutes`/`routeDefinitions` do.
|
|
135
|
+
*/
|
|
136
|
+
export function addRoute(
|
|
137
|
+
method: HttpMethod,
|
|
138
|
+
normalized: string,
|
|
139
|
+
entries: RouteEntry[],
|
|
140
|
+
middleware: Middleware[],
|
|
141
|
+
state: RegistrationState,
|
|
142
|
+
recordIntrospection = true
|
|
143
|
+
): boolean {
|
|
144
|
+
const segments = parseSegments(normalized, state.caseSensitive);
|
|
145
|
+
|
|
146
|
+
if (segments.length > state.maxDepth) {
|
|
147
|
+
state.maxDepth = segments.length;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let node = state.root;
|
|
151
|
+
|
|
152
|
+
for (const seg of segments) {
|
|
153
|
+
if (seg.type === NodeType.PARAM) {
|
|
154
|
+
if (!node.paramChild) {
|
|
155
|
+
node.paramChild = createNode(seg.segment, NodeType.PARAM);
|
|
156
|
+
node.paramChild.paramName = seg.paramName;
|
|
157
|
+
} else if (node.paramChild.paramName !== seg.paramName) {
|
|
158
|
+
// Same position, different param names — this silently loses one name
|
|
159
|
+
// at runtime (params[newName] is undefined), so fail fast at
|
|
160
|
+
// registration rather than warn (audit RT-5). Also removes the
|
|
161
|
+
// process.env / console.warn usage that was here.
|
|
162
|
+
throw new Error(
|
|
163
|
+
`Route param name conflict at "${normalized}": ":${String(seg.paramName)}" ` +
|
|
164
|
+
`conflicts with existing ":${String(node.paramChild.paramName)}" at the same ` +
|
|
165
|
+
`position. Use the same param name for this segment across all routes.`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
node = node.paramChild;
|
|
169
|
+
} else if (seg.type === NodeType.WILDCARD) {
|
|
170
|
+
node.wildcardChild ??= createNode('*', NodeType.WILDCARD);
|
|
171
|
+
node = node.wildcardChild;
|
|
172
|
+
break; // Wildcard must be last
|
|
173
|
+
} else {
|
|
174
|
+
const key = seg.segment;
|
|
175
|
+
let child = node.children.get(key);
|
|
176
|
+
if (!child) {
|
|
177
|
+
child = createNode(seg.segment, NodeType.STATIC);
|
|
178
|
+
node.children.set(key, child);
|
|
179
|
+
}
|
|
180
|
+
node = child;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Partition entries: functions are behavior (inline middleware + the final
|
|
185
|
+
// handler); pure markers (endpoint()) contribute metadata only and never
|
|
186
|
+
// execute. Every entry may also carry a metadata contribution (e.g. a
|
|
187
|
+
// function like validate() that both runs and contributes its schema).
|
|
188
|
+
const functions: Middleware[] = [];
|
|
189
|
+
const contributions: MetadataContribution[] = [];
|
|
190
|
+
for (const routeEntry of entries) {
|
|
191
|
+
const contribution = readContribution(routeEntry);
|
|
192
|
+
if (contribution) contributions.push(contribution);
|
|
193
|
+
if (typeof routeEntry === 'function') functions.push(routeEntry);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Combine functions into a single handler with inline middleware
|
|
197
|
+
const combinedMiddleware = [...middleware];
|
|
198
|
+
const finalHandler: RouteHandler | undefined = functions[functions.length - 1];
|
|
199
|
+
|
|
200
|
+
if (!finalHandler) {
|
|
201
|
+
throw new Error('At least one handler is required');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Inline middleware = every function before the last
|
|
205
|
+
for (let i = 0; i < functions.length - 1; i++) {
|
|
206
|
+
const fn = functions[i];
|
|
207
|
+
if (fn) combinedMiddleware.push(fn);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Pre-compile executor at registration time (not per-request!)
|
|
211
|
+
const executor = compileExecutor(finalHandler, combinedMiddleware);
|
|
212
|
+
|
|
213
|
+
const handlerEntry: HandlerEntry = {
|
|
214
|
+
handler: finalHandler,
|
|
215
|
+
middleware: combinedMiddleware,
|
|
216
|
+
executor,
|
|
217
|
+
autoHead: false,
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
// Detect duplicate route registration. A derived HEAD entry is not a
|
|
221
|
+
// duplicate — an explicit `router.head()` replaces it, in either
|
|
222
|
+
// registration order.
|
|
223
|
+
const existing = node.handlers.get(method);
|
|
224
|
+
if (existing && !(method === 'HEAD' && existing.autoHead)) {
|
|
225
|
+
throw new Error(
|
|
226
|
+
`Route conflict: ${method} ${normalized} is already registered. ` +
|
|
227
|
+
'Remove the duplicate or use a different path.'
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
node.handlers.set(method, handlerEntry);
|
|
232
|
+
|
|
233
|
+
// Populate static route hash map for O(1) lookup. Method-nested (HP-9): the
|
|
234
|
+
// outer map is keyed by method, the inner by the (lowercased, unless
|
|
235
|
+
// case-sensitive) path — so matching selects the inner map by method and
|
|
236
|
+
// probes by the raw path with no per-request key-string concatenation.
|
|
237
|
+
const hasParams = segments.some((s) => s.type === NodeType.PARAM || s.type === NodeType.WILDCARD);
|
|
238
|
+
const staticKey = hasParams
|
|
239
|
+
? undefined
|
|
240
|
+
: state.caseSensitive
|
|
241
|
+
? normalized
|
|
242
|
+
: normalized.toLowerCase();
|
|
243
|
+
if (staticKey !== undefined) {
|
|
244
|
+
setStaticEntry(state.staticRoutes, method, staticKey, handlerEntry);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// RFC 9110 §9.3.2: HEAD is GET without a body, so a GET registration answers
|
|
248
|
+
// HEAD too — matching Fastify/Express/Koa/Hono. Derived at registration time,
|
|
249
|
+
// so request dispatch is unchanged. An explicit HEAD already registered for
|
|
250
|
+
// this path wins and is never overwritten.
|
|
251
|
+
if (method === 'GET' && !node.handlers.has('HEAD')) {
|
|
252
|
+
const derived: HandlerEntry = {
|
|
253
|
+
handler: finalHandler,
|
|
254
|
+
middleware: combinedMiddleware,
|
|
255
|
+
executor,
|
|
256
|
+
autoHead: true,
|
|
257
|
+
};
|
|
258
|
+
node.handlers.set('HEAD', derived);
|
|
259
|
+
if (staticKey !== undefined) {
|
|
260
|
+
setStaticEntry(state.staticRoutes, 'HEAD', staticKey, derived);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Record in the introspection registry (side structure — never touched by
|
|
265
|
+
// request dispatch). key is the canonical `${METHOD} ${pathPattern}`. Skipped
|
|
266
|
+
// when the caller is consolidating multiple addRoute calls into a single row
|
|
267
|
+
// itself (Router.all(), T016) — the concrete trie handler above is still
|
|
268
|
+
// inserted regardless, so matching for this method is unaffected.
|
|
269
|
+
if (recordIntrospection) {
|
|
270
|
+
state.routeDefinitions.push({
|
|
271
|
+
key: `${method} ${normalized}`,
|
|
272
|
+
method,
|
|
273
|
+
path: normalized,
|
|
274
|
+
metadata: mergeContributions(contributions),
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return hasParams;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Callback signature matching `Router['addRoute']` (the validating/
|
|
283
|
+
* normalizing wrapper, not the extracted `addRoute` above) — injected so
|
|
284
|
+
* `registerRedirect` doesn't need `Router` instance access, same pattern as
|
|
285
|
+
* `copyRoutes`'s `AddRouteFn` in `composition.ts`.
|
|
286
|
+
*/
|
|
287
|
+
export type RegisterRouteFn = (method: HttpMethod, path: string, entries: RouteEntry[]) => void;
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Register a redirect route from one path to another across every HTTP
|
|
291
|
+
* method the target status code requires (GET/HEAD always; POST/PUT/PATCH/
|
|
292
|
+
* DELETE additionally for 307/308, which must preserve the original method).
|
|
293
|
+
*
|
|
294
|
+
* Extracted from `Router.redirect()` — thematically closer to "register N
|
|
295
|
+
* routes for one call" than to composition or matching, and, like
|
|
296
|
+
* `copyRoutes`, only needs `addRoute` injected rather than full `Router`
|
|
297
|
+
* instance access.
|
|
298
|
+
*/
|
|
299
|
+
export function registerRedirect(
|
|
300
|
+
from: string,
|
|
301
|
+
to: string,
|
|
302
|
+
status: RedirectStatus,
|
|
303
|
+
addRoute: RegisterRouteFn
|
|
304
|
+
): void {
|
|
305
|
+
const redirectHandler = createRedirectHandler(to, status);
|
|
306
|
+
|
|
307
|
+
// Register for common methods. 307/308 preserve the original method,
|
|
308
|
+
// so register all standard methods for those status codes.
|
|
309
|
+
addRoute('GET', from, [redirectHandler]);
|
|
310
|
+
addRoute('HEAD', from, [redirectHandler]);
|
|
311
|
+
if (status === 307 || status === 308) {
|
|
312
|
+
addRoute('POST', from, [redirectHandler]);
|
|
313
|
+
addRoute('PUT', from, [redirectHandler]);
|
|
314
|
+
addRoute('PATCH', from, [redirectHandler]);
|
|
315
|
+
addRoute('DELETE', from, [redirectHandler]);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Push a single consolidated any-method (`isAnyMethod: true`) introspection
|
|
321
|
+
* row (T016) into the registry.
|
|
322
|
+
*
|
|
323
|
+
* @remarks
|
|
324
|
+
* `Router.all()` / `GroupRouter.all()` insert one concrete per-method trie
|
|
325
|
+
* handler each (so every method still matches) with `recordIntrospection`
|
|
326
|
+
* off, then call this exactly once — so `getRoutes()` yields a single row per
|
|
327
|
+
* `.all()`/`@All()` route instead of one row per enumerated HTTP method,
|
|
328
|
+
* without changing how any individual method is matched.
|
|
329
|
+
*
|
|
330
|
+
* @param routeDefinitions - The router's introspection registry to append to.
|
|
331
|
+
* @param normalized - The already-normalized route path.
|
|
332
|
+
*/
|
|
333
|
+
export function pushAnyMethodDefinition(
|
|
334
|
+
routeDefinitions: RouteDefinition[],
|
|
335
|
+
normalized: string
|
|
336
|
+
): void {
|
|
337
|
+
routeDefinitions.push({
|
|
338
|
+
key: `${HTTP_METHODS[0]} ${normalized}`,
|
|
339
|
+
method: HTTP_METHODS[0],
|
|
340
|
+
path: normalized,
|
|
341
|
+
isAnyMethod: true,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @nextrush/router - Route Metadata Contributions
|
|
3
|
+
*
|
|
4
|
+
* Inline route metadata (`endpoint()`) and the registration-time merge of
|
|
5
|
+
* contributions from `validate()`, `endpoint()`, etc. Extracted from `router.ts`
|
|
6
|
+
* (audit RT-3). None of this is on the request hot path — it is read only at
|
|
7
|
+
* registration and by `getRoutes()` at doc-generation time.
|
|
8
|
+
*
|
|
9
|
+
* @packageDocumentation
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
ROUTE_METADATA,
|
|
14
|
+
type MetadataContribution,
|
|
15
|
+
type RouteEntry,
|
|
16
|
+
type RouteMetadata,
|
|
17
|
+
type RouteMetaMarker,
|
|
18
|
+
} from '@nextrush/types';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Declare route metadata inline in a route's argument list. Returns a pure data
|
|
22
|
+
* marker (not a middleware) — the router reads its metadata at registration and
|
|
23
|
+
* never executes it per request.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```typescript
|
|
27
|
+
* router.post('/users',
|
|
28
|
+
* validate(User),
|
|
29
|
+
* endpoint({ summary: 'Create a user', responses: { 201: UserResponse } }),
|
|
30
|
+
* handler,
|
|
31
|
+
* );
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
export function endpoint(metadata: MetadataContribution): RouteMetaMarker {
|
|
35
|
+
return { [ROUTE_METADATA]: metadata };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Mutable view of RouteMetadata, used only while merging contributions. */
|
|
39
|
+
type MutableRouteMetadata = { -readonly [K in keyof RouteMetadata]?: RouteMetadata[K] };
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Merge metadata contributions (from `validate()`, `endpoint()`, etc.) in
|
|
43
|
+
* registration order. Scalars and arrays are last-write-wins; the `request` and
|
|
44
|
+
* `responses` maps merge per key. Returns `undefined` when nothing contributed
|
|
45
|
+
* (an undocumented route).
|
|
46
|
+
*/
|
|
47
|
+
export function mergeContributions(
|
|
48
|
+
contributions: readonly MetadataContribution[]
|
|
49
|
+
): RouteMetadata | undefined {
|
|
50
|
+
if (contributions.length === 0) return undefined;
|
|
51
|
+
|
|
52
|
+
const meta: MutableRouteMetadata = {};
|
|
53
|
+
for (const c of contributions) {
|
|
54
|
+
if (c.summary !== undefined) meta.summary = c.summary;
|
|
55
|
+
if (c.description !== undefined) meta.description = c.description;
|
|
56
|
+
if (c.deprecated !== undefined) meta.deprecated = c.deprecated;
|
|
57
|
+
if (c.visibility !== undefined) meta.visibility = c.visibility;
|
|
58
|
+
if (c.tags !== undefined) meta.tags = c.tags;
|
|
59
|
+
if (c.request) meta.request = { ...meta.request, ...c.request };
|
|
60
|
+
if (c.responses) meta.responses = { ...meta.responses, ...c.responses };
|
|
61
|
+
}
|
|
62
|
+
return meta;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Read a route entry's metadata contribution, if it carries one. */
|
|
66
|
+
export function readContribution(entry: RouteEntry): MetadataContribution | undefined {
|
|
67
|
+
return (entry as Partial<RouteMetaMarker>)[ROUTE_METADATA];
|
|
68
|
+
}
|