diff --git a/deno.jsonc b/deno.jsonc index 79d2642..b5976ad 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -2,5 +2,8 @@ "lock": false, "name": "@fartlabs/rt", "version": "0.0.2", - "exports": "./mod.ts" + "exports": "./mod.ts", + "imports": { + "@std/assert": "jsr:@std/assert@^0.225.1" + } } diff --git a/rt.ts b/rt.ts index bf3bac9..7bf3314 100644 --- a/rt.ts +++ b/rt.ts @@ -1,8 +1,11 @@ /** * createRouter creates a new router. */ -export function createRouter(fn?: (r: Router) => Router): Router { - const router = new Router(); +export function createRouter( + fn?: (r: Router) => Router, + state?: RouterState, +): Router { + const router = new Router(state); if (fn) { return fn(router); } @@ -34,7 +37,7 @@ export type Method = typeof METHODS[number]; * Match is a type which matches a Request object. */ export type Match = - | ((detail: { request: Request; url: URL }) => Promise) + | ((r: RouterRequest) => boolean | Promise) | { /** * pattern is the URL pattern to match on. @@ -50,8 +53,8 @@ export type Match = /** * Handle is called to handle a request. */ -export interface Handle { - (ctx: RouterContext): Promise | Response; +export interface Handle { + (ctx: RouterContext): Promise | Response; } /** @@ -61,44 +64,48 @@ export interface ErrorHandle { (error: Error): Promise | Response; } +/** + * DefaultHandle is called to handle a request when no routes are matched. + */ +type DefaultHandle = Handle; + /** * Route represents a the pairing of a matcher and a handler. */ -export interface Route { +export interface Route { /** - * handle is called to handle a request. + * match is called to match a request. */ - handle: Handle; + match?: Match; /** - * match is called to match a request. + * handle is called to handle a request. */ - match?: Match; + handle: Handle; } /** * Routes is a sequence of routes. */ -export type Routes = Route[]; +export type Routes = Array< + Route +>; /** * RouterContext is the object passed to a router. */ -export interface RouterContext { +export interface RouterContext + extends RouterRequest { /** - * request is the original request object. - */ - request: Request; - - /** - * url is the parsed fully qualified URL of the request. + * params is a map of matched parameters from the URL pattern. */ - url: URL; + params: { [key in TParam]: string }; /** - * params is a map of matched parameters from the URL pattern. + * state is the state passed to the router. Modify this to pass data between + * routes. */ - params: { [key in T]: string }; + state: TState; /** * next executes the next matched route in the sequence. If no more routes are @@ -107,28 +114,55 @@ export interface RouterContext { next: () => Promise; } +/** + * RouterRequest is the object passed to a router. + */ +interface RouterRequest { + /** + * request is the original request object. + */ + request: Request; + + /** + * url is the parsed fully qualified URL of the request. + */ + url: URL; +} + +/** + * RouterState is the state passed to a router. + */ +type RouterState = (r: RouterRequest) => T; + /** * RouterInterface is the interface for a router. */ -type RouterInterface = Record< +type RouterInterface = Record< Lowercase, - ((pattern: string, handle: Handle) => Router) + ((pattern: string, handle: Handle) => Router) >; /** * Router is an HTTP router based on the `URLPattern` API. */ -export class Router implements RouterInterface { - public routes: Routes = []; - public defaultHandle?: Handle; +export class Router implements RouterInterface { + public routes: Routes = []; + public defaultHandle?: DefaultHandle; public errorHandle?: ErrorHandle; + public constructor(public readonly state?: RouterState) {} + /** * fetch invokes the router for the given request. */ - public async fetch(request: Request, i = 0): Promise { + public async fetch( + request: Request, + url: URL = new URL(request.url), + state: T = + (this.state !== undefined ? this.state({ request, url }) : {}) as T, + i = 0, + ): Promise { try { - const url = new URL(request.url); while (i < this.routes.length) { const route = this.routes[i]; const matchedMethod = route.match === undefined || @@ -169,7 +203,8 @@ export class Router implements RouterInterface { request, url, params, - next: () => this.fetch(request, i + 1), + state, + next: () => this.fetch(request, url, state, i + 1), }); } @@ -181,6 +216,7 @@ export class Router implements RouterInterface { request, url, params: {}, + state, next: () => { throw new Error("next() called from default handler"); }, @@ -198,14 +234,14 @@ export class Router implements RouterInterface { /** * with appends a route to the router. */ - public with(route: Route): this; - public with( + public with(route: Route): this; + public with( match: Match, - handle: Handle, + handle: Handle, ): this; - public with( - routeOrMatch: Match | Route, - handle?: Handle, + public with( + routeOrMatch: Match | Route, + handle?: Handle, ): this { if (handle === undefined && "handle" in routeOrMatch) { this.routes.push(routeOrMatch); @@ -221,7 +257,7 @@ export class Router implements RouterInterface { /** * use appends a sequence of routers to the router. */ - public use(data: Routes | Router): this { + public use(data: Routes | Router): this { if (data instanceof Router) { this.routes.push(...data.routes); } else { @@ -234,7 +270,7 @@ export class Router implements RouterInterface { /** * default sets the router's default handler. */ - public default(handle: Handle | undefined): this { + public default(handle: DefaultHandle | undefined): this { this.defaultHandle = handle; return this; } @@ -250,9 +286,9 @@ export class Router implements RouterInterface { /** * connect appends a router for the CONNECT method to the router. */ - public connect( + public connect( pattern: string, - handle: Handle, + handle: Handle, ): this { return this.with({ method: "CONNECT", @@ -263,9 +299,9 @@ export class Router implements RouterInterface { /** * delete appends a router for the DELETE method to the router. */ - public delete( + public delete( pattern: string, - handle: Handle, + handle: Handle, ): this { return this.with({ method: "DELETE", @@ -276,9 +312,9 @@ export class Router implements RouterInterface { /** * get appends a router for the GET method to the router. */ - public get( + public get( pattern: string, - handle: Handle, + handle: Handle, ): this { return this.with({ method: "GET", @@ -289,9 +325,9 @@ export class Router implements RouterInterface { /** * head appends a router for the HEAD method to the router. */ - public head( + public head( pattern: string, - handle: Handle, + handle: Handle, ): this { return this.with({ method: "HEAD", @@ -302,9 +338,9 @@ export class Router implements RouterInterface { /** * options appends a router for the OPTIONS method to the router. */ - public options( + public options( pattern: string, - handle: Handle, + handle: Handle, ): this { return this.with({ method: "OPTIONS", @@ -315,9 +351,9 @@ export class Router implements RouterInterface { /** * patch appends a router for the PATCH method to the router. */ - public patch( + public patch( pattern: string, - handle: Handle, + handle: Handle, ): this { return this.with({ method: "PATCH", @@ -328,9 +364,9 @@ export class Router implements RouterInterface { /** * post appends a router for the POST method to the router. */ - public post( + public post( pattern: string, - handle: Handle, + handle: Handle, ): this { return this.with({ method: "POST", @@ -341,9 +377,9 @@ export class Router implements RouterInterface { /** * put appends a router for the PUT method to the router. */ - public put( + public put( pattern: string, - handle: Handle, + handle: Handle, ): this { return this.with({ method: "PUT", @@ -354,9 +390,9 @@ export class Router implements RouterInterface { /** * trace appends a router for the TRACE method to the router. */ - public trace( + public trace( pattern: string, - handle: Handle, + handle: Handle, ): this { return this.with({ method: "TRACE", diff --git a/rt_test.ts b/rt_test.ts new file mode 100644 index 0000000..8e334d4 --- /dev/null +++ b/rt_test.ts @@ -0,0 +1,63 @@ +import { assertEquals } from "@std/assert"; +import { createRouter } from "./rt.ts"; + +const router = createRouter() + .get( + "/", + ({ url }) => + new Response(`Hello, ${url.searchParams.get("name") ?? "World"}!`), + ) + .default(() => new Response("Not found", { status: 404 })); + +Deno.test("createRouter handles GET request", async () => { + const response = await router.fetch( + new Request("http://localhost/?name=Deno"), + ); + + assertEquals(await response.text(), "Hello, Deno!"); +}); + +Deno.test("createRouter navigates unmatched route to default handler", async () => { + const response = await router.fetch( + new Request("http://localhost/404"), + ); + assertEquals(response.status, 404); +}); + +const statefulRouter = createRouter( + (r) => + r + .get( + "/*", + async (ctx) => { + ctx.state.user = { name: "Alice" }; + return await ctx.next(); + }, + ) + .get<"name">( + "/:name", + (ctx) => { + if (ctx.state.user === null) { + return new Response("Unauthorized", { status: 401 }); + } + + if (ctx.state.user.name !== ctx.params.name) { + return new Response("Forbidden", { status: 403 }); + } + + return new Response(`Hello, ${ctx.params.name}!`); + }, + ), + (): { user: User | null } => ({ user: null }), +); + +interface User { + name: string; +} + +Deno.test("createRouter preserves state", async () => { + const response = await statefulRouter.fetch( + new Request("http://localhost/Alice"), + ); + assertEquals(await response.text(), "Hello, Alice!"); +});