Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BREAKING(http/unstable): add capability to attach handlers by methods, implement sensible defaults #6305

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 129 additions & 39 deletions http/unstable_route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -18,29 +18,75 @@ export type Handler = (
) => Response | Promise<Response>;

/**
* 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<Response>;

/**
* 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.
*
Expand All @@ -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<Response>,
errorHandler: ErrorHandler = () => {
return new Response("Internal Server Error", { status: 500 });
},
): (
request: Request,
info?: Deno.ServeHandlerInfo,
Expand All @@ -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}
kerezsiz42 marked this conversation as resolved.
Show resolved Hide resolved
*/
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 });
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it's a good idea to remove the capability to customize the 404 page

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I mentioned it in the PR but did not emphasize it enough. So putting a wildcard route that matches any URLPattern at the end of the Route[] achieves exactly the same result as the current defaultHandler:

const wildcardRoute: Route = {
  pattern: new URLPattern({ pathname: "/*" }),
  handler: (request: Request) => {
    return new Response(new URL(request.url).pathname, { status: 404 });
  },
};

For this reason I think that the defaultHandler is unnecessary, and the second parameter of route could be deleted or changed to something else without losing features.

};
}
90 changes: 71 additions & 19 deletions http/unstable_route_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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);
Expand All @@ -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");
});
});
Loading