diff --git a/http/unstable_route.ts b/http/unstable_route.ts index 17d3bc57daf6..aaf7e0365a57 100644 --- a/http/unstable_route.ts +++ b/http/unstable_route.ts @@ -5,7 +5,7 @@ * * @experimental **UNSTABLE**: New API, yet to be vetted. * - * Extends {@linkcode Deno.ServeHandlerInfo} by adding adding a `params` argument. + * Extends {@linkcode Deno.ServeHandlerInfo} by adding a `params` argument. * * @param request Request * @param info Request info @@ -18,29 +18,75 @@ export type Handler = ( ) => Response | Promise; /** - * Route configuration for {@linkcode route}. + * Error handler for {@linkcode route}. * * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * Extends {@linkcode Handler} by adding a first `error` argument. + * + * @param error Error thrown by a handler + * @param request Request + * @param info Request info + * @param params URL pattern result */ -export interface Route { - /** - * Request URL pattern. - */ +export type ErrorHandler = ( + error: unknown, + request: Request, + params?: URLPatternResult, + info?: Deno.ServeHandlerInfo, +) => Response | Promise; + +/** + * RouteWithDefaultHandler subtype of {@linkcode Route}. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param pattern Request URL pattern + * @param handler Default request handler that runs for any method + */ +export type RouteWithDefaultHandler = { pattern: URLPattern; - /** - * Request method. This can be a string or an array of strings. - * - * @default {"GET"} - */ - method?: string | string[]; - /** - * Request handler. - */ handler: Handler; -} + handlers?: never; +}; + +/** + * HandlersByMethods for {@linkcode RouteWithHandlersByMethods}. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + */ +export type HandlersByMethods = { [k in string]: Handler }; + +/** + * RouteWithHandlersByMethods subtype of {@linkcode Route}. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @param pattern Request URL pattern + * @param handlers An object with method keys and Handler values + */ +export type RouteWithHandlersByMethods = { + pattern: URLPattern; + handler?: never; + handlers: HandlersByMethods; +}; + +/** + * Route configuration for {@linkcode route}. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + */ +export type Route = RouteWithDefaultHandler | RouteWithHandlersByMethods; /** * Routes requests to different handlers based on the request path and method. + * Iterates over the elements of the provided routes array and handles requests + * using the first Route that has a matching URLPattern. When Route is of type + * RouteWithMethodHandlers and no handler is defined for the requested method, + * then returns a 405 Method Not Allowed response. Returns a generic 404 Not Found + * response when no route matches. Catches errors thrown by a handler and returns + * a generic 500 Internal Server Error response, or handles the error with the + * provided errorHandler when available. * * @experimental **UNSTABLE**: New API, yet to be vetted. * @@ -52,42 +98,69 @@ export interface Route { * const routes: Route[] = [ * { * pattern: new URLPattern({ pathname: "/about" }), - * handler: () => new Response("About page"), + * handlers: { GET: () => new Response("About page") }, * }, * { * pattern: new URLPattern({ pathname: "/users/:id" }), - * handler: (_req, params) => new Response(params?.pathname.groups.id), + * handlers: { + * GET: (_req: Request, params?: URLPatternResult) => + * new Response(params?.pathname.groups.id), + * }, * }, * { * pattern: new URLPattern({ pathname: "/static/*" }), - * handler: (req: Request) => serveDir(req) + * handlers: { GET: (req: Request) => serveDir(req) }, * }, * { - * method: ["GET", "HEAD"], * pattern: new URLPattern({ pathname: "/api" }), - * handler: (req: Request) => new Response(req.method === 'HEAD' ? null : 'ok'), + * handlers: { + * GET: (_req: Request) => new Response("Ok"), + * HEAD: (_req: Request) => new Response(null), + * }, + * }, + * { + * pattern: new URLPattern({ pathname: "/unavailable" }), + * handler: (_req: Request) => { + * return new Response(null, { + * status: 307, + * headers: { Location: "http://localhost:8000/api" }, + * }); + * }, + * }, + * { + * pattern: new URLPattern({ pathname: "/will-fail" }), + * handler: (_req: Request) => { + * throw new Error("oops"); + * return new Response("Ok", { status: 200 }); + * }, + * }, + * { + * pattern: new URLPattern({ pathname: "/*" }), + * handler: (_req: Request) => { + * return new Response("Custom Not Found", { status: 404 }); + * }, * }, * ]; * - * function defaultHandler(_req: Request) { - * return new Response("Not found", { status: 404 }); + * function errorHandler(err: unknown) { + * console.error(err); + * return new Response("Custom Error Handler", { + * status: 500, + * }); * } * - * Deno.serve(route(routes, defaultHandler)); + * Deno.serve(route(routes, errorHandler)); * ``` * * @param routes Route configurations - * @param defaultHandler Default request handler that's returned when no route - * matches the given request. Serving HTTP 404 Not Found or 405 Method Not - * Allowed response can be done in this function. + * @param errorHandler Optional error handler * @returns Request handler */ export function route( routes: Route[], - defaultHandler: ( - request: Request, - info?: Deno.ServeHandlerInfo, - ) => Response | Promise, + errorHandler: ErrorHandler = () => { + return new Response("Internal Server Error", { status: 500 }); + }, ): ( request: Request, info?: Deno.ServeHandlerInfo, @@ -96,15 +169,32 @@ export function route( return (request: Request, info?: Deno.ServeHandlerInfo) => { for (const route of routes) { const match = route.pattern.exec(request.url); - if ( - match && - (Array.isArray(route.method) - ? route.method.includes(request.method) - : request.method === (route.method ?? "GET")) - ) { - return route.handler(request, match, info); + if (match === null) { + continue; + } + + let handler: Handler; + if (route.handler) { + handler = route.handler; + } else if (!(request.method in route.handlers)) { + /** + * @see {@link https://www.iana.org/go/rfc2616 | RFC2616, Section 14.7} + */ + return new Response("Method Not Allowed", { + status: 405, + headers: { Allow: Object.keys(route.handlers).join(", ") }, + }); + } else { + handler = route.handlers[request.method] as Handler; + } + + try { + return handler(request, match, info); + } catch (error) { + return errorHandler(error, request, match, info); } } - return defaultHandler(request, info); + + return new Response("Not Found", { status: 404 }); }; } diff --git a/http/unstable_route_test.ts b/http/unstable_route_test.ts index 699629bd69bf..0c89df30d074 100644 --- a/http/unstable_route_test.ts +++ b/http/unstable_route_test.ts @@ -6,31 +6,47 @@ import { assertEquals } from "../assert/equals.ts"; const routes: Route[] = [ { pattern: new URLPattern({ pathname: "/about" }), - handler: (request: Request) => new Response(new URL(request.url).pathname), + handlers: { GET: (request) => new Response(new URL(request.url).pathname) }, }, { pattern: new URLPattern({ pathname: "/users/:id" }), - handler: (_request, params) => new Response(params?.pathname.groups.id), + handlers: { + GET: (_request, params) => new Response(params?.pathname.groups.id), + POST: () => new Response("Done"), + }, }, { - pattern: new URLPattern({ pathname: "/users/:id" }), - method: "POST", - handler: () => new Response("Done"), + pattern: new URLPattern({ pathname: "/resource" }), + handlers: { + GET: (_request: Request) => new Response("Ok"), + HEAD: (_request: Request) => new Response(null), + }, }, { - pattern: new URLPattern({ pathname: "/resource" }), - method: ["GET", "HEAD"], - handler: (request: Request) => - new Response(request.method === "HEAD" ? null : "Ok"), + pattern: new URLPattern({ pathname: "/will-throw" }), + handler: (_request: Request) => { + throw new Error("oops"); + // deno-lint-ignore no-unreachable + return new Response(null, { status: 200 }); + }, }, ]; -function defaultHandler(request: Request) { - return new Response(new URL(request.url).pathname, { status: 404 }); +const wildcardHandler: Route = { + pattern: new URLPattern({ pathname: "/*" }), + handler: (request: Request) => { + return new Response(new URL(request.url).pathname, { status: 404 }); + }, +}; + +function errorHandler(_err: unknown, _req: Request) { + return new Response("Custom Error Message", { + status: 500, + }); } Deno.test("route()", async (t) => { - const handler = route(routes, defaultHandler); + let handler = route(routes); await t.step("handles static routes", async () => { const request = new Request("http://example.com/about"); @@ -53,13 +69,6 @@ Deno.test("route()", async (t) => { assertEquals(response2?.status, 200); }); - await t.step("handles default handler", async () => { - const request = new Request("http://example.com/not-found"); - const response = await handler(request); - assertEquals(response?.status, 404); - assertEquals(await response?.text(), "/not-found"); - }); - await t.step("handles multiple methods", async () => { const getMethodRequest = new Request("http://example.com/resource"); const getMethodResponse = await handler(getMethodRequest); @@ -73,4 +82,47 @@ Deno.test("route()", async (t) => { assertEquals(headMethodResponse?.status, 200); assertEquals(await headMethodResponse?.text(), ""); }); + + await t.step("handles method not allowed", async () => { + const request = new Request("http://example.com/resource", { + method: "POST", + }); + const response = await handler(request); + assertEquals(response?.status, 405); + assertEquals(response?.headers.get("Allow"), "GET, HEAD"); + assertEquals(await response?.text(), "Method Not Allowed"); + }); + + await t.step("handles errors using default error handler", async () => { + const request = new Request("http://example.com/will-throw"); + const response = await handler(request); + assertEquals(response?.status, 500); + assertEquals(await response?.text(), "Internal Server Error"); + }); + + await t.step( + "handles no matching route with default 404 handler", + async () => { + const request = new Request("http://example.com/not-found"); + const response = await handler(request); + assertEquals(response?.status, 404); + assertEquals(await response?.text(), "Not Found"); + }, + ); + + handler = route([...routes, wildcardHandler], errorHandler); + + await t.step("handles routes using wildcard handler", async () => { + const request = new Request("http://example.com/not-found"); + const response = await handler(request); + assertEquals(response?.status, 404); + assertEquals(await response?.text(), "/not-found"); + }); + + await t.step("handles errors using custom error handler", async () => { + const request = new Request("http://example.com/will-throw"); + const response = await handler(request); + assertEquals(response?.status, 500); + assertEquals(await response?.text(), "Custom Error Message"); + }); });