a minimal web framework for deno

test: add comprehensive trie router test suite and fix wildcard types

+302 -41
+5 -1
src/router.ts
··· 54 54 metadata?: RouteMetadata, 55 55 ): void; 56 56 57 - group(prefix: string, configure: (router: Router) => void): Router; 57 + group(prefix: string, configure: (router: ExtendedRouter) => void): this; 58 58 59 59 fetch( 60 60 request: Request, ··· 223 223 for (const child of node.children) { 224 224 if (child.optional && child.handler && child.route) { 225 225 return { handler: child.handler, params, route: child.route }; 226 + } 227 + if (child.type === NodeType.WILDCARD && child.handler && child.route) { 228 + const newParams = { ...params, [child.paramName || "*"]: "" }; 229 + return { handler: child.handler, params: newParams, route: child.route }; 226 230 } 227 231 } 228 232 return null;
+294 -39
src/test.ts
··· 42 42 assertEquals(result, "session=mraow; Max-Age=3600; Secure; HttpOnly; SameSite=Lax"); 43 43 }); 44 44 45 - Deno.test("basic routing", async (t) => { 46 - await t.step("matches routes correctly", async () => { 45 + Deno.test("trie routing: static routes", async (t) => { 46 + await t.step("matches exact static paths", async () => { 47 + const router = createRouter(); 48 + router.get("/cats", (ctx) => ctx.json({ page: "meow" })); 49 + router.get("/posts", (ctx) => ctx.json({ page: "posts" })); 50 + 51 + const res1 = await router.fetch(new Request("http://localhost/cats"), mockInfo); 52 + assertEquals(res1.status, 200); 53 + assertEquals(await res1.json(), { page: "meow" }); 54 + 55 + const res2 = await router.fetch(new Request("http://localhost/posts"), mockInfo); 56 + assertEquals(res2.status, 200); 57 + assertEquals(await res2.json(), { page: "posts" }); 58 + }); 59 + 60 + await t.step("distinguishes between similar paths", async () => { 61 + const router = createRouter(); 62 + router.get("/user", (ctx) => ctx.json({ type: "single" })); 63 + router.get("/users", (ctx) => ctx.json({ type: "plural" })); 64 + router.get("/user/profile", (ctx) => ctx.json({ type: "profile" })); 65 + 66 + const res1 = await router.fetch(new Request("http://localhost/user"), mockInfo); 67 + assertEquals(await res1.json(), { type: "single" }); 68 + 69 + const res2 = await router.fetch(new Request("http://localhost/users"), mockInfo); 70 + assertEquals(await res2.json(), { type: "plural" }); 71 + 72 + const res3 = await router.fetch(new Request("http://localhost/user/profile"), mockInfo); 73 + assertEquals(await res3.json(), { type: "profile" }); 74 + }); 75 + 76 + await t.step("handles trailing slashes correctly", async () => { 77 + const router = createRouter(); 78 + router.get("/api/users", (ctx) => ctx.json({ ok: true })); 79 + 80 + const res1 = await router.fetch(new Request("http://localhost/api/users"), mockInfo); 81 + assertEquals(res1.status, 200); 82 + 83 + const res2 = await router.fetch(new Request("http://localhost/api/users/"), mockInfo); 84 + assertEquals(res2.status, 200); 85 + }); 86 + 87 + await t.step("handles root path", async () => { 88 + const router = createRouter(); 89 + router.get("/", (ctx) => ctx.json({ root: true })); 90 + 91 + const res = await router.fetch(new Request("http://localhost/"), mockInfo); 92 + assertEquals(res.status, 200); 93 + assertEquals(await res.json(), { root: true }); 94 + }); 95 + }); 96 + 97 + Deno.test("trie routing: dynamic parameters", async (t) => { 98 + await t.step("extracts single parameter", async () => { 99 + const router = createRouter(); 100 + router.get("/users/:id", (ctx) => ctx.json({ id: ctx.params.id })); 101 + 102 + const res = await router.fetch(new Request("http://localhost/users/42"), mockInfo); 103 + assertEquals(res.status, 200); 104 + assertEquals(await res.json(), { id: "42" }); 105 + }); 106 + 107 + await t.step("extracts multiple parameters", async () => { 108 + const router = createRouter(); 109 + router.get( 110 + "/cats/:feline/meow/:mrrp", 111 + (ctx) => ctx.json({ feline: ctx.params.feline, mrrp: ctx.params.mrrp }), 112 + ); 113 + 114 + const res = await router.fetch(new Request("http://localhost/cats/123/meow/456"), mockInfo); 115 + assertEquals(await res.json(), { feline: "123", mrrp: "456" }); 116 + }); 117 + 118 + await t.step("decodes URL-encoded parameters", async () => { 119 + const router = createRouter(); 120 + router.get("/search/:query", (ctx) => ctx.json({ query: ctx.params.query })); 121 + 122 + const res = await router.fetch( 123 + new Request("http://localhost/search/hello%20world"), 124 + mockInfo, 125 + ); 126 + assertEquals(await res.json(), { query: "hello world" }); 127 + }); 128 + 129 + await t.step("handles special characters in params", async () => { 47 130 const router = createRouter(); 48 - router.get("/xd/:id", (ctx) => { 49 - return ctx.json({ id: ctx.params.id }); 50 - }); 131 + router.get("/file/:name", (ctx) => ctx.json({ name: ctx.params.name })); 51 132 52 - const response = await router.fetch( 53 - new Request("http://localhost/xd/123"), 133 + const res = await router.fetch( 134 + new Request("http://localhost/file/test%26file.txt"), 54 135 mockInfo, 55 136 ); 137 + assertEquals(await res.json(), { name: "test&file.txt" }); 138 + }); 139 + }); 56 140 57 - assertEquals(response.status, 200); 58 - const body = await response.json(); 59 - assertEquals(body.id, "123"); 141 + Deno.test("trie routing: optional parameters", async (t) => { 142 + await t.step("matches with optional param present", async () => { 143 + const router = createRouter(); 144 + router.get("/posts/:id?", (ctx) => ctx.json({ id: ctx.params.id || null })); 145 + 146 + const res = await router.fetch(new Request("http://localhost/posts/123"), mockInfo); 147 + assertEquals(await res.json(), { id: "123" }); 148 + }); 149 + 150 + await t.step("matches with optional param absent", async () => { 151 + const router = createRouter(); 152 + router.get("/posts/:id?", (ctx) => ctx.json({ id: ctx.params.id || null })); 153 + 154 + const res = await router.fetch(new Request("http://localhost/posts"), mockInfo); 155 + assertEquals(await res.json(), { id: null }); 156 + }); 157 + }); 158 + 159 + Deno.test("trie routing: wildcard routes", async (t) => { 160 + await t.step("captures remaining path segments", async () => { 161 + const router = createRouter(); 162 + router.get("/files/*", (ctx) => ctx.json({ path: ctx.params["*"] })); 163 + 164 + const res = await router.fetch( 165 + new Request("http://localhost/files/docs/api/readme.md"), 166 + mockInfo, 167 + ); 168 + assertEquals(await res.json(), { path: "docs/api/readme.md" }); 169 + }); 170 + 171 + await t.step("wildcard with empty path", async () => { 172 + const router = createRouter(); 173 + router.get("/static/*", (ctx) => ctx.json({ path: ctx.params["*"] || "" })); 174 + 175 + const res = await router.fetch(new Request("http://localhost/static/"), mockInfo); 176 + assertEquals(await res.json(), { path: "" }); 177 + }); 178 + }); 179 + 180 + Deno.test("trie routing: priority and precedence", async (t) => { 181 + await t.step("static routes take precedence over params", async () => { 182 + const router = createRouter(); 183 + router.get("/users/new", (ctx) => ctx.json({ type: "new" })); 184 + router.get("/users/:id", (ctx) => ctx.json({ type: "id", id: ctx.params.id })); 185 + 186 + const res1 = await router.fetch(new Request("http://localhost/users/new"), mockInfo); 187 + assertEquals(await res1.json(), { type: "new" }); 188 + 189 + const res2 = await router.fetch(new Request("http://localhost/users/123"), mockInfo); 190 + assertEquals(await res2.json(), { type: "id", id: "123" }); 191 + }); 192 + 193 + await t.step("params take precedence over wildcards", async () => { 194 + const router = createRouter(); 195 + router.get("/api/:resource", (ctx) => ctx.json({ type: "param", resource: ctx.params.resource })); 196 + router.get("/api/*", (ctx) => ctx.json({ type: "wildcard", path: ctx.params["*"] })); 197 + 198 + const res = await router.fetch(new Request("http://localhost/api/users"), mockInfo); 199 + assertEquals(await res.json(), { type: "param", resource: "users" }); 200 + }); 201 + 202 + await t.step("registration order doesn't affect precedence", async () => { 203 + const router = createRouter(); 204 + 205 + router.get("/users/:id", (ctx) => ctx.json({ type: "param" })); 206 + router.get("/users/admin", (ctx) => ctx.json({ type: "static" })); 207 + 208 + const res = await router.fetch(new Request("http://localhost/users/admin"), mockInfo); 209 + assertEquals(await res.json(), { type: "static" }); 210 + }); 211 + }); 212 + 213 + Deno.test("trie routing: edge cases", async (t) => { 214 + await t.step("handles empty path segments", async () => { 215 + const router = createRouter(); 216 + router.get("/api//users", (ctx) => ctx.json({ ok: true })); 217 + 218 + const res = await router.fetch(new Request("http://localhost/api//users"), mockInfo); 219 + assertEquals(res.status, 200); 60 220 }); 61 221 62 - await t.step("returns 404 for unmatched routes", async () => { 222 + await t.step("handles deeply nested routes", async () => { 63 223 const router = createRouter(); 64 - router.get("/only", (ctx) => ctx.text("ok")); 224 + router.get("/a/b/c/d/e/f/g", (ctx) => ctx.json({ depth: 7 })); 225 + 226 + const res = await router.fetch(new Request("http://localhost/a/b/c/d/e/f/g"), mockInfo); 227 + assertEquals(await res.json(), { depth: 7 }); 228 + }); 65 229 66 - const response = await router.fetch(new Request("http://localhost/unreal"), mockInfo); 67 - assertEquals(response.status, 404); 68 - const body = await response.json(); 69 - assertEquals(body.error, "Not Found"); 230 + await t.step("handles routes with many parameters", async () => { 231 + const router = createRouter(); 232 + router.get("/:a/:b/:c/:d/:e", (ctx) => 233 + ctx.json({ 234 + a: ctx.params.a, 235 + b: ctx.params.b, 236 + c: ctx.params.c, 237 + d: ctx.params.d, 238 + e: ctx.params.e, 239 + })); 240 + 241 + const res = await router.fetch(new Request("http://localhost/1/2/3/4/5"), mockInfo); 242 + assertEquals(await res.json(), { a: "1", b: "2", c: "3", d: "4", e: "5" }); 243 + }); 244 + 245 + await t.step("404 for non-existent routes", async () => { 246 + const router = createRouter(); 247 + router.get("/users", (ctx) => ctx.json({ ok: true })); 248 + 249 + const res = await router.fetch(new Request("http://localhost/posts"), mockInfo); 250 + assertEquals(res.status, 404); 70 251 }); 71 252 72 - await t.step("lists all routes via allRoutes()", () => { 253 + await t.step("handles malformed URL encoding gracefully", async () => { 73 254 const router = createRouter(); 74 - router.get("/users", () => {}); 75 - router.post("/users", () => {}); 255 + router.get("/search/:query", (ctx) => ctx.json({ query: ctx.params.query })); 76 256 77 - const routes = router.allRoutes(); 78 - assertEquals(routes.length, 2); 79 - assertEquals(routes[0].method, "GET"); 80 - assertEquals(routes[1].method, "POST"); 257 + // Invalid percent encoding - should not crash 258 + const res = await router.fetch(new Request("http://localhost/search/%ZZ"), mockInfo); 259 + assertEquals(res.status, 200); 260 + const body = await res.json(); 261 + assertEquals(body.query, "%ZZ"); // Should keep original if decode fails 81 262 }); 263 + }); 82 264 83 - await t.step("decodes route params properly", async () => { 265 + Deno.test("trie routing: method isolation", async (t) => { 266 + await t.step("different methods on same path", async () => { 84 267 const router = createRouter(); 268 + router.get("/resource", (ctx) => ctx.json({ method: "GET" })); 269 + router.post("/resource", (ctx) => ctx.json({ method: "POST" })); 270 + router.delete("/resource", (ctx) => ctx.json({ method: "DELETE" })); 85 271 86 - router.get("/bleh/:name/:artist", (ctx) => { 87 - return ctx.json({ 88 - name: ctx.params.name, 89 - artist: ctx.params.artist, 272 + const get = await router.fetch(new Request("http://localhost/resource"), mockInfo); 273 + assertEquals(await get.json(), { method: "GET" }); 274 + 275 + const post = await router.fetch( 276 + new Request("http://localhost/resource", { method: "POST" }), 277 + mockInfo, 278 + ); 279 + assertEquals(await post.json(), { method: "POST" }); 280 + 281 + const del = await router.fetch( 282 + new Request("http://localhost/resource", { method: "DELETE" }), 283 + mockInfo, 284 + ); 285 + assertEquals(await del.json(), { method: "DELETE" }); 286 + }); 287 + 288 + await t.step("405 not implemented for method", async () => { 289 + const router = createRouter(); 290 + router.get("/users", (ctx) => ctx.json({ ok: true })); 291 + 292 + const res = await router.fetch( 293 + new Request("http://localhost/users", { method: "POST" }), 294 + mockInfo, 295 + ); 296 + assertEquals(res.status, 404); // No POST handler = 404 297 + }); 298 + }); 299 + 300 + Deno.test("trie routing: route groups", async (t) => { 301 + await t.step("applies prefix to grouped routes", async () => { 302 + const router = createRouter(); 303 + router.group("/api", (api) => { 304 + api.get("/users", (ctx) => ctx.json({ group: "api" })); 305 + api.get("/posts", (ctx) => ctx.json({ group: "api" })); 306 + }); 307 + 308 + const res1 = await router.fetch(new Request("http://localhost/api/users"), mockInfo); 309 + assertEquals(await res1.json(), { group: "api" }); 310 + 311 + const res2 = await router.fetch(new Request("http://localhost/api/posts"), mockInfo); 312 + assertEquals(await res2.json(), { group: "api" }); 313 + }); 314 + 315 + await t.step("nested groups", async () => { 316 + const router = createRouter(); 317 + router.group("/api", (api) => { 318 + api.group("/v1", (v1) => { 319 + v1.get("/users", (ctx) => ctx.json({ version: "v1" })); 90 320 }); 91 321 }); 92 322 93 - const url = "http://localhost/bleh/Song%20Name/Artist%20%26%20Band"; 94 - const res = await router.fetch(new Request(url), mockInfo); 323 + const res = await router.fetch(new Request("http://localhost/api/v1/users"), mockInfo); 324 + assertEquals(await res.json(), { version: "v1" }); 325 + }); 326 + }); 327 + 328 + Deno.test("trie routing: performance characteristics", async (t) => { 329 + await t.step("scales with many routes", async () => { 330 + const router = createRouter(); 331 + 332 + for (let i = 0; i < 1000; i++) { 333 + router.get(`/route${i}`, (ctx) => ctx.json({ route: i })); 334 + } 335 + 336 + const start = performance.now(); 337 + const res = await router.fetch(new Request("http://localhost/route999"), mockInfo); 338 + const elapsed = performance.now() - start; 95 339 96 340 assertEquals(res.status, 200); 97 - 98 - const body = await res.json(); 99 - assertEquals(body.name, "Song Name"); 100 - assertEquals(body.artist, "Artist & Band"); 341 + assertEquals(await res.json(), { route: 999 }); 342 + assertEquals(elapsed < 25, true, `Routing took ${elapsed}ms, expected < 25ms`); 101 343 }); 102 344 103 - await t.step("handles non-encoded params correctly", async () => { 345 + await t.step("efficient param extraction", async () => { 104 346 const router = createRouter(); 105 - router.get("/user/:id", (ctx) => ctx.json({ id: ctx.params.id })); 347 + router.get("/a/:p1/b/:p2/c/:p3/d/:p4/e/:p5", (ctx) => 348 + ctx.json({ 349 + p1: ctx.params.p1, 350 + p2: ctx.params.p2, 351 + p3: ctx.params.p3, 352 + p4: ctx.params.p4, 353 + p5: ctx.params.p5, 354 + })); 106 355 107 - const res = await router.fetch(new Request("http://localhost/user/123"), mockInfo); 108 - const body = await res.json(); 109 - assertEquals(body.id, "123"); 356 + const start = performance.now(); 357 + const res = await router.fetch( 358 + new Request("http://localhost/a/1/b/2/c/3/d/4/e/5"), 359 + mockInfo, 360 + ); 361 + const elapsed = performance.now() - start; 362 + 363 + assertEquals(await res.json(), { p1: "1", p2: "2", p3: "3", p4: "4", p5: "5" }); 364 + assertEquals(elapsed < 5, true, `Param extraction took ${elapsed}ms, expected < 5ms`); 110 365 }); 111 366 }); 112 367
+3 -1
src/utils.ts
··· 18 18 type ExtractParameterNames<S extends string> = S extends `${string}:${infer Param}/${infer Rest}` 19 19 ? Param | ExtractParameterNames<`/${Rest}`> 20 20 : S extends `${string}:${infer Param}` ? Param 21 + : S extends `${string}/*${infer _Rest}` ? "*" 21 22 : never; 22 23 23 24 type Skippable<S extends string, T> = S extends `${string}?` ? T | undefined ··· 27 28 28 29 /** 29 30 * constructs a typed object for route parameters 30 - * @example ParametersOf<"/cats/:id/meows/:meowId"> // { id: string; meowId: string } 31 + * @example ParametersOf<"/cats/:id/meows/:mrrp"> // { id: string; mrrp: string } 32 + * @example ParametersOf<"/bleh/*"> // { "*": string } 31 33 */ 32 34 export type ParametersOf<S extends string> = { 33 35 [K in ExtractParameterNames<S> as StripOptional<K>]: Skippable<K, string>;