@gate-forge/http-contract 0.1.0
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 +202 -0
- package/README.md +73 -0
- package/dist/codes.d.ts +41 -0
- package/dist/codes.d.ts.map +1 -0
- package/dist/codes.js +50 -0
- package/dist/codes.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/join.d.ts +115 -0
- package/dist/join.d.ts.map +1 -0
- package/dist/join.js +280 -0
- package/dist/join.js.map +1 -0
- package/dist/linkage.d.ts +15 -0
- package/dist/linkage.d.ts.map +1 -0
- package/dist/linkage.js +38 -0
- package/dist/linkage.js.map +1 -0
- package/dist/normalize.d.ts +61 -0
- package/dist/normalize.d.ts.map +1 -0
- package/dist/normalize.js +145 -0
- package/dist/normalize.js.map +1 -0
- package/dist/schema.d.ts +84 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +75 -0
- package/dist/schema.js.map +1 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +3 -0
- package/dist/version.js.map +1 -0
- package/package.json +43 -0
package/dist/join.js
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic one-to-one join engine (ADR 0004 D3).
|
|
3
|
+
*
|
|
4
|
+
* A frontend consumption exists only when one frontend-call fact joins
|
|
5
|
+
* exactly one distinct backend route identity: equal uppercase method,
|
|
6
|
+
* position-wise segment match where a frontend `{}` matches any single
|
|
7
|
+
* route segment (param or literal) and a trailing route `{*}` matches one
|
|
8
|
+
* or more trailing call segments.
|
|
9
|
+
*
|
|
10
|
+
* LITERAL PRECEDENCE (phase 3 refinement). Positional candidates are
|
|
11
|
+
* partitioned by match quality before the exactly-one check:
|
|
12
|
+
*
|
|
13
|
+
* - A match is *literal* when it consumed no slot generality: every
|
|
14
|
+
* matched position is exact segment equality — literal==literal, or the
|
|
15
|
+
* call's own `{}` mirrored by the route's `{}` (the route then declares
|
|
16
|
+
* exactly the shape the call already has; nothing is absorbed beyond
|
|
17
|
+
* what the call itself carries).
|
|
18
|
+
* - A match is *parameter* otherwise: a route `{}` absorbed a call
|
|
19
|
+
* literal, a call `{}` was relaxed onto a route literal, or any `{*}`
|
|
20
|
+
* absorption happened. A wildcard match is never a literal match.
|
|
21
|
+
*
|
|
22
|
+
* If any literal matches exist they are THE candidates; parameter-only
|
|
23
|
+
* matches are considered only when zero literal matches exist. This
|
|
24
|
+
* mirrors runtime routing truth: FastAPI — like every major router —
|
|
25
|
+
* resolves literal path segments before parameterized ones, so a request
|
|
26
|
+
* to `/messages/search` always hits the literal route and never reaches
|
|
27
|
+
* `/messages/{}` with id="search". The static join must agree: a
|
|
28
|
+
* template call `/messages/${id}` joins `/messages/{}` despite literal
|
|
29
|
+
* siblings, and a literal call `/messages/unread-count` joins only the
|
|
30
|
+
* literal route.
|
|
31
|
+
*
|
|
32
|
+
* Never guess: zero selected candidates and multiple distinct selected
|
|
33
|
+
* candidates (in either tier) are typed blocks — there is no scoring, no
|
|
34
|
+
* fuzzy distance, and no first-match-wins beyond the documented partition.
|
|
35
|
+
*
|
|
36
|
+
* The result is a pure function of the input facts: identical inputs in
|
|
37
|
+
* any permutation produce byte-identical output (total order everywhere).
|
|
38
|
+
*/
|
|
39
|
+
import { createHash } from 'node:crypto';
|
|
40
|
+
import { FRONTEND_ROUTE_AMBIGUOUS, FRONTEND_ROUTE_UNWIRED, HTTP_METHOD_DYNAMIC, } from './codes.js';
|
|
41
|
+
import { HTTP_PARAM_SLOT, HTTP_WILDCARD_SLOT, pathSegments } from './normalize.js';
|
|
42
|
+
/** Canonical identity string for a method/path pair. */
|
|
43
|
+
export function canonicalEndpointIdentity(method, canonicalPath) {
|
|
44
|
+
return `${method} ${canonicalPath}`;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Deterministic, collision-safe endpoint resource name (ADR 0004 D1):
|
|
48
|
+
* `http-<method>-<path-slug>-<sha8>`. The slug keeps reports readable; the
|
|
49
|
+
* identity hash suffix makes distinct endpoints collision-free even when
|
|
50
|
+
* sanitization would coincide (dot vs dash), and is invariant under
|
|
51
|
+
* parameter-name changes because it hashes the canonical form.
|
|
52
|
+
*/
|
|
53
|
+
export function endpointResourceName(method, canonicalPath) {
|
|
54
|
+
const identity = canonicalEndpointIdentity(method, canonicalPath);
|
|
55
|
+
const slug = canonicalPath
|
|
56
|
+
.split('/')
|
|
57
|
+
.filter((segment) => segment !== '')
|
|
58
|
+
.map((segment) => {
|
|
59
|
+
if (segment === HTTP_PARAM_SLOT)
|
|
60
|
+
return 'param';
|
|
61
|
+
if (segment === HTTP_WILDCARD_SLOT)
|
|
62
|
+
return 'wildcard';
|
|
63
|
+
return segment
|
|
64
|
+
.toLowerCase()
|
|
65
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
66
|
+
.replace(/^-+|-+$/g, '');
|
|
67
|
+
})
|
|
68
|
+
.filter((segment) => segment !== '')
|
|
69
|
+
.join('-');
|
|
70
|
+
const hash = createHash('sha256').update(identity).digest('hex').slice(0, 8);
|
|
71
|
+
const base = slug.length > 0 ? slug : 'root';
|
|
72
|
+
return `http-${method.toLowerCase()}-${base}-${hash}`;
|
|
73
|
+
}
|
|
74
|
+
/** Positional segment match for a route without a trailing wildcard. */
|
|
75
|
+
function segmentsMatchExact(routeSegments, callSegments) {
|
|
76
|
+
if (routeSegments.length !== callSegments.length)
|
|
77
|
+
return false;
|
|
78
|
+
for (let index = 0; index < routeSegments.length; index += 1) {
|
|
79
|
+
const routeSegment = routeSegments[index] ?? '';
|
|
80
|
+
const callSegment = callSegments[index] ?? '';
|
|
81
|
+
if (routeSegment === callSegment)
|
|
82
|
+
continue; // literal==literal or slot==slot
|
|
83
|
+
if (routeSegment === HTTP_PARAM_SLOT)
|
|
84
|
+
continue; // backend param matches any call segment
|
|
85
|
+
if (callSegment === HTTP_PARAM_SLOT)
|
|
86
|
+
continue; // frontend slot matches any route segment
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Wildcard rule (ADR 0004 D3, the only one): a trailing route `{*}`
|
|
93
|
+
* matches one or more trailing call segments; everything before it must
|
|
94
|
+
* match positionally. Non-trailing wildcards never join.
|
|
95
|
+
*/
|
|
96
|
+
function segmentsMatchWildcard(routeSegments, callSegments) {
|
|
97
|
+
const wildcardIndex = routeSegments.length - 1;
|
|
98
|
+
if (callSegments.length < routeSegments.length)
|
|
99
|
+
return false; // needs >=1 absorbed segment
|
|
100
|
+
for (let index = 0; index < wildcardIndex; index += 1) {
|
|
101
|
+
const routeSegment = routeSegments[index] ?? '';
|
|
102
|
+
const callSegment = callSegments[index] ?? '';
|
|
103
|
+
if (routeSegment === callSegment)
|
|
104
|
+
continue;
|
|
105
|
+
if (routeSegment === HTTP_PARAM_SLOT)
|
|
106
|
+
continue;
|
|
107
|
+
if (callSegment === HTTP_PARAM_SLOT)
|
|
108
|
+
continue;
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Classifies one call/route pair by match quality, or returns `null` when
|
|
115
|
+
* the pair does not match under the documented positional rules. Pure and
|
|
116
|
+
* order-independent; `routeMatchesCall` is exactly `kind !== null`.
|
|
117
|
+
*/
|
|
118
|
+
export function routeMatchKind(route, call) {
|
|
119
|
+
if (route.method === 'ANY' || call.method === 'ANY')
|
|
120
|
+
return null;
|
|
121
|
+
if (route.method !== call.method)
|
|
122
|
+
return null;
|
|
123
|
+
const routeSegments = pathSegments(route.normalizedPath);
|
|
124
|
+
const callSegments = pathSegments(call.normalizedPath);
|
|
125
|
+
if (routeSegments.length === 0 || callSegments.length === 0) {
|
|
126
|
+
// Root matches root, and only root: a vacuous all-literal match.
|
|
127
|
+
return routeSegments.length === 0 && callSegments.length === 0 ? 'literal' : null;
|
|
128
|
+
}
|
|
129
|
+
const wildcard = routeSegments[routeSegments.length - 1] === HTTP_WILDCARD_SLOT;
|
|
130
|
+
if (wildcard) {
|
|
131
|
+
// Absorption is generality, not equality — see the wildcard rule and
|
|
132
|
+
// the LITERAL PRECEDENCE note in the module docstring.
|
|
133
|
+
return segmentsMatchWildcard(routeSegments, callSegments) ? 'parameter' : null;
|
|
134
|
+
}
|
|
135
|
+
if (!segmentsMatchExact(routeSegments, callSegments))
|
|
136
|
+
return null;
|
|
137
|
+
for (let index = 0; index < routeSegments.length; index += 1) {
|
|
138
|
+
// Any position where the strings differ (route `{}` vs call literal,
|
|
139
|
+
// or call `{}` vs route literal) consumed slot generality.
|
|
140
|
+
if (routeSegments[index] !== callSegments[index])
|
|
141
|
+
return 'parameter';
|
|
142
|
+
}
|
|
143
|
+
return 'literal';
|
|
144
|
+
}
|
|
145
|
+
/** Whether one call fact can join one route fact at all. */
|
|
146
|
+
export function routeMatchesCall(route, call) {
|
|
147
|
+
return routeMatchKind(route, call) !== null;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Joins frontend-call facts against server-route facts.
|
|
151
|
+
*
|
|
152
|
+
* Candidate selection applies LITERAL PRECEDENCE (see the module
|
|
153
|
+
* docstring): literal matches shadow parameter matches, mirroring how
|
|
154
|
+
* routers resolve literal segments before parameterized ones at runtime.
|
|
155
|
+
*
|
|
156
|
+
* `ANY` routes participate in the inventory (they carry exposure evidence)
|
|
157
|
+
* but never join: a catch-all registration cannot prove which concrete
|
|
158
|
+
* method the frontend exercised. Calls with a non-concrete method produce
|
|
159
|
+
* `HTTP_METHOD_DYNAMIC` blocks.
|
|
160
|
+
*/
|
|
161
|
+
export function joinFrontendCalls(routes, calls) {
|
|
162
|
+
const byIdentity = new Map();
|
|
163
|
+
for (const route of routes) {
|
|
164
|
+
if (route.role !== 'server-route' || route.method === 'ANY')
|
|
165
|
+
continue;
|
|
166
|
+
const identity = canonicalEndpointIdentity(route.method, route.normalizedPath);
|
|
167
|
+
const entry = byIdentity.get(identity);
|
|
168
|
+
if (entry) {
|
|
169
|
+
pushUniqueFact(entry.routes, route);
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
byIdentity.set(identity, { identity, routes: [route], calls: [] });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const blocks = [];
|
|
176
|
+
const seenBlockKeys = new Set();
|
|
177
|
+
for (const call of calls) {
|
|
178
|
+
if (call.role !== 'frontend-call')
|
|
179
|
+
continue;
|
|
180
|
+
if (call.method === 'ANY') {
|
|
181
|
+
pushBlock(blocks, seenBlockKeys, {
|
|
182
|
+
code: HTTP_METHOD_DYNAMIC,
|
|
183
|
+
detail: `frontend call to '${call.rawPath}' has no statically provable method`,
|
|
184
|
+
location: call.source,
|
|
185
|
+
candidates: [],
|
|
186
|
+
});
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
// Step 1 — all positional candidates, computed exactly as before.
|
|
190
|
+
const candidates = [];
|
|
191
|
+
for (const entry of byIdentity.values()) {
|
|
192
|
+
const route = entry.routes[0];
|
|
193
|
+
if (!route)
|
|
194
|
+
continue;
|
|
195
|
+
const kind = routeMatchKind(route, call);
|
|
196
|
+
if (kind !== null)
|
|
197
|
+
candidates.push({ entry, kind });
|
|
198
|
+
}
|
|
199
|
+
if (candidates.length === 0) {
|
|
200
|
+
pushBlock(blocks, seenBlockKeys, {
|
|
201
|
+
code: FRONTEND_ROUTE_UNWIRED,
|
|
202
|
+
detail: `frontend call '${call.method} ${call.rawPath}' matches no backend route`,
|
|
203
|
+
location: call.source,
|
|
204
|
+
candidates: [],
|
|
205
|
+
});
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
// Steps 2/3 — LITERAL PRECEDENCE partition. Literal matches shadow
|
|
209
|
+
// parameter matches because routers (FastAPI included) resolve literal
|
|
210
|
+
// segments before parameterized ones at runtime; a wildcard match is a
|
|
211
|
+
// parameter match and never shadows. Parameter-only candidates keep
|
|
212
|
+
// today's behavior. No scoring: the partition is all-or-nothing.
|
|
213
|
+
const selected = candidates.some((candidate) => candidate.kind === 'literal')
|
|
214
|
+
? candidates.filter((candidate) => candidate.kind === 'literal')
|
|
215
|
+
: candidates;
|
|
216
|
+
const distinct = new Map();
|
|
217
|
+
for (const { entry } of selected) {
|
|
218
|
+
const route = entry.routes[0];
|
|
219
|
+
if (route)
|
|
220
|
+
distinct.set(entry.identity, entry);
|
|
221
|
+
}
|
|
222
|
+
if (distinct.size > 1) {
|
|
223
|
+
pushBlock(blocks, seenBlockKeys, {
|
|
224
|
+
code: FRONTEND_ROUTE_AMBIGUOUS,
|
|
225
|
+
detail: `frontend call '${call.method} ${call.rawPath}' matches ${distinct.size} distinct backend routes`,
|
|
226
|
+
location: call.source,
|
|
227
|
+
candidates: [...distinct.keys()].sort(compareStrings),
|
|
228
|
+
});
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const matched = selected[0]?.entry;
|
|
232
|
+
if (matched) {
|
|
233
|
+
pushUniqueFact(matched.calls, call);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const endpoints = [...byIdentity.values()]
|
|
237
|
+
.filter((entry) => entry.calls.length > 0)
|
|
238
|
+
.map((entry) => {
|
|
239
|
+
const route = entry.routes[0];
|
|
240
|
+
const method = route?.method ?? 'GET';
|
|
241
|
+
return {
|
|
242
|
+
method,
|
|
243
|
+
canonicalPath: route?.normalizedPath ?? '/',
|
|
244
|
+
identity: entry.identity,
|
|
245
|
+
resourceName: endpointResourceName(method, route?.normalizedPath ?? '/'),
|
|
246
|
+
routes: sortFacts(entry.routes),
|
|
247
|
+
calls: sortFacts(entry.calls),
|
|
248
|
+
};
|
|
249
|
+
})
|
|
250
|
+
.sort((a, b) => compareStrings(a.identity, b.identity));
|
|
251
|
+
return { endpoints, blocks: sortBlocks(blocks) };
|
|
252
|
+
}
|
|
253
|
+
function pushUniqueFact(list, fact) {
|
|
254
|
+
const key = JSON.stringify(fact);
|
|
255
|
+
if (!list.some((existing) => JSON.stringify(existing) === key))
|
|
256
|
+
list.push(fact);
|
|
257
|
+
}
|
|
258
|
+
function pushBlock(list, seen, block) {
|
|
259
|
+
const key = JSON.stringify([block.code, block.detail, block.location, block.candidates]);
|
|
260
|
+
if (seen.has(key))
|
|
261
|
+
return;
|
|
262
|
+
seen.add(key);
|
|
263
|
+
list.push(block);
|
|
264
|
+
}
|
|
265
|
+
function sortFacts(facts) {
|
|
266
|
+
return [...facts].sort((a, b) => compareStrings(JSON.stringify(a), JSON.stringify(b)));
|
|
267
|
+
}
|
|
268
|
+
function sortBlocks(blocks) {
|
|
269
|
+
return [...blocks].sort((a, b) => compareStrings(a.location.file, b.location.file) ||
|
|
270
|
+
(a.location.line || 0) - (b.location.line || 0) ||
|
|
271
|
+
(a.location.col || 0) - (b.location.col || 0) ||
|
|
272
|
+
compareStrings(a.code, b.code) ||
|
|
273
|
+
compareStrings(a.detail, b.detail));
|
|
274
|
+
}
|
|
275
|
+
function compareStrings(a, b) {
|
|
276
|
+
if (a === b)
|
|
277
|
+
return 0;
|
|
278
|
+
return a < b ? -1 : 1;
|
|
279
|
+
}
|
|
280
|
+
//# sourceMappingURL=join.js.map
|
package/dist/join.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"join.js","sourceRoot":"","sources":["../src/join.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path-derived business-resource name candidate (ADR 0004 D5).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `resourceNameFromPath` in pack-http and the fastapi wrapper:
|
|
5
|
+
* the LAST non-empty, non-parameter, non-numeric path segment,
|
|
6
|
+
* lower-cased, extension stripped, dashes normalized to underscores
|
|
7
|
+
* (kebab-case route segments derive their snake_case form, e.g.
|
|
8
|
+
* `/email-accounts/{id}` derives `email_accounts`). This is a
|
|
9
|
+
* NON-AUTHORITATIVE linkage
|
|
10
|
+
* CANDIDATE only — the endpoint compiler links an endpoint to a business
|
|
11
|
+
* resource solely when the derived name matches exactly one discovered
|
|
12
|
+
* business resource; ambiguity is `ENDPOINT_RESOURCE_LINK_UNRESOLVED`.
|
|
13
|
+
*/
|
|
14
|
+
export declare function derivePathResourceName(rawPath: string): string | null;
|
|
15
|
+
//# sourceMappingURL=linkage.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"linkage.d.ts","sourceRoot":"","sources":["../src/linkage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAmBrE"}
|
package/dist/linkage.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path-derived business-resource name candidate (ADR 0004 D5).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors `resourceNameFromPath` in pack-http and the fastapi wrapper:
|
|
5
|
+
* the LAST non-empty, non-parameter, non-numeric path segment,
|
|
6
|
+
* lower-cased, extension stripped, dashes normalized to underscores
|
|
7
|
+
* (kebab-case route segments derive their snake_case form, e.g.
|
|
8
|
+
* `/email-accounts/{id}` derives `email_accounts`). This is a
|
|
9
|
+
* NON-AUTHORITATIVE linkage
|
|
10
|
+
* CANDIDATE only — the endpoint compiler links an endpoint to a business
|
|
11
|
+
* resource solely when the derived name matches exactly one discovered
|
|
12
|
+
* business resource; ambiguity is `ENDPOINT_RESOURCE_LINK_UNRESOLVED`.
|
|
13
|
+
*/
|
|
14
|
+
export function derivePathResourceName(rawPath) {
|
|
15
|
+
const withoutTail = rawPath.split('?')[0]?.split('#')[0] ?? rawPath;
|
|
16
|
+
const segments = withoutTail.split('/').filter((segment) => segment !== '');
|
|
17
|
+
for (let index = segments.length - 1; index >= 0; index -= 1) {
|
|
18
|
+
const segment = segments[index] ?? '';
|
|
19
|
+
if (segment.startsWith(':') || segment.startsWith('{') || segment.startsWith('*'))
|
|
20
|
+
continue;
|
|
21
|
+
if (/^\d+$/.test(segment))
|
|
22
|
+
continue;
|
|
23
|
+
if (segment.includes('$') && segment.includes('{'))
|
|
24
|
+
continue; // unresolved slot
|
|
25
|
+
const cleaned = segment.replace(/\.(json|xml|txt|html)$/i, '');
|
|
26
|
+
if (cleaned.length === 0)
|
|
27
|
+
continue;
|
|
28
|
+
// Dash→underscore NAME-FORM normalization only: kebab-case route
|
|
29
|
+
// segments must derive the snake_case form so the candidate can link
|
|
30
|
+
// an existing snake_case resource. Fail-closed doctrine: this never
|
|
31
|
+
// guesses a plane, mints a new identity, or widens matching — a
|
|
32
|
+
// candidate that names no discovered resource stays unlinked exactly
|
|
33
|
+
// as before.
|
|
34
|
+
return cleaned.toLowerCase().replaceAll('-', '_');
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=linkage.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"linkage.js","sourceRoot":"","sources":["../src/linkage.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,sBAAsB,CAAC,OAAe;IACpD,MAAM,WAAW,GAAG,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,OAAO,CAAC;IACpE,MAAM,QAAQ,GAAG,WAAW,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,KAAK,EAAE,CAAC,CAAC;IAC5E,KAAK,IAAI,KAAK,GAAG,QAAQ,CAAC,MAAM,GAAG,CAAC,EAAE,KAAK,IAAI,CAAC,EAAE,KAAK,IAAI,CAAC,EAAE,CAAC;QAC7D,MAAM,OAAO,GAAG,QAAQ,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;QACtC,IAAI,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC;YAAE,SAAS;QAC5F,IAAI,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC;YAAE,SAAS;QACpC,IAAI,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC;YAAE,SAAS,CAAC,kBAAkB;QAChF,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,yBAAyB,EAAE,EAAE,CAAC,CAAC;QAC/D,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;YAAE,SAAS;QACnC,iEAAiE;QACjE,qEAAqE;QACrE,oEAAoE;QACpE,gEAAgE;QAChE,qEAAqE;QACrE,aAAa;QACb,OAAO,OAAO,CAAC,WAAW,EAAE,CAAC,UAAU,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;IACpD,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC"}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical HTTP path and method normalization (ADR 0004 D2).
|
|
3
|
+
*
|
|
4
|
+
* Total and deterministic: the same input always yields the same result,
|
|
5
|
+
* and the canonical form is invariant under parameter-name changes
|
|
6
|
+
* (`/accounts/{account_id}` and `/accounts/{id}` both canonicalize to
|
|
7
|
+
* `/accounts/{}`). There is no fuzzy matching here — unresolvable shapes
|
|
8
|
+
* return a typed `HTTP_PATH_DYNAMIC` outcome instead of a best effort.
|
|
9
|
+
*/
|
|
10
|
+
import type { HttpMethod } from './schema.js';
|
|
11
|
+
/** Canonical single-segment positional parameter slot. */
|
|
12
|
+
export declare const HTTP_PARAM_SLOT = "{}";
|
|
13
|
+
/** Canonical wildcard slot (FastAPI `{name:path}` converters, `*` catch-alls). */
|
|
14
|
+
export declare const HTTP_WILDCARD_SLOT = "{*}";
|
|
15
|
+
export declare const NormalizeDynamicCode = "HTTP_PATH_DYNAMIC";
|
|
16
|
+
export interface NormalizePathOk {
|
|
17
|
+
ok: true;
|
|
18
|
+
/** Canonical path: leading `/`, no trailing slash (except root), no query/fragment, positional slots. */
|
|
19
|
+
canonical: string;
|
|
20
|
+
/** True when the path contains at least one positional or wildcard slot. */
|
|
21
|
+
hasParameters: boolean;
|
|
22
|
+
}
|
|
23
|
+
export interface NormalizePathDynamic {
|
|
24
|
+
ok: false;
|
|
25
|
+
code: typeof NormalizeDynamicCode;
|
|
26
|
+
detail: string;
|
|
27
|
+
}
|
|
28
|
+
export type NormalizePathResult = NormalizePathOk | NormalizePathDynamic;
|
|
29
|
+
export interface NormalizePathOptions {
|
|
30
|
+
/**
|
|
31
|
+
* Hosts whose absolute URLs are treated as same-origin: their path
|
|
32
|
+
* portion is canonicalized. Hosts are matched case-insensitively and
|
|
33
|
+
* without port normalization (declare exactly what you deploy).
|
|
34
|
+
*/
|
|
35
|
+
sameOriginHosts?: readonly string[];
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Normalizes one raw path expression to canonical positional form.
|
|
39
|
+
*
|
|
40
|
+
* Rules (ADR 0004 D2), applied in order:
|
|
41
|
+
* 1. strip query (`?...`) and fragment (`#...`);
|
|
42
|
+
* 2. collapse duplicate slashes, ensure one leading slash, strip trailing
|
|
43
|
+
* slashes (root `/` stays `/`);
|
|
44
|
+
* 3. `${...}` template expressions become `{}`;
|
|
45
|
+
* 4. FastAPI `{name}` / `{name:type}` params become `{}` (names are not
|
|
46
|
+
* identity); `{name:path}` converters become `{*}`;
|
|
47
|
+
* 5. bare `*` / `*name` catch-all segments become `{*}`;
|
|
48
|
+
* 6. absolute URLs canonicalize only for configured same-origin hosts —
|
|
49
|
+
* anything else is a typed dynamic outcome, never host-stripped;
|
|
50
|
+
* 7. `..` escape segments are rejected.
|
|
51
|
+
*/
|
|
52
|
+
export declare function normalizeHttpPath(rawPath: string, options?: NormalizePathOptions): NormalizePathResult;
|
|
53
|
+
/**
|
|
54
|
+
* Normalizes one raw method expression. Returns `null` for dynamic or
|
|
55
|
+
* unknown methods — the caller must emit `HTTP_METHOD_DYNAMIC`; defaulting
|
|
56
|
+
* to `GET` is forbidden (ADR 0004 D2 rule 7).
|
|
57
|
+
*/
|
|
58
|
+
export declare function normalizeHttpMethod(rawMethod: string): HttpMethod | null;
|
|
59
|
+
/** Splits a canonical path into segments (empty segments dropped). */
|
|
60
|
+
export declare function pathSegments(canonicalPath: string): string[];
|
|
61
|
+
//# sourceMappingURL=normalize.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"normalize.d.ts","sourceRoot":"","sources":["../src/normalize.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAGH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C,0DAA0D;AAC1D,eAAO,MAAM,eAAe,OAAO,CAAC;AAEpC,kFAAkF;AAClF,eAAO,MAAM,kBAAkB,QAAQ,CAAC;AAExC,eAAO,MAAM,oBAAoB,sBAAoB,CAAC;AAEtD,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,IAAI,CAAC;IACT,yGAAyG;IACzG,SAAS,EAAE,MAAM,CAAC;IAClB,4EAA4E;IAC5E,aAAa,EAAE,OAAO,CAAC;CACxB;AAED,MAAM,WAAW,oBAAoB;IACnC,EAAE,EAAE,KAAK,CAAC;IACV,IAAI,EAAE,OAAO,oBAAoB,CAAC;IAClC,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,MAAM,mBAAmB,GAAG,eAAe,GAAG,oBAAoB,CAAC;AAEzE,MAAM,WAAW,oBAAoB;IACnC;;;;OAIG;IACH,eAAe,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACrC;AAID;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,oBAAyB,GACjC,mBAAmB,CAwErB;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,UAAU,GAAG,IAAI,CAKxE;AAED,sEAAsE;AACtE,wBAAgB,YAAY,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,EAAE,CAE5D"}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical HTTP path and method normalization (ADR 0004 D2).
|
|
3
|
+
*
|
|
4
|
+
* Total and deterministic: the same input always yields the same result,
|
|
5
|
+
* and the canonical form is invariant under parameter-name changes
|
|
6
|
+
* (`/accounts/{account_id}` and `/accounts/{id}` both canonicalize to
|
|
7
|
+
* `/accounts/{}`). There is no fuzzy matching here — unresolvable shapes
|
|
8
|
+
* return a typed `HTTP_PATH_DYNAMIC` outcome instead of a best effort.
|
|
9
|
+
*/
|
|
10
|
+
import { HTTP_PATH_DYNAMIC } from './codes.js';
|
|
11
|
+
/** Canonical single-segment positional parameter slot. */
|
|
12
|
+
export const HTTP_PARAM_SLOT = '{}';
|
|
13
|
+
/** Canonical wildcard slot (FastAPI `{name:path}` converters, `*` catch-alls). */
|
|
14
|
+
export const HTTP_WILDCARD_SLOT = '{*}';
|
|
15
|
+
export const NormalizeDynamicCode = HTTP_PATH_DYNAMIC;
|
|
16
|
+
const ABSOLUTE_URL_RE = /^https?:\/\/([^/?#\s]+)/i;
|
|
17
|
+
/**
|
|
18
|
+
* Normalizes one raw path expression to canonical positional form.
|
|
19
|
+
*
|
|
20
|
+
* Rules (ADR 0004 D2), applied in order:
|
|
21
|
+
* 1. strip query (`?...`) and fragment (`#...`);
|
|
22
|
+
* 2. collapse duplicate slashes, ensure one leading slash, strip trailing
|
|
23
|
+
* slashes (root `/` stays `/`);
|
|
24
|
+
* 3. `${...}` template expressions become `{}`;
|
|
25
|
+
* 4. FastAPI `{name}` / `{name:type}` params become `{}` (names are not
|
|
26
|
+
* identity); `{name:path}` converters become `{*}`;
|
|
27
|
+
* 5. bare `*` / `*name` catch-all segments become `{*}`;
|
|
28
|
+
* 6. absolute URLs canonicalize only for configured same-origin hosts —
|
|
29
|
+
* anything else is a typed dynamic outcome, never host-stripped;
|
|
30
|
+
* 7. `..` escape segments are rejected.
|
|
31
|
+
*/
|
|
32
|
+
export function normalizeHttpPath(rawPath, options = {}) {
|
|
33
|
+
if (typeof rawPath !== 'string' || rawPath.length === 0) {
|
|
34
|
+
return dynamic('path is empty');
|
|
35
|
+
}
|
|
36
|
+
let working = rawPath;
|
|
37
|
+
const absolute = ABSOLUTE_URL_RE.exec(working);
|
|
38
|
+
if (absolute !== null) {
|
|
39
|
+
const host = (absolute[1] ?? '').toLowerCase();
|
|
40
|
+
const allowed = (options.sameOriginHosts ?? []).some((candidate) => candidate.toLowerCase() === host);
|
|
41
|
+
if (!allowed) {
|
|
42
|
+
return dynamic(`absolute URL host '${host}' is not a configured same-origin host`);
|
|
43
|
+
}
|
|
44
|
+
const pathStart = working.indexOf('/', absolute.index + absolute[0].length - host.length);
|
|
45
|
+
working = pathStart === -1 ? '/' : working.slice(pathStart);
|
|
46
|
+
}
|
|
47
|
+
else if (!working.startsWith('/')) {
|
|
48
|
+
// Relative expressions have no statically knowable base; resolving
|
|
49
|
+
// them would be a guess (ADR 0004 D2 rule 6).
|
|
50
|
+
return dynamic(`path '${rawPath}' is neither path-absolute nor a configured same-origin URL`);
|
|
51
|
+
}
|
|
52
|
+
// 1. query/fragment strip (first occurrence wins; inside-template edge
|
|
53
|
+
// cases are the detector's responsibility — facts carry resolved paths).
|
|
54
|
+
const queryStart = findFirstOutsideTemplate(working, ['?', '#']);
|
|
55
|
+
if (queryStart !== -1) {
|
|
56
|
+
working = working.slice(0, queryStart);
|
|
57
|
+
}
|
|
58
|
+
// 2. slash normalization. Trailing slashes are NOT significant: runtime
|
|
59
|
+
// frameworks treat `/api/v2/accounts` and `/api/v2/accounts/` as the
|
|
60
|
+
// same resource (routers normalize or redirect-accept the variant),
|
|
61
|
+
// so the canonical form strips them and the static join mirrors that
|
|
62
|
+
// equivalence — a frontend call written with a trailing slash still
|
|
63
|
+
// joins the route declared without one. The root `/` is the bare
|
|
64
|
+
// resource and stays.
|
|
65
|
+
working = working.replace(/\/{2,}/g, '/');
|
|
66
|
+
if (!working.startsWith('/')) {
|
|
67
|
+
working = `/${working}`;
|
|
68
|
+
}
|
|
69
|
+
if (working.length > 1) {
|
|
70
|
+
working = working.replace(/\/+$/, '');
|
|
71
|
+
}
|
|
72
|
+
if (working === '') {
|
|
73
|
+
working = '/';
|
|
74
|
+
}
|
|
75
|
+
// 3. template expressions -> positional slots.
|
|
76
|
+
working = working.replace(/\$\{[^}]*\}/g, HTTP_PARAM_SLOT);
|
|
77
|
+
// 4/5. FastAPI params, typed converters, catch-all segments.
|
|
78
|
+
const segments = working.split('/').map((segment) => {
|
|
79
|
+
if (segment === '*' || /^\*[\w:.-]*$/.test(segment))
|
|
80
|
+
return HTTP_WILDCARD_SLOT;
|
|
81
|
+
const param = /^\{([^{}]+)\}$/.exec(segment);
|
|
82
|
+
if (param === null)
|
|
83
|
+
return segment;
|
|
84
|
+
// `{name:path}` converters absorb one-or-more trailing segments;
|
|
85
|
+
// every other `{name}` / `{name:type}` form is a single positional slot.
|
|
86
|
+
return (param[1] ?? '').endsWith(':path') ? HTTP_WILDCARD_SLOT : HTTP_PARAM_SLOT;
|
|
87
|
+
});
|
|
88
|
+
working = segments.join('/');
|
|
89
|
+
// 7. escape rejection — after normalization so the message quotes the
|
|
90
|
+
// canonical shape the detector would have relied on.
|
|
91
|
+
if (segments.includes('..')) {
|
|
92
|
+
return dynamic(`path '${rawPath}' contains a '..' escape segment`);
|
|
93
|
+
}
|
|
94
|
+
const hasParameters = working.includes(HTTP_PARAM_SLOT) || working.includes(HTTP_WILDCARD_SLOT);
|
|
95
|
+
return { ok: true, canonical: working, hasParameters };
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Normalizes one raw method expression. Returns `null` for dynamic or
|
|
99
|
+
* unknown methods — the caller must emit `HTTP_METHOD_DYNAMIC`; defaulting
|
|
100
|
+
* to `GET` is forbidden (ADR 0004 D2 rule 7).
|
|
101
|
+
*/
|
|
102
|
+
export function normalizeHttpMethod(rawMethod) {
|
|
103
|
+
const upper = rawMethod.trim().toUpperCase();
|
|
104
|
+
if (upper === 'ANY' || upper === '*')
|
|
105
|
+
return 'ANY';
|
|
106
|
+
if (HTTP_METHOD_SET.has(upper))
|
|
107
|
+
return upper;
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
/** Splits a canonical path into segments (empty segments dropped). */
|
|
111
|
+
export function pathSegments(canonicalPath) {
|
|
112
|
+
return canonicalPath.split('/').filter((segment) => segment !== '');
|
|
113
|
+
}
|
|
114
|
+
const HTTP_METHOD_SET = new Set([
|
|
115
|
+
'GET',
|
|
116
|
+
'HEAD',
|
|
117
|
+
'POST',
|
|
118
|
+
'PUT',
|
|
119
|
+
'PATCH',
|
|
120
|
+
'DELETE',
|
|
121
|
+
'OPTIONS',
|
|
122
|
+
]);
|
|
123
|
+
function dynamic(detail) {
|
|
124
|
+
return { ok: false, code: NormalizeDynamicCode, detail };
|
|
125
|
+
}
|
|
126
|
+
/** Finds the first index of any of `chars` outside a `${...}` template. */
|
|
127
|
+
function findFirstOutsideTemplate(input, chars) {
|
|
128
|
+
let depth = 0;
|
|
129
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
130
|
+
const char = input[index] ?? '';
|
|
131
|
+
if (char === '$' && input[index + 1] === '{') {
|
|
132
|
+
depth += 1;
|
|
133
|
+
index += 1;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (depth > 0 && char === '}') {
|
|
137
|
+
depth -= 1;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (depth === 0 && chars.includes(char))
|
|
141
|
+
return index;
|
|
142
|
+
}
|
|
143
|
+
return -1;
|
|
144
|
+
}
|
|
145
|
+
//# sourceMappingURL=normalize.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"normalize.js","sourceRoot":"","sources":["../src/normalize.ts"],"names":[],"mappings":"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"}
|