testing and fixes to middleware matching
This commit is contained in:
parent
38b995ce8d
commit
a27883f859
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "BearMetal Router",
|
"name": "@bearmetal/router",
|
||||||
"description": "A simple router for Deno",
|
"description": "A simple router for Deno",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"stable": true,
|
"stable": true,
|
||||||
@ -8,5 +8,10 @@
|
|||||||
"mod.ts",
|
"mod.ts",
|
||||||
"README.md",
|
"README.md",
|
||||||
"LICENSE"
|
"LICENSE"
|
||||||
]
|
],
|
||||||
|
"exports": "./mod.ts",
|
||||||
|
"imports": {
|
||||||
|
"@std/assert": "jsr:@std/assert@^1.0.7",
|
||||||
|
"@std/testing": "jsr:@std/testing@^1.0.4"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
51
deno.lock
generated
Normal file
51
deno.lock
generated
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"version": "4",
|
||||||
|
"specifiers": {
|
||||||
|
"jsr:@std/assert@^1.0.7": "1.0.7",
|
||||||
|
"jsr:@std/data-structures@^1.0.4": "1.0.4",
|
||||||
|
"jsr:@std/fs@^1.0.5": "1.0.5",
|
||||||
|
"jsr:@std/internal@^1.0.5": "1.0.5",
|
||||||
|
"jsr:@std/path@^1.0.7": "1.0.8",
|
||||||
|
"jsr:@std/path@^1.0.8": "1.0.8",
|
||||||
|
"jsr:@std/testing@^1.0.4": "1.0.4"
|
||||||
|
},
|
||||||
|
"jsr": {
|
||||||
|
"@std/assert@1.0.7": {
|
||||||
|
"integrity": "64ce9fac879e0b9f3042a89b3c3f8ccfc9c984391af19e2087513a79d73e28c3",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@std/internal"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@std/data-structures@1.0.4": {
|
||||||
|
"integrity": "fa0e20c11eb9ba673417450915c750a0001405a784e2a4e0c3725031681684a0"
|
||||||
|
},
|
||||||
|
"@std/fs@1.0.5": {
|
||||||
|
"integrity": "41806ad6823d0b5f275f9849a2640d87e4ef67c51ee1b8fb02426f55e02fd44e",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@std/path@^1.0.7"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@std/internal@1.0.5": {
|
||||||
|
"integrity": "54a546004f769c1ac9e025abd15a76b6671ddc9687e2313b67376125650dc7ba"
|
||||||
|
},
|
||||||
|
"@std/path@1.0.8": {
|
||||||
|
"integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be"
|
||||||
|
},
|
||||||
|
"@std/testing@1.0.4": {
|
||||||
|
"integrity": "ca1368d720b183f572d40c469bb9faf09643ddd77b54f8b44d36ae6b94940576",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@std/assert",
|
||||||
|
"jsr:@std/data-structures",
|
||||||
|
"jsr:@std/fs",
|
||||||
|
"jsr:@std/internal",
|
||||||
|
"jsr:@std/path@^1.0.8"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"workspace": {
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@std/assert@^1.0.7",
|
||||||
|
"jsr:@std/testing@^1.0.4"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
223
mod.ts
223
mod.ts
@ -1,222 +1 @@
|
|||||||
interface RouterContext {
|
export { Router } from "./router.ts";
|
||||||
url: URL;
|
|
||||||
params: Record<string, string | undefined>;
|
|
||||||
state: Record<string, unknown>;
|
|
||||||
pattern: URLPattern;
|
|
||||||
request: Request;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Handler = (ctx: RouterContext) => Promise<Response> | Response;
|
|
||||||
type Middleware = (ctx: RouterContext, next: () => Promise<Response>) => Promise<Response>;
|
|
||||||
|
|
||||||
interface RouteConfig {
|
|
||||||
pattern: URLPattern;
|
|
||||||
handlers: { [method: string]: Handler };
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MiddlewareConfig {
|
|
||||||
pattern: URLPattern;
|
|
||||||
handler: Middleware;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Router {
|
|
||||||
private routes: RouteConfig[] = [];
|
|
||||||
private middleware: MiddlewareConfig[] = [];
|
|
||||||
|
|
||||||
route(path: string) {
|
|
||||||
path = path.startsWith('/') ? path : `/${path}`;
|
|
||||||
|
|
||||||
const pattern = new URLPattern({ pathname: path });
|
|
||||||
const routeConfig: RouteConfig = {
|
|
||||||
pattern,
|
|
||||||
handlers: {}
|
|
||||||
};
|
|
||||||
this.routes.push(routeConfig);
|
|
||||||
|
|
||||||
return {
|
|
||||||
get: (handler: Handler) => {
|
|
||||||
routeConfig.handlers['GET'] = handler;
|
|
||||||
return this;
|
|
||||||
},
|
|
||||||
post: (handler: Handler) => {
|
|
||||||
routeConfig.handlers['POST'] = handler;
|
|
||||||
return this;
|
|
||||||
},
|
|
||||||
put: (handler: Handler) => {
|
|
||||||
routeConfig.handlers['PUT'] = handler;
|
|
||||||
return this;
|
|
||||||
},
|
|
||||||
delete: (handler: Handler) => {
|
|
||||||
routeConfig.handlers['DELETE'] = handler;
|
|
||||||
return this;
|
|
||||||
},
|
|
||||||
patch: (handler: Handler) => {
|
|
||||||
routeConfig.handlers['PATCH'] = handler;
|
|
||||||
return this;
|
|
||||||
},
|
|
||||||
options: (handler: Handler) => {
|
|
||||||
routeConfig.handlers['OPTIONS'] = handler;
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
use(pathOrMiddleware: string | Middleware, middlewareOrRouter?: Middleware | Router) {
|
|
||||||
// Handle the case where only middleware is provided
|
|
||||||
if (typeof pathOrMiddleware === 'function') {
|
|
||||||
const pattern = new URLPattern({ pathname: '/*' });
|
|
||||||
this.middleware.push({
|
|
||||||
pattern,
|
|
||||||
handler: pathOrMiddleware
|
|
||||||
});
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
const path = pathOrMiddleware;
|
|
||||||
const middleware = middlewareOrRouter;
|
|
||||||
|
|
||||||
if (!middleware) {
|
|
||||||
throw new Error('Middleware or Router is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize the path
|
|
||||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
|
||||||
// Only add wildcard if there isn't already a parameter or pattern at the end
|
|
||||||
const wildcardPath = normalizedPath.includes(':') || normalizedPath.includes('*') ?
|
|
||||||
normalizedPath :
|
|
||||||
`${normalizedPath.replace(/\/+$/, '')}/*`;
|
|
||||||
|
|
||||||
if (middleware instanceof Router) {
|
|
||||||
// Merge the nested router's routes
|
|
||||||
for (const nestedRoute of middleware.routes) {
|
|
||||||
const combinedPath = this.combinePaths(path, nestedRoute.pattern.pathname);
|
|
||||||
const newPattern = new URLPattern({ pathname: combinedPath });
|
|
||||||
|
|
||||||
this.routes.push({
|
|
||||||
pattern: newPattern,
|
|
||||||
handlers: { ...nestedRoute.handlers }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge the nested router's middleware
|
|
||||||
for (const nestedMiddleware of middleware.middleware) {
|
|
||||||
const combinedPath = this.combinePaths(path, nestedMiddleware.pattern.pathname);
|
|
||||||
const newPattern = new URLPattern({ pathname: combinedPath });
|
|
||||||
|
|
||||||
this.middleware.push({
|
|
||||||
pattern: newPattern,
|
|
||||||
handler: nestedMiddleware.handler
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Handle regular middleware
|
|
||||||
const pattern = new URLPattern({ pathname: wildcardPath });
|
|
||||||
this.middleware.push({
|
|
||||||
pattern,
|
|
||||||
handler: middleware
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
handle = async (req: Request): Promise<Response> => {
|
|
||||||
const url = new URL(req.url);
|
|
||||||
const method = req.method;
|
|
||||||
|
|
||||||
// Find the matching route
|
|
||||||
const matchedRoute = this.findMatchingRoute(url);
|
|
||||||
if (!matchedRoute) {
|
|
||||||
return new Response('Not Found', { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const handler = matchedRoute.config.handlers[method];
|
|
||||||
if (!handler) {
|
|
||||||
return new Response('Method Not Allowed', { status: 405 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get matching middleware with their parameters
|
|
||||||
const matchingMiddleware = this.getMatchingMiddleware(url);
|
|
||||||
|
|
||||||
// Create the base context object
|
|
||||||
const baseCtx: RouterContext = {
|
|
||||||
url,
|
|
||||||
params: {},
|
|
||||||
state: {},
|
|
||||||
pattern: matchedRoute.config.pattern,
|
|
||||||
request: req
|
|
||||||
};
|
|
||||||
|
|
||||||
// Combine route parameters with the base context
|
|
||||||
baseCtx.params = {
|
|
||||||
...baseCtx.params,
|
|
||||||
...matchedRoute.params
|
|
||||||
};
|
|
||||||
|
|
||||||
// Execute middleware chain
|
|
||||||
let index = 0;
|
|
||||||
const executeMiddleware = async (): Promise<Response> => {
|
|
||||||
if (index < matchingMiddleware.length) {
|
|
||||||
const { middleware, params } = matchingMiddleware[index++];
|
|
||||||
// Create a new context for each middleware with its specific parameters
|
|
||||||
const middlewareCtx: RouterContext = {
|
|
||||||
...baseCtx,
|
|
||||||
params: { ...baseCtx.params, ...params },
|
|
||||||
pattern: middleware.pattern
|
|
||||||
};
|
|
||||||
return await middleware.handler(middlewareCtx, executeMiddleware);
|
|
||||||
}
|
|
||||||
// Final handler gets the accumulated parameters
|
|
||||||
return await handler(baseCtx);
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
return await executeMiddleware();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error handling request:', error);
|
|
||||||
return new Response('Internal Server Error', { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private findMatchingRoute(url: URL): { config: RouteConfig; params: Record<string, string | undefined> } | null {
|
|
||||||
for (const route of this.routes) {
|
|
||||||
const result = route.pattern.exec(url);
|
|
||||||
if (result) {
|
|
||||||
return {
|
|
||||||
config: route,
|
|
||||||
params: result.pathname.groups
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getMatchingMiddleware(url: URL): Array<{
|
|
||||||
middleware: MiddlewareConfig;
|
|
||||||
params: Record<string, string>
|
|
||||||
}> {
|
|
||||||
return this.middleware
|
|
||||||
.map(mw => {
|
|
||||||
const result = mw.pattern.exec(url);
|
|
||||||
if (result) {
|
|
||||||
return {
|
|
||||||
middleware: mw,
|
|
||||||
params: result.pathname.groups
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
.filter((item): item is {
|
|
||||||
middleware: MiddlewareConfig;
|
|
||||||
params: Record<string, string>
|
|
||||||
} => item !== null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private combinePaths(basePath: string, routePath: string): string {
|
|
||||||
const normalizedBase = basePath.replace(/\/+$/, '').replace(/^\/*/, '/');
|
|
||||||
const normalizedRoute = routePath.replace(/^\/*/, '');
|
|
||||||
return `${normalizedBase}/${normalizedRoute}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Router;
|
|
294
router.test.ts
Normal file
294
router.test.ts
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
// deno-lint-ignore-file require-await
|
||||||
|
import { assertEquals } from "@std/assert";
|
||||||
|
import { beforeEach, describe, it } from "@std/testing/bdd";
|
||||||
|
import Router from "./router.ts";
|
||||||
|
|
||||||
|
describe("Router", () => {
|
||||||
|
let router: Router;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
router = new Router();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Basic Routing", () => {
|
||||||
|
it("should handle basic GET request", async () => {
|
||||||
|
router.route("/test")
|
||||||
|
.get(async () => new Response("test response"));
|
||||||
|
|
||||||
|
const req = new Request("http://localhost/test", {
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await router.handle(req);
|
||||||
|
assertEquals(res.status, 200);
|
||||||
|
assertEquals(await res.text(), "test response");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 404 for non-existent route", async () => {
|
||||||
|
const req = new Request("http://localhost/nonexistent", {
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await router.handle(req);
|
||||||
|
assertEquals(res.status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return 405 for unsupported method", async () => {
|
||||||
|
router.route("/test")
|
||||||
|
.get(async () => new Response("test"));
|
||||||
|
|
||||||
|
const req = new Request("http://localhost/test", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await router.handle(req);
|
||||||
|
assertEquals(res.status, 405);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Route Parameters", () => {
|
||||||
|
it("should handle route parameters", async () => {
|
||||||
|
router.route("/users/:id")
|
||||||
|
.get(async (ctx) => new Response(ctx.params.id));
|
||||||
|
|
||||||
|
const req = new Request("http://localhost/users/123", {
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await router.handle(req);
|
||||||
|
assertEquals(await res.text(), "123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle multiple route parameters", async () => {
|
||||||
|
router.route("/users/:userId/posts/:postId")
|
||||||
|
.get(async (ctx) => {
|
||||||
|
return new Response(JSON.stringify(ctx.params));
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = new Request("http://localhost/users/123/posts/456", {
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await router.handle(req);
|
||||||
|
const params = JSON.parse(await res.text());
|
||||||
|
assertEquals(params.userId, "123");
|
||||||
|
assertEquals(params.postId, "456");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Middleware", () => {
|
||||||
|
it("should execute global middleware", async () => {
|
||||||
|
let middlewareExecuted = false;
|
||||||
|
|
||||||
|
router.use(async (_ctx, next) => {
|
||||||
|
middlewareExecuted = true;
|
||||||
|
return await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.route("/test")
|
||||||
|
.get(async () => new Response("test"));
|
||||||
|
|
||||||
|
const req = new Request("http://localhost/test", {
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
|
||||||
|
await router.handle(req);
|
||||||
|
assertEquals(middlewareExecuted, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should execute path-specific middleware", async () => {
|
||||||
|
let middlewareExecuted = false;
|
||||||
|
|
||||||
|
router.use("/test", async (_ctx, next) => {
|
||||||
|
middlewareExecuted = true;
|
||||||
|
console.log('middlware happened')
|
||||||
|
return await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.route("/test")
|
||||||
|
.get(async () => new Response("test"));
|
||||||
|
|
||||||
|
const req = new Request("http://localhost/test", {
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
|
||||||
|
await router.handle(req);
|
||||||
|
assertEquals(middlewareExecuted, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle middleware parameters", async () => {
|
||||||
|
const capturedParams: Record<string, string | undefined> = {};
|
||||||
|
|
||||||
|
router.use("/:version/.*", async (ctx, next) => {
|
||||||
|
capturedParams.version = ctx.params.version;
|
||||||
|
return await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.route("/:version/test")
|
||||||
|
.get(async () => new Response("test"));
|
||||||
|
|
||||||
|
const req = new Request("http://localhost/v1/test", {
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
|
||||||
|
await router.handle(req);
|
||||||
|
assertEquals(capturedParams.version, "v1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should execute middleware in correct order", async () => {
|
||||||
|
const order: number[] = [];
|
||||||
|
|
||||||
|
router.use(async (_ctx, next) => {
|
||||||
|
order.push(1);
|
||||||
|
return await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.use("/test", async (_ctx, next) => {
|
||||||
|
order.push(2);
|
||||||
|
return await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.route("/test")
|
||||||
|
.get(async () => {
|
||||||
|
order.push(3);
|
||||||
|
return new Response("test");
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = new Request("http://localhost/test", {
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
|
||||||
|
await router.handle(req);
|
||||||
|
assertEquals(order, [1, 2, 3]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Nested Routers", () => {
|
||||||
|
it("should handle nested routes", async () => {
|
||||||
|
const apiRouter = new Router();
|
||||||
|
apiRouter.route("/test")
|
||||||
|
.get(async () => new Response("api test"));
|
||||||
|
|
||||||
|
router.use("/api", apiRouter);
|
||||||
|
|
||||||
|
const req = new Request("http://localhost/api/test", {
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await router.handle(req);
|
||||||
|
assertEquals(await res.text(), "api test");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle nested routes with parameters", async () => {
|
||||||
|
const apiRouter = new Router();
|
||||||
|
apiRouter.route("/users/:id")
|
||||||
|
.get(async (ctx) => new Response(ctx.params.id));
|
||||||
|
|
||||||
|
router.use("/api/:version", apiRouter);
|
||||||
|
|
||||||
|
const req = new Request("http://localhost/api/v1/users/123", {
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await router.handle(req);
|
||||||
|
assertEquals(await res.text(), "123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle nested middleware", async () => {
|
||||||
|
const apiRouter = new Router();
|
||||||
|
let middlewareExecuted = false;
|
||||||
|
|
||||||
|
apiRouter.use(async (_ctx, next) => {
|
||||||
|
middlewareExecuted = true;
|
||||||
|
return await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
apiRouter.route("/test")
|
||||||
|
.get(async () => new Response("test"));
|
||||||
|
|
||||||
|
router.use("/api", apiRouter);
|
||||||
|
|
||||||
|
const req = new Request("http://localhost/api/test", {
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
|
||||||
|
await router.handle(req);
|
||||||
|
assertEquals(middlewareExecuted, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Context State", () => {
|
||||||
|
it("should maintain state across middleware chain", async () => {
|
||||||
|
router.use(async (ctx, next) => {
|
||||||
|
ctx.state.test = "value";
|
||||||
|
return await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
router.route("/test")
|
||||||
|
.get(async (ctx) => {
|
||||||
|
return new Response(ctx.state.test as string);
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = new Request("http://localhost/test", {
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await router.handle(req);
|
||||||
|
assertEquals(await res.text(), "value");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Error Handling", () => {
|
||||||
|
it("should handle errors in handlers", async () => {
|
||||||
|
router.route("/error")
|
||||||
|
.get(async () => {
|
||||||
|
throw new Error("Test error");
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = new Request("http://localhost/error", {
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await router.handle(req);
|
||||||
|
assertEquals(res.status, 500);
|
||||||
|
assertEquals(await res.text(), "Internal Server Error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle errors in middleware", async () => {
|
||||||
|
router.use(async () => {
|
||||||
|
throw new Error("Middleware error");
|
||||||
|
});
|
||||||
|
|
||||||
|
router.route("/test")
|
||||||
|
.get(async () => new Response("test"));
|
||||||
|
|
||||||
|
const req = new Request("http://localhost/test", {
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await router.handle(req);
|
||||||
|
assertEquals(res.status, 500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("HTTP Methods", () => {
|
||||||
|
const methods = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"];
|
||||||
|
|
||||||
|
methods.forEach(method => {
|
||||||
|
it(`should handle ${method} requests`, async () => {
|
||||||
|
const route = router.route("/test");
|
||||||
|
route[method.toLowerCase() as keyof typeof route](
|
||||||
|
async () => new Response(method)
|
||||||
|
);
|
||||||
|
|
||||||
|
const req = new Request("http://localhost/test", {
|
||||||
|
method: method,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await router.handle(req);
|
||||||
|
assertEquals(res.status, 200);
|
||||||
|
assertEquals(await res.text(), method);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
312
router.ts
Normal file
312
router.ts
Normal file
@ -0,0 +1,312 @@
|
|||||||
|
interface RouterContext {
|
||||||
|
url: URL;
|
||||||
|
params: Record<string, string | undefined>;
|
||||||
|
state: Record<string, unknown>;
|
||||||
|
pattern: URLPattern;
|
||||||
|
request: Request;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handler = (ctx: RouterContext) => Promise<Response> | Response;
|
||||||
|
type Middleware = (ctx: RouterContext, next: () => Promise<Response>) => Promise<Response>;
|
||||||
|
|
||||||
|
interface RouteConfig {
|
||||||
|
pattern: URLPattern;
|
||||||
|
handlers: { [method: string]: Handler };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MiddlewareConfig {
|
||||||
|
pattern: URLPattern;
|
||||||
|
handler: Middleware;
|
||||||
|
// Add path for sorting
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Router {
|
||||||
|
private routes: RouteConfig[] = [];
|
||||||
|
private middleware: MiddlewareConfig[] = [];
|
||||||
|
|
||||||
|
route(path: string) {
|
||||||
|
path = path.startsWith('/') ? path : `/${path}`;
|
||||||
|
|
||||||
|
const pattern = new URLPattern({ pathname: path });
|
||||||
|
const routeConfig: RouteConfig = {
|
||||||
|
pattern,
|
||||||
|
handlers: {}
|
||||||
|
};
|
||||||
|
this.routes.push(routeConfig);
|
||||||
|
|
||||||
|
return {
|
||||||
|
get(handler: Handler) {
|
||||||
|
routeConfig.handlers['GET'] = handler;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
post(handler: Handler) {
|
||||||
|
routeConfig.handlers['POST'] = handler;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
put(handler: Handler) {
|
||||||
|
routeConfig.handlers['PUT'] = handler;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
delete(handler: Handler) {
|
||||||
|
routeConfig.handlers['DELETE'] = handler;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
patch(handler: Handler) {
|
||||||
|
routeConfig.handlers['PATCH'] = handler;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
options(handler: Handler) {
|
||||||
|
routeConfig.handlers['OPTIONS'] = handler;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
use(pathOrMiddleware: string | Middleware, middlewareOrRouter?: Middleware | Router) {
|
||||||
|
// Handle the case where only middleware is provided
|
||||||
|
if (typeof pathOrMiddleware === 'function') {
|
||||||
|
const pattern = new URLPattern({ pathname: '/*' });
|
||||||
|
this.middleware.push({
|
||||||
|
pattern,
|
||||||
|
handler: pathOrMiddleware,
|
||||||
|
path: '/*'
|
||||||
|
});
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = pathOrMiddleware;
|
||||||
|
const middleware = middlewareOrRouter;
|
||||||
|
|
||||||
|
if (!middleware) {
|
||||||
|
throw new Error('Middleware or Router is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize the path to handle both exact matches and nested paths
|
||||||
|
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||||
|
const isParameterPath = normalizedPath.includes(':');
|
||||||
|
const wildcardPath = isParameterPath ?
|
||||||
|
normalizedPath :
|
||||||
|
normalizedPath === '/' ?
|
||||||
|
'/*' :
|
||||||
|
`${normalizedPath.replace(/\/+$/, '')}/*?`;
|
||||||
|
|
||||||
|
if (middleware instanceof Router) {
|
||||||
|
// Merge the nested router's routes
|
||||||
|
for (const nestedRoute of middleware.routes) {
|
||||||
|
const combinedPath = this.combinePaths(path, nestedRoute.pattern.pathname);
|
||||||
|
const newPattern = new URLPattern({ pathname: combinedPath });
|
||||||
|
|
||||||
|
this.routes.push({
|
||||||
|
pattern: newPattern,
|
||||||
|
handlers: { ...nestedRoute.handlers }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge the nested router's middleware
|
||||||
|
for (const nestedMiddleware of middleware.middleware) {
|
||||||
|
const combinedPath = this.combinePaths(path, nestedMiddleware.path);
|
||||||
|
const newPattern = new URLPattern({ pathname: combinedPath });
|
||||||
|
|
||||||
|
this.middleware.push({
|
||||||
|
pattern: newPattern,
|
||||||
|
handler: nestedMiddleware.handler,
|
||||||
|
path: combinedPath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Handle regular middleware
|
||||||
|
const pattern = new URLPattern({ pathname: wildcardPath });
|
||||||
|
this.middleware.push({
|
||||||
|
pattern,
|
||||||
|
handler: middleware,
|
||||||
|
path: wildcardPath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
handle = async (req: Request): Promise<Response> => {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const method = req.method;
|
||||||
|
|
||||||
|
// Find the matching route
|
||||||
|
const matchedRoute = this.findMatchingRoute(url);
|
||||||
|
if (!matchedRoute) {
|
||||||
|
return new Response('Not Found', { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = matchedRoute.config.handlers[method];
|
||||||
|
if (!handler) {
|
||||||
|
return new Response('Method Not Allowed', { status: 405 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get matching middleware and sort by path specificity
|
||||||
|
const matchingMiddleware = this.getMatchingMiddleware(url);
|
||||||
|
|
||||||
|
// Create the base context object
|
||||||
|
const baseCtx: RouterContext = {
|
||||||
|
url,
|
||||||
|
params: {},
|
||||||
|
state: {},
|
||||||
|
pattern: matchedRoute.config.pattern,
|
||||||
|
request: req
|
||||||
|
};
|
||||||
|
|
||||||
|
// Combine route parameters with the base context
|
||||||
|
baseCtx.params = {
|
||||||
|
...baseCtx.params,
|
||||||
|
...matchedRoute.params
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute middleware chain
|
||||||
|
let index = 0;
|
||||||
|
const executeMiddleware = async (): Promise<Response> => {
|
||||||
|
if (index < matchingMiddleware.length) {
|
||||||
|
const { middleware, params } = matchingMiddleware[index++];
|
||||||
|
// Create a new context for each middleware with its specific parameters
|
||||||
|
const middlewareCtx: RouterContext = {
|
||||||
|
...baseCtx,
|
||||||
|
params: { ...baseCtx.params, ...params },
|
||||||
|
pattern: middleware.pattern
|
||||||
|
};
|
||||||
|
return await middleware.handler(middlewareCtx, executeMiddleware);
|
||||||
|
}
|
||||||
|
// Final handler gets the accumulated parameters
|
||||||
|
return await handler(baseCtx);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await executeMiddleware();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling request:', error);
|
||||||
|
return new Response('Internal Server Error', { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private findMatchingRoute(url: URL): { config: RouteConfig; params: Record<string, string | undefined> } | null {
|
||||||
|
for (const route of this.routes) {
|
||||||
|
const result = route.pattern.exec(url);
|
||||||
|
if (result) {
|
||||||
|
return {
|
||||||
|
config: route,
|
||||||
|
params: result.pathname.groups
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMatchingMiddleware(url: URL): Array<{
|
||||||
|
middleware: MiddlewareConfig;
|
||||||
|
params: Record<string, string>
|
||||||
|
}> {
|
||||||
|
const matches = this.middleware
|
||||||
|
.map(mw => {
|
||||||
|
const result = mw.pattern.exec(url) ?? mw.pattern.exec(url.href + '/');
|
||||||
|
// console.log(url, mw.pattern, result);
|
||||||
|
if (result) {
|
||||||
|
return {
|
||||||
|
middleware: mw,
|
||||||
|
params: result.pathname.groups
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter((item): item is {
|
||||||
|
middleware: MiddlewareConfig;
|
||||||
|
params: Record<string, string>
|
||||||
|
} => item !== null);
|
||||||
|
|
||||||
|
// Sort middleware by path specificity
|
||||||
|
return matches.sort((a, b) => {
|
||||||
|
const pathA = a.middleware.path;
|
||||||
|
const pathB = b.middleware.path;
|
||||||
|
|
||||||
|
// Global middleware comes first
|
||||||
|
if (pathA === '/*' && pathB !== '/*') return -1;
|
||||||
|
if (pathB === '/*' && pathA !== '/*') return 1;
|
||||||
|
|
||||||
|
// More specific paths (with more segments) come later
|
||||||
|
const segmentsA = pathA.split('/').length;
|
||||||
|
const segmentsB = pathB.split('/').length;
|
||||||
|
|
||||||
|
if (segmentsA !== segmentsB) {
|
||||||
|
return segmentsA - segmentsB;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If same number of segments, longer paths are more specific
|
||||||
|
return pathA.length - pathB.length;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private combinePaths(basePath: string, routePath: string): string {
|
||||||
|
const normalizedBase = basePath.replace(/\/+$/, '').replace(/^\/*/, '/');
|
||||||
|
const normalizedRoute = routePath.replace(/^\/*/, '');
|
||||||
|
return normalizedBase === '/' ? `/${normalizedRoute}` : `${normalizedBase}/${normalizedRoute}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Router;
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
|
console.log('Starting server...');
|
||||||
|
const router = new Router();
|
||||||
|
router.route('/users')
|
||||||
|
.get((ctx) => {
|
||||||
|
return new Response('GET /users');
|
||||||
|
})
|
||||||
|
// .post((ctx) => {
|
||||||
|
// return new Response('POST /users');
|
||||||
|
// });
|
||||||
|
|
||||||
|
// router.route('/users/:id')
|
||||||
|
// .get((ctx) => {
|
||||||
|
// return new Response(`GET /users/${ctx.params.id}`);
|
||||||
|
// })
|
||||||
|
// .put((ctx) => {
|
||||||
|
// return new Response(`PUT /users/${ctx.params.id}`);
|
||||||
|
// })
|
||||||
|
// .delete((ctx) => {
|
||||||
|
// return new Response(`DELETE /users/${ctx.params.id}`);
|
||||||
|
// });
|
||||||
|
|
||||||
|
// router.route('/posts')
|
||||||
|
// .get((ctx) => {
|
||||||
|
// return new Response('GET /posts');
|
||||||
|
// })
|
||||||
|
// .post((ctx) => {
|
||||||
|
// return new Response('POST /posts');
|
||||||
|
// });
|
||||||
|
|
||||||
|
// router.route('/posts/:id')
|
||||||
|
// .get((ctx) => {
|
||||||
|
// return new Response(`GET /posts/${ctx.params.id}`);
|
||||||
|
// })
|
||||||
|
// .put((ctx) => {
|
||||||
|
// return new Response(`PUT /posts/${ctx.params.id}`);
|
||||||
|
// })
|
||||||
|
// .delete((ctx) => {
|
||||||
|
// return new Response(`DELETE /posts/${ctx.params.id}`);
|
||||||
|
// });
|
||||||
|
|
||||||
|
// router.route('/*')
|
||||||
|
// .get((ctx) => {
|
||||||
|
// return new Response('GET /*');
|
||||||
|
// })
|
||||||
|
// .post((ctx) => {
|
||||||
|
// return new Response('POST /*');
|
||||||
|
// });
|
||||||
|
|
||||||
|
router.use('/users', async (_, next) => {
|
||||||
|
console.log('Using middleware');
|
||||||
|
return await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.serve({
|
||||||
|
port: 8000,
|
||||||
|
|
||||||
|
handler: router.handle
|
||||||
|
});
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user