From c0c601770578717a2869b690b4e8a6413d3aa76f Mon Sep 17 00:00:00 2001 From: Lee Cheneler Date: Thu, 21 Nov 2024 00:03:56 +0000 Subject: [PATCH] feat: routing enhancements (#25) --- README.md | 123 ++++++++++++++---- deno.json | 2 +- src/app.ts | 16 ++- src/context.ts | 39 ++++-- src/router.ts | 87 +++++++++++-- tests/routing/params.test.ts | 64 +++++++++ .../{routenames.ts => routenames.test.ts} | 0 tests/routing/wildcard.test.ts | 76 +++++++++++ 8 files changed, 353 insertions(+), 54 deletions(-) create mode 100644 tests/routing/params.test.ts rename tests/routing/{routenames.ts => routenames.test.ts} (100%) create mode 100644 tests/routing/wildcard.test.ts diff --git a/README.md b/README.md index 0b5bca6..da676df 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,36 @@ app.get("/", (context) => { }); ``` +You can register middleware to execute on every route and method via the +`app.use` method. This is useful for middleware that should run on every +request. + +```tsx +app.use(async (context, next) => { + console.log("Request received"); + + await next(); +}); +``` + +You can configure multiple middleware at a time: + +```tsx +app.get( + "/", + (context) => { + context.text(StatusCode.OK, "One!"); + }, + (context) => { + context.text(StatusCode.OK, "Two!"); + }, + (context) => { + context.text(StatusCode.OK, "Three!"); + }, + // ... etc +); +``` + ### Available middleware A collection of prebuilt middleware is available to use. @@ -209,17 +239,7 @@ context.cookies.delete("name"); ## Routing -You can register middleware to execute on every route and method via the -`app.use` method. This is useful for middleware that should run on every -request. - -```tsx -app.use(async (context, next) => { - console.log("Request received"); - - await next(); -}); -``` +### HTTP methods Routes can be registered for each HTTP method against a route: @@ -247,20 +267,73 @@ app.options((context) => { }); ``` -You can configure multiple middleware at a time: +### Paths + +Paths can be simple: ```tsx -app.get( - "/", - (context) => { - context.text(StatusCode.OK, "One!"); - }, - (context) => { - context.text(StatusCode.OK, "Two!"); - }, - (context) => { - context.text(StatusCode.OK, "Three!"); - }, - // ... etc -); +app.get("/one", (context) => { + context.text(StatusCode.OK, "Simple path"); +}); + +app.get("/one/two", (context) => { + context.text(StatusCode.OK, "Simple path"); +}); + +app.get("/one/two/three", (context) => { + context.text(StatusCode.OK, "Simple path"); +}); +``` + +### Parameters + +Paths can contain parameters that will be passed to the context as strings: + +```tsx +app.get("/user/:id", (context) => { + context.text(StatusCode.OK, `User ID: ${context.params.id}`); +}); + +app.get("/user/:id/post/:postId", (context) => { + context.text( + StatusCode.OK, + `User ID: ${context.params.id}, Post ID: ${context.params.postId}`, + ); +}); +``` + +### Wildcards + +Paths can contain wildcards that will match any path. Wildcards must be at the +end of the path. + +```tsx +app.get("/public/*", (context) => { + context.text(StatusCode.OK, "Wildcard path"); +}); +``` + +The path portion captured by the wildcard is available on the context: + +```tsx +app.get("/public/*", (context) => { + context.text(StatusCode.OK, `Wildcard path: ${context.wildcard}`); +}); +``` + +Wildcards are inclusive of the path its placed on. This means that the wildcard +will match any path that starts with the wildcard path. + +```tsx +app.get("/public/*", (context) => { + context.text(StatusCode.OK, "Wildcard path"); +}); + +/** + * matches: + * + * /public + * /public/one + * /public/one/two + */ ``` diff --git a/deno.json b/deno.json index a68c889..d927edf 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@mage/server", - "version": "0.6.4", + "version": "0.7.0", "license": "MIT", "exports": "./mod.ts", "tasks": { diff --git a/src/app.ts b/src/app.ts index 40b226d..0a3bfbd 100644 --- a/src/app.ts +++ b/src/app.ts @@ -167,13 +167,21 @@ export class MageApp { }; return Deno.serve(serveOptions, async (req) => { - const context: MageContext = new MageContext(req); + const url = new URL(req.url); + const matchResult = this._router.match(url, req.method); - const matchResult = this._router.match(context); + const context: MageContext = new MageContext( + req, + url, + matchResult.params, + matchResult.wildcard, + ); + + const getAllowedMethods = () => this._router.getAvailableMethods(url); const middleware = [ useOptions({ - getAllowedMethods: () => this._router.getAvailableMethods(context), + getAllowedMethods, }), ...matchResult.middleware, ]; @@ -185,7 +193,7 @@ export class MageApp { if (!matchResult.matchedMethod) { middleware.push( useMethodNotAllowed({ - getAllowedMethods: () => this._router.getAvailableMethods(context), + getAllowedMethods, }), ); } diff --git a/src/context.ts b/src/context.ts index 9e87a80..c6716af 100644 --- a/src/context.ts +++ b/src/context.ts @@ -4,16 +4,6 @@ import { RedirectType, StatusCode, statusTextMap } from "./http.ts"; import { Cookies } from "./cookies.ts"; import type { CookieOptions } from "./cookies.ts"; -/** - * Serializable JSON value - */ -type JSONValues = string | number | boolean | null | JSONValues[]; - -/** - * Serializable JSON object - */ -type JSON = { [key: string]: JSONValues } | JSONValues[]; - /** * Context object that is passed to each middleware. It persists throughout the * request/response cycle and is used to interact with the request and response. @@ -23,6 +13,9 @@ export class MageContext { private _response: Response; private _request: Request; private _cookies: Cookies; + private _params: { [key: string]: string }; + private _wildcard?: string; + /** * The URL of the request */ @@ -30,6 +23,13 @@ export class MageContext { return this._url; } + /** + * The URL parameters of the request matched by the router + */ + public get params(): { [key: string]: string } { + return this._params; + } + /** * The response object that will be sent at the end of the request/response * cycle. @@ -45,11 +45,22 @@ export class MageContext { return this._request; } - public constructor(request: Request) { - this._url = new URL(request.url); - this._response = new Response(); + public get wildcard(): string | undefined { + return this._wildcard; + } + + public constructor( + request: Request, + url: URL, + params: { [key: string]: string }, + wildcard?: string, + ) { this._request = request; + this._url = url; + this._params = params; + this._response = new Response(); this._cookies = new Cookies(this); + this._wildcard = wildcard; } /** @@ -72,7 +83,7 @@ export class MageContext { * @param status * @param body */ - public json(status: StatusCode, body: JSON) { + public json(status: StatusCode, body: { [key: string]: unknown }) { this._response = new Response(JSON.stringify(body), { status: status, statusText: statusTextMap[status], diff --git a/src/router.ts b/src/router.ts index b3d0799..b07aa8a 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,6 +1,62 @@ import type { MageContext } from "./context.ts"; import { HttpMethod } from "./http.ts"; +interface MatchRoutenameResultMatch { + match: true; + params: { [key: string]: string }; + wildcard?: string; +} + +interface MatchRoutenameResultNoMatch { + match: false; +} + +type MatchRoutenameResult = + | MatchRoutenameResultMatch + | MatchRoutenameResultNoMatch; + +function matchRoutename( + routename: string, + pathname: string, +): MatchRoutenameResult { + const routeParts = routename.split("/"); + const pathParts = pathname.split("/"); + + const params: { [key: string]: string } = {}; + + for (let i = 0; i < routeParts.length; i++) { + if (routeParts[i] === "*") { + const wildcard = pathParts.slice(i).join("/"); + + return { match: true, params, wildcard }; + } + + if (routeParts[i].startsWith(":")) { + const paramName = routeParts[i].substring(1); + params[paramName] = pathParts[i]; + } else if (routeParts[i] !== pathParts[i]) { + return { match: false }; + } + } + + // Check for wildcard at the end of the route + if ( + routeParts.length < pathParts.length && + routeParts[routeParts.length - 1] === "*" + ) { + const wildcard = pathParts.slice(routeParts.length - 1).join("/"); + + return { match: true, params, wildcard }; + } + + // Ensure all parts are matched + if (routeParts.length !== pathParts.length) { + return { match: false }; + } + + return { match: true, params }; +} + /** * Middleware function signature for Mage applications. */ @@ -40,6 +96,8 @@ interface MatchResult { middleware: MageMiddleware[]; matchedRoutename: boolean; matchedMethod: boolean; + params: { [key: string]: string }; + wildcard?: string; } /** @@ -50,26 +108,33 @@ export class MageRouter { private _entries: RouterEntry[] = []; /** - * Match middleware for a given context. + * Match middleware for a given request and extract parameters. * - * @param context + * @param url + * @param method * @returns */ - public match(context: MageContext): MatchResult { + public match(url: URL, method: string): MatchResult { let matchedRoutename = false; let matchedMethod = false; + let params: { [key: string]: string } = {}; + let wildcard: string | undefined; const middleware = this._entries .filter((entry) => { - if (entry.routename && entry.routename !== context.url.pathname) { - return false; - } - if (entry.routename) { + const result = matchRoutename(entry.routename, url.pathname); + + if (!result.match) { + return false; + } + + params = result.params; + wildcard = result.wildcard; matchedRoutename = true; } - if (entry.methods && !entry.methods.includes(context.request.method)) { + if (entry.methods && !entry.methods.includes(method)) { return false; } @@ -85,6 +150,8 @@ export class MageRouter { middleware, matchedRoutename, matchedMethod, + params, + wildcard, }; } @@ -94,9 +161,9 @@ export class MageRouter { * @param pathname * @returns */ - public getAvailableMethods(context: MageContext): string[] { + public getAvailableMethods(url: URL): string[] { const methods = this._entries - .filter((entry) => entry.routename === context.url.pathname) + .filter((entry) => entry.routename === url.pathname) .flatMap((entry) => entry.methods ?? []); return methods; diff --git a/tests/routing/params.test.ts b/tests/routing/params.test.ts new file mode 100644 index 0000000..e574683 --- /dev/null +++ b/tests/routing/params.test.ts @@ -0,0 +1,64 @@ +import { afterAll, beforeAll, describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; +import { StatusCode } from "../../mod.ts"; +import { MageTestServer } from "../../test-utils/server.ts"; + +let server: MageTestServer; + +beforeAll(() => { + server = new MageTestServer(); + + server.app.get("/book/:id", (context) => { + context.json(StatusCode.OK, context.params); + }); + + server.app.get("/countries/:country/:city/:road", (context) => { + context.json(StatusCode.OK, context.params); + }); + + server.app.get("/user/:id/post/:postId", (context) => { + context.json(StatusCode.OK, context.params); + }); + + server.start(); +}); + +afterAll(async () => { + await server.stop(); +}); + +describe("routing - params", () => { + it("should hit route with params", async () => { + const response = await fetch(server.url("/book/1"), { + method: "GET", + }); + + expect(await response.json()).toEqual({ id: "1" }); + }); + + it("should hit route with multiple params", async () => { + const response = await fetch( + server.url("/countries/usa/new-york/5th-avenue"), + { + method: "GET", + }, + ); + + expect(await response.json()).toEqual({ + country: "usa", + city: "new-york", + road: "5th-avenue", + }); + }); + + it("should hit route with multiple detached params", async () => { + const response = await fetch(server.url("/user/1/post/2"), { + method: "GET", + }); + + expect(await response.json()).toEqual({ + id: "1", + postId: "2", + }); + }); +}); diff --git a/tests/routing/routenames.ts b/tests/routing/routenames.test.ts similarity index 100% rename from tests/routing/routenames.ts rename to tests/routing/routenames.test.ts diff --git a/tests/routing/wildcard.test.ts b/tests/routing/wildcard.test.ts new file mode 100644 index 0000000..b39fbde --- /dev/null +++ b/tests/routing/wildcard.test.ts @@ -0,0 +1,76 @@ +import { afterAll, beforeAll, describe, it } from "@std/testing/bdd"; +import { expect } from "@std/expect"; +import { StatusCode } from "../../mod.ts"; +import { MageTestServer } from "../../test-utils/server.ts"; + +let server: MageTestServer; + +beforeAll(() => { + server = new MageTestServer(); + + server.app.get("/public/specific", (context) => { + context.text(StatusCode.OK, "specific"); + }); + + server.app.get("/public/*", (context) => { + context.json(StatusCode.OK, { wildcard: context.wildcard }); + }); + + server.app.get("/:param/*", (context) => { + context.json(StatusCode.OK, { + params: context.params, + wildcard: context.wildcard, + }); + }); + + server.start(); +}); + +afterAll(async () => { + await server.stop(); +}); + +describe("routing - wildcard", () => { + it("should hit route for wildcard base, wildcards are inclusive", async () => { + const response = await fetch(server.url("/public"), { + method: "GET", + }); + + expect(await response.json()).toEqual({ wildcard: "" }); + }); + + it("should hit route with wildcard and place wildcard on context", async () => { + const response = await fetch(server.url("/public/wildcard"), { + method: "GET", + }); + + expect(await response.json()).toEqual({ wildcard: "wildcard" }); + }); + + it("should hit route with wildcard with multiple paths", async () => { + const response = await fetch(server.url("/public/js/index.js"), { + method: "GET", + }); + + expect(await response.json()).toEqual({ wildcard: "js/index.js" }); + }); + + it("should hit specific path if registered first over wildcard", async () => { + const response = await fetch(server.url("/public/specific"), { + method: "GET", + }); + + expect(await response.text()).toBe("specific"); + }); + + it("should hit route with wildcard and extract params", async () => { + const response = await fetch(server.url("/param/wildcard"), { + method: "GET", + }); + + expect(await response.json()).toEqual({ + params: { param: "param" }, + wildcard: "wildcard", + }); + }); +});