diff --git a/.vscode/settings.json b/.vscode/settings.json old mode 100755 new mode 100644 diff --git a/LICENSE b/LICENSE old mode 100755 new mode 100644 diff --git a/README.md b/README.md old mode 100755 new mode 100644 diff --git a/deno.json b/deno.json old mode 100755 new mode 100644 diff --git a/deno.lock b/deno.lock old mode 100755 new mode 100644 index d787b32..a2e7d53 --- a/deno.lock +++ b/deno.lock @@ -1,7 +1,8 @@ { - "version": "4", + "version": "5", "specifiers": { "jsr:@std/assert@^1.0.7": "1.0.7", + "jsr:@std/async@^1.0.8": "1.0.13", "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", @@ -16,6 +17,9 @@ "jsr:@std/internal" ] }, + "@std/async@1.0.13": { + "integrity": "1d76ca5d324aef249908f7f7fe0d39aaf53198e5420604a59ab5c035adc97c96" + }, "@std/data-structures@1.0.4": { "integrity": "fa0e20c11eb9ba673417450915c750a0001405a784e2a4e0c3725031681684a0" }, @@ -35,6 +39,7 @@ "integrity": "ca1368d720b183f572d40c469bb9faf09643ddd77b54f8b44d36ae6b94940576", "dependencies": [ "jsr:@std/assert", + "jsr:@std/async", "jsr:@std/data-structures", "jsr:@std/fs", "jsr:@std/internal", diff --git a/fileRouterTest/test.ts b/fileRouterTest/test.ts old mode 100755 new mode 100644 diff --git a/file_router.ts b/file_router.ts old mode 100755 new mode 100644 diff --git a/mod.ts b/mod.ts old mode 100755 new mode 100644 diff --git a/router.new.test.ts b/router.new.test.ts new file mode 100644 index 0000000..73f03dd --- /dev/null +++ b/router.new.test.ts @@ -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.new.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() ?? new Response("unresolved stack"); + }); + + 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 = {}; + + router.use("/:version/.*", async (_, ctx, next) => { + capturedParams.version = ctx.params.version; + return await next() ?? new Response("unresolved stack"); + }); + + 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[] = [1]; + + // router.use(async (_, _ctx, next) => { + // order.push(1); + // return await next()?? new Response("unresolved stack"); + // }); + + router.use("/test", async (_, _ctx, next) => { + order.push(2); + return await next() ?? new Response("unresolved stack"); + }); + + 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); + }); + }); + }); +}); diff --git a/router.new.ts b/router.new.ts new file mode 100644 index 0000000..b0b637a --- /dev/null +++ b/router.new.ts @@ -0,0 +1,222 @@ +import type { RouterContext as oldContext } from "@bearmetal/router/types"; +import { joinPaths } from "./util/join.ts"; + +type RouterContext = Omit; +type Handler = ( + req: Request, + ctx: RouterContext, + next: () => Promise | null, +) => Promise; +type RouteConfigurator = { + get(handler: Handler): RouteConfigurator; + post(handler: Handler): RouteConfigurator; + put(handler: Handler): RouteConfigurator; + delete(handler: Handler): RouteConfigurator; + patch(handler: Handler): RouteConfigurator; + options(handler: Handler): RouteConfigurator; + + use(handler: Handler | Router): RouteConfigurator; +}; + +type RouteConfig = { + handlers: { [method: string]: Handler[] }; + pattern: URLPattern; +}; + +const GET = "GET"; +const POST = "POST"; +const PUT = "PUT"; +const PATCH = "PATCH"; +const DELETE = "DELETE"; +const OPTIONS = "OPTIONS"; +const _use = "_use"; + +const methods = [GET, POST, PUT, PATCH, DELETE, OPTIONS, _use]; + +export class Router { + protected routes: Map = new Map(); + + /** + * @description defines a new route + * @param path the path to match, uses the same syntax as the URLPattern constructor + * @returns a RouteConfigurator object + * + * @example + * ```ts + * router.route('/users') + * .get((req, ctx) => { + * return new Response('GET /users'); + * }) + * .post((req, ctx) => { + * return new Response('POST /users'); + * }); + * ``` + */ + route(path: string): RouteConfigurator { + path = fixPath(path); + + const routeConfig = this.getOrCreateConfig(path); + + const configurator = { + get: (handler: Handler) => { + this.getOrCreateConfigHandlers(GET, routeConfig).push(handler); + return configurator; + }, + post: (handler: Handler) => { + this.getOrCreateConfigHandlers(POST, routeConfig).push(handler); + return configurator; + }, + put: (handler: Handler) => { + this.getOrCreateConfigHandlers(PUT, routeConfig).push(handler); + return configurator; + }, + patch: (handler: Handler) => { + this.getOrCreateConfigHandlers(PATCH, routeConfig).push(handler); + return configurator; + }, + delete: (handler: Handler) => { + this.getOrCreateConfigHandlers(DELETE, routeConfig).push(handler); + return configurator; + }, + options: (handler: Handler) => { + this.getOrCreateConfigHandlers(OPTIONS, routeConfig).push(handler); + return configurator; + }, + use: (handler: Handler | Router) => { + if (handler instanceof Router) { + this.resolveRouterHandlerStack(path, handler); + return configurator; + } + this.getOrCreateConfigHandlers(_use, routeConfig).push(handler); + return configurator; + }, + }; + + return configurator; + } + + get(path: string, handler: Handler) { + path = fixPath(path); + const config = this.getOrCreateConfig(path); + this.getOrCreateConfigHandlers(GET, config).push(handler); + } + post(path: string, handler: Handler) { + path = fixPath(path); + const config = this.getOrCreateConfig(path); + this.getOrCreateConfigHandlers(POST, config).push(handler); + } + put(path: string, handler: Handler) { + path = fixPath(path); + const config = this.getOrCreateConfig(path); + this.getOrCreateConfigHandlers(PUT, config).push(handler); + } + patch(path: string, handler: Handler) { + path = fixPath(path); + const config = this.getOrCreateConfig(path); + this.getOrCreateConfigHandlers(PATCH, config).push(handler); + } + delete(path: string, handler: Handler) { + path = fixPath(path); + const config = this.getOrCreateConfig(path); + this.getOrCreateConfigHandlers(DELETE, config).push(handler); + } + options(path: string, handler: Handler) { + path = fixPath(path); + const config = this.getOrCreateConfig(path); + this.getOrCreateConfigHandlers(OPTIONS, config).push(handler); + } + + use(path: string, handler: Handler | Router) { + path = fixPath(path); + if (handler instanceof Router) { + return this.resolveRouterHandlerStack(path, handler); + } + const config = this.getOrCreateConfig(path); + this.getOrCreateConfigHandlers(_use, config).push(handler); + } + + private getOrCreateConfig(path: string): RouteConfig { + let config = this.routes.get(path); + if (!config) { + config = { + handlers: {}, + pattern: new URLPattern({ pathname: path }), + }; + } + return config; + } + private getOrCreateConfigHandlers(method: string, config: RouteConfig) { + config.handlers[method] = config.handlers[method] ?? []; + return config.handlers[method]; + } + resolveRouterHandlerStack(path: string, router: Router): void { + for (const route of router.rawRoutes) { + const p = joinPaths(path, route[0]); + const thisConfig = this.getOrCreateConfig(p); + const thatConfig = route[1]; + for (const method of methods) { + if (!thatConfig.handlers[method]) continue; + thisConfig.handlers[method] = (thisConfig.handlers[method] ?? []) + .concat(thatConfig.handlers[method]); + } + } + } + get rawRoutes() { + return this.routes.entries(); + } + + get handle() { + return this.handler; + } + async handler(req: Request) { + const url = new URL(req.url); + const method = req.method; + + const matchingRoutes = this.findMatchingRoutes(url).filter((r) => + Object.hasOwn(r.config.handlers, method) || + Object.hasOwn(r.config.handlers, _use) + ); + const middlewareStack = matchingRoutes.flatMap((r) => + r.config.handlers[method] + ); + const ctx: RouterContext = { + url, + params: matchingRoutes.reduce((a, b) => ({ ...a, ...b.params }), {}), + state: {}, + request: req, + }; + + let index = 0; + const executeMiddleware = async (): Promise => { + if (index < middlewareStack.length) { + const res = await middlewareStack[index++](req, ctx, executeMiddleware); + if (res instanceof Response) return res; + } + return new Response("End of stack", { status: 501 }); + }; + try { + return await executeMiddleware(); + } catch { + return new Response("Internal Server Error", { status: 500 }); + } + } + + private findMatchingRoutes(url: URL) { + return this.routes.values().map((route) => { + const result = route.pattern.exec(url); + if (result) { + return { + config: route, + params: result.pathname.groups, + }; + } + }).filter((r) => !!r).toArray(); + } +} + +function fixPath(path: string) { + path = path.startsWith("/") ? path : `/${path}`; + return path; +} + +export default Router; diff --git a/router.test.ts b/router.test.ts old mode 100755 new mode 100644 index 11f6c2d..a92a1c4 --- a/router.test.ts +++ b/router.test.ts @@ -80,7 +80,7 @@ describe("Router", () => { it("should execute global middleware", async () => { let middlewareExecuted = false; - router.use(async (_ctx, next) => { + router.use(async (_, _ctx, next) => { middlewareExecuted = true; return await next(); }); @@ -99,9 +99,9 @@ describe("Router", () => { it("should execute path-specific middleware", async () => { let middlewareExecuted = false; - router.use("/test", async (_ctx, next) => { + router.use("/test", async (_, _ctx, next) => { middlewareExecuted = true; - console.log('middlware happened') + console.log("middlware happened"); return await next(); }); @@ -119,7 +119,7 @@ describe("Router", () => { it("should handle middleware parameters", async () => { const capturedParams: Record = {}; - router.use("/:version/.*", async (ctx, next) => { + router.use("/:version/.*", async (_, ctx, next) => { capturedParams.version = ctx.params.version; return await next(); }); @@ -138,12 +138,12 @@ describe("Router", () => { it("should execute middleware in correct order", async () => { const order: number[] = []; - router.use(async (_ctx, next) => { + router.use(async (_, _ctx, next) => { order.push(1); return await next(); }); - router.use("/test", async (_ctx, next) => { + router.use("/test", async (_, _ctx, next) => { order.push(2); return await next(); }); @@ -198,7 +198,7 @@ describe("Router", () => { const apiRouter = new Router(); let middlewareExecuted = false; - apiRouter.use(async (_ctx, next) => { + apiRouter.use(async (_, _ctx, next) => { middlewareExecuted = true; return await next(); }); @@ -219,7 +219,7 @@ describe("Router", () => { describe("Context State", () => { it("should maintain state across middleware chain", async () => { - router.use(async (ctx, next) => { + router.use(async (_, ctx, next) => { ctx.state.test = "value"; return await next(); }); @@ -274,11 +274,11 @@ describe("Router", () => { describe("HTTP Methods", () => { const methods = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]; - methods.forEach(method => { + 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) + async () => new Response(method), ); const req = new Request("http://localhost/test", { @@ -291,4 +291,4 @@ describe("Router", () => { }); }); }); -}); \ No newline at end of file +}); diff --git a/router.ts b/router.ts old mode 100755 new mode 100644 diff --git a/types.ts b/types.ts old mode 100755 new mode 100644 diff --git a/util/contentType.ts b/util/contentType.ts old mode 100755 new mode 100644 diff --git a/util/isRelativePath.ts b/util/isRelativePath.ts old mode 100755 new mode 100644 diff --git a/util/join.ts b/util/join.ts new file mode 100644 index 0000000..bf56a78 --- /dev/null +++ b/util/join.ts @@ -0,0 +1,2 @@ +export const joinPaths = (...args: string[]) => + args.map((a) => a.replace(/\/?\*?$/, "")).join(); diff --git a/util/response.ts b/util/response.ts old mode 100755 new mode 100644