Skip to content

Commit 9885d99

Browse files
committed
better route definitions using regex named capture groups for path params (temporarily broken)
1 parent a096b84 commit 9885d99

File tree

4 files changed

+136
-76
lines changed

4 files changed

+136
-76
lines changed

packages/server/src/StateObject.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,7 @@ export class ServerRequestClass<
6464
) {
6565
super(streamer);
6666

67-
this.pathParams = Object.fromEntries<string | undefined>(routePath.map(r =>
68-
r.route.pathParams
69-
?.map((e, i) => [e, r.params[i]] as const)
70-
.filter(<T>(e: T): e is T & {} => !!e)
71-
?? []
72-
).flat()) as any;
67+
this.pathParams = routePath.reduce((n, e) => Object.assign(n, e.groups), {});
7368

7469
const pathParamsZodCheck = zod.record(zod.string(), zod.string().transform(zodURIComponent).optional()).safeParse(this.pathParams);
7570
if (!pathParamsZodCheck.success) console.log("BUG: Path params zod error", pathParamsZodCheck.error, this.pathParams);

packages/server/src/router.ts

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ export interface AllowedRequestedWithHeaderKeys {
1818
}
1919

2020
export class Router {
21-
allowedRequestedWithHeaders: (keyof AllowedRequestedWithHeaderKeys)[] = ["fetch", "XMLHttpRequest"];
21+
static allowedRequestedWithHeaders: AllowedRequestedWithHeaderKeys = {
22+
fetch: true,
23+
XMLHttpRequest: true,
24+
}
2225
constructor(
2326
public rootRoute: ServerRoute
2427
) {
@@ -101,7 +104,7 @@ export class Router {
101104
// If the method is not GET, HEAD, or OPTIONS,
102105
if (!["GET", "HEAD", "OPTIONS"].includes(streamer.method))
103106
// If the x-requested-with header is not set to "fetch",
104-
if (!reqwith || !this.allowedRequestedWithHeaders.includes(reqwith))
107+
if (!reqwith || !Router.allowedRequestedWithHeaders[reqwith])
105108
// we reject the request with a 403 Forbidden.
106109
throw new SendError("INVALID_X_REQUESTED_WITH", 400, null);
107110

@@ -219,10 +222,11 @@ export class Router {
219222
// Remove the matched portion from the testPath.
220223
const remainingPath = testPath.slice(matchedPortion.length) || "/";
221224

222-
const result = {
225+
const result: RouteMatch = {
223226
route: potentialRoute,
224227
params: match.slice(1),
225228
remainingPath,
229+
groups: match.groups ?? {},
226230
};
227231
const { childRoutes = [] } = potentialRoute as any; // see this.defineRoute
228232
debug(potentialRoute.path.source, testPath, match[0], match[0].length, remainingPath, childRoutes.length);
@@ -280,6 +284,7 @@ export interface RouteMatch {
280284
route: ServerRoute;
281285
params: (string | undefined)[];
282286
remainingPath: string;
287+
groups: { [key: string]: string; };
283288
}
284289

285290

@@ -326,10 +331,13 @@ export interface RouteDef {
326331

327332
/**
328333
* Regex to test the pathname on. It must start with `^`. If this is a child route,
329-
* it will be tested against the remaining portion of the parent route.
334+
* it will be tested against the remaining portion of the parent route.
335+
* - Use named capture groups `(?<name>regex)` to set path params.
336+
* - Child route param names take precedent.
337+
* - Use lookahead `(?=\/)` at the end of the regex to make sure the matched portion ends with
338+
* a slash while still preserving the slash for the start of the child route.
330339
*/
331340
path: RegExp;
332-
pathParams?: string[];
333341
/**
334342
* The uppercase method names to match this route.
335343
*
@@ -339,20 +347,24 @@ export interface RouteDef {
339347
*/
340348
method: string[];
341349
/**
342-
* The highest bodyformat in the chain always takes precedent. Type-wise, only one is allowed,
343-
* but at runtime the first one found is the one used.
350+
* The highest bodyformat in the chain always takes precedent.
351+
* At runtime the first one found is the one used.
344352
*
345-
* Note that bodyFormat is completely ignored for GET and HEAD requests.
353+
* Note that bodyFormat is always "ignore" for GET and HEAD requests.
346354
*/
347355
bodyFormat?: BodyFormat;
348-
/** If this route is the last one matched, it will NOT be called, and a 404 will be returned. */
356+
/**
357+
* If this route is matched, but no children are matched,
358+
* it will NOT be called, and a 404 will be returned.
359+
*/
349360
denyFinal?: boolean;
350361

351362
securityChecks?: {
352363
/**
353-
* If true, the request must have the "x-requested-with" header set to keyof AllowedRequestedWithHeaderKeys.
364+
* If true, the request must have the "x-requested-with" header
365+
* set to keyof AllowedRequestedWithHeaderKeys.
354366
* This is a common way to check if the request is an AJAX request.
355-
* If the header is not set, the request will be rejected with a 403 Forbidden.
367+
* If this is true and the header is not set, the request will be rejected with a 403 Forbidden.
356368
*
357369
* @see {@link AllowedRequestedWithHeaderKeys}
358370
*/

packages/server/src/zodRegister.ts

Lines changed: 78 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -10,66 +10,86 @@ const debugCORS = Debug("mws:cors");
1010

1111

1212

13-
export const registerZodRoutes = (parent: ServerRoute, router: any, keys: string[]) => {
14-
// const router = new TiddlerRouter();
15-
keys.forEach((key) => {
16-
const route = router[key as keyof typeof router] as ZodRoute<any, any, any, any, any, any>;
17-
const {
18-
method, path, bodyFormat, registerError,
19-
zodPathParams,
20-
zodQueryParams = (z => ({}) as any),
21-
zodRequestBody = ["string", "json", "www-form-urlencoded"].includes(bodyFormat)
22-
? z => z.undefined() : (z => z.any() as any),
23-
inner,
24-
securityChecks,
25-
} = route;
26-
27-
if (method.includes("OPTIONS"))
28-
throw new Error(key + " includes OPTIONS. Use corsRequest instead.");
29-
30-
const pathParams = path.split("/").filter(e => e.startsWith(":")).map(e => e.substring(1));
31-
///^\/recipes\/([^\/]+)\/tiddlers\/(.+)$/,
32-
if (!path.startsWith("/")) throw new Error(`Path ${path} must start with a forward slash`);
33-
if (key.startsWith(":")) throw new Error(`Key ${key} must not start with a colon`)
34-
const pathregex = "^" + path.split("/").map(e =>
35-
e === "$key" ? key : e.startsWith(":") ? "([^/]+)" : e
36-
).join("\\/") + "$";
37-
38-
parent.defineRoute({
39-
method,
40-
path: new RegExp(pathregex),
41-
pathParams,
42-
bodyFormat,
43-
denyFinal: false,
44-
securityChecks,
45-
}, async state => {
46-
47-
checkPath(state, zodPathParams, registerError);
48-
49-
checkQuery(state, zodQueryParams, registerError);
50-
51-
checkData(state, zodRequestBody, registerError);
52-
53-
const timekey = `handler ${state.bodyFormat} ${state.method} ${state.urlInfo.pathname}`;
54-
if (Debug.enabled("server:handler:timing")) console.time(timekey);
55-
const [good, error, res] = await inner(state)
56-
.then(e => [true, undefined, e] as const, e => [false, e, undefined] as const);
57-
if (Debug.enabled("server:handler:timing")) console.timeEnd(timekey);
58-
59-
if (!good) {
60-
if (error === STREAM_ENDED) {
61-
return error;
62-
} else if (typeof error === "string") {
63-
return state.sendString(400, { "x-reason": "zod-handler" }, error, "utf8");
64-
} else if (error instanceof Error && error.name === "UserError") {
65-
return state.sendString(400, { "x-reason": "user-error" }, error.message, "utf8");
66-
} else {
67-
throw error;
68-
}
13+
export const registerZodRoutes = (parent: ServerRoute, router: any, keys: string[], keyReplacer: string = "$key") => {
14+
return keys.map((key) => {
15+
defineZodRoute(parent, key, keyReplacer, router[key]);
16+
});
17+
}
18+
19+
function buildPathRegex(path: string, key: string, keyReplacer: string) {
20+
if (!path.startsWith("/")) throw new Error(`Path ${path} must start with a forward slash`);
21+
if (key.startsWith(":")) throw new Error(`Key ${key} must not start with a colon`)
22+
if (path !== path.trim()) throw new Error(`Path ${path} must not have leading and trailing white space or line terminator characters`);
23+
const parts = path.split("/");
24+
const final = path.endsWith("/");
25+
return "^" + parts.map((e, i) => {
26+
27+
const last = i === parts.length - 1;
28+
if (e.length === 0) {
29+
if (!last && i !== 0) throw new Error(`Path ${path} has an empty part at index ${i}`);
30+
return "";
31+
}
32+
const name = e.startsWith(":") && e.slice(1);
33+
if (e === keyReplacer) return key;
34+
if (!name) return e;
35+
return (last && final) ? `(?<${name}>.+)` : `(?<${name}>[^/]+)`;
36+
}).join("\\/") + (final ? "$" : "(?=\/)");
37+
}
38+
39+
export function defineZodRoute(
40+
parent: ServerRoute,
41+
key: string,
42+
keyReplacer: string,
43+
route: ZodRoute<any, any, any, any, any, any>
44+
) {
45+
const {
46+
method, path, bodyFormat, registerError,
47+
zodPathParams,
48+
zodQueryParams = (z => ({}) as any),
49+
zodRequestBody = ["string", "json", "www-form-urlencoded"].includes(bodyFormat)
50+
? z => z.undefined() : (z => z.any() as any),
51+
inner,
52+
securityChecks,
53+
} = route;
54+
55+
if (method.includes("OPTIONS"))
56+
throw new Error(key + " includes OPTIONS. Use corsRequest instead.");
57+
58+
const pathregex = typeof path === "string" ? buildPathRegex(path, key, keyReplacer) : path;
59+
60+
return parent.defineRoute({
61+
method,
62+
path: new RegExp(pathregex),
63+
bodyFormat,
64+
denyFinal: false,
65+
securityChecks,
66+
}, async state => {
67+
68+
checkPath(state, zodPathParams, registerError);
69+
70+
checkQuery(state, zodQueryParams, registerError);
71+
72+
checkData(state, zodRequestBody, registerError);
73+
74+
const timekey = `handler ${state.bodyFormat} ${state.method} ${state.urlInfo.pathname}`;
75+
if (Debug.enabled("server:handler:timing")) console.time(timekey);
76+
const [good, error, res] = await inner(state)
77+
.then(e => [true, undefined, e] as const, e => [false, e, undefined] as const);
78+
if (Debug.enabled("server:handler:timing")) console.timeEnd(timekey);
79+
80+
if (!good) {
81+
if (error === STREAM_ENDED) {
82+
return error;
83+
} else if (typeof error === "string") {
84+
return state.sendString(400, { "x-reason": "zod-handler" }, error, "utf8");
85+
} else if (error instanceof Error && error.name === "UserError") {
86+
return state.sendString(400, { "x-reason": "user-error" }, error.message, "utf8");
87+
} else {
88+
throw error;
6989
}
90+
}
7091

71-
return state.sendJSON(200, res);
72-
});
92+
return state.sendJSON(200, res);
7393
});
7494
}
7595

packages/server/src/zodRoute.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,40 @@ export interface ZodRoute<
7474

7575
securityChecks?: RouteDef["securityChecks"];
7676
method: M[];
77-
path: string;
77+
/**
78+
* The filter to match requests to. It can either be a string or a regex.
79+
*
80+
* The following string is converted to the following regex. Notice the last param entirely
81+
* matches the rest of the path, and it may be zero length (the url may be `/literal/test/hello/world/`)
82+
* - `/literal/:param1/hello/world/:param2`
83+
* - `/^\/literal\/(?<param1>[^\/]+)\/literal\/literal\/(?<param2>.*)$/`
84+
*
85+
* The following string is converted to the following regex to allow child routes to
86+
* be nested under it. The regex uses the lookahead group syntax to ensure that
87+
* the child route begins with a forward slash and isn't a partial match.
88+
*
89+
* - `/literal/:param1/hello/world/:param2/`
90+
* - `/^\/literal\/(?<param1>[^\/]+)\/literal\/literal\/(?<param2>[^\/]+)(?=\/)/`
91+
*
92+
* Regex must start with `^`. The matched portion is removed from child route matches.
93+
*
94+
*
95+
* If the last portion of the string is a param, and is not followed by a slash,
96+
* it will match the rest of the request path. If it ends with a slash, the matching
97+
* portion of the request path will be consumed and child routes will match the remaining
98+
* portion.
99+
*
100+
* So `/literal//hello/world/` would be invalid, but `/literal/test/hello/world/` would match.
101+
*
102+
* The regex is not checked in any way, so children will be matched against the
103+
* remaining portion of the path. Lookaheads may be used to match portions of the url
104+
* without capturing the path, such as a trailing slash (`/^\/test(?=\/)`)
105+
*
106+
* Name collisions are possible between parent and child routes. The child route takes precedent,
107+
* and the parent route handler will see the child path param value.
108+
*
109+
*/
110+
path: string | RegExp;
78111
bodyFormat: B;
79112
registerError: Error;
80113
inner: (state: { [K in M]: ZodState<K, B, P, Q, T> }[M]) => Promise<R>;

0 commit comments

Comments
 (0)