HTTP Routing with Web Standards
URLPattern
can
appear daunting at first, but for our purposes here, we can ignore most of its
nuances: Typically all we want are path parameters à la /items/:slug
, if any.
We can check whether a given request URL matches such a route:
let pattern = new URLPattern({
pathname: "/items/:slug"
});
let url = "https://example.org/items/hello-world?locale=en-dk";
let match = pattern.exec(url);
if(match) {
console.log(200, match.pathname.groups);
} else {
console.log(404);
}
This will emit 200 { slug: "hello-world" }
.
Unfortunately, browser support for URLPattern
is
limited
right now.
A simplistic dispatching mechanism for incoming requests might look like this:
let request = new Request("https://example.org/items/hello-world");
let response = dispatch(request);
console.log(response, "\n" + await response.text());
function dispatch(request) {
let match = pattern.exec(request.url);
if(match) {
return handler(request, match.pathname.groups);
}
return new Response("", { status: 404 });
}
function handler(request, params) {
return new Response(`This is ${params.slug}.`, {
status: 200,
headers: {
"Content-Type": "text/plain"
}
});
}
Route Abstraction
Usually we have more than one route, of course, with request processing additionally depending on the respective HTTP method:
/
is our root, responding toGET
/items
is a collection, responding toGET
andPOST
/items/:slug
is an entity, responding toGET
andPUT
Clearly we could use an abstraction to define request handlers (AKA controllers) and to determine which is responsible for incoming requests:
class Route {
constructor(pattern, handlers) {
this._pattern = new URLPattern({ pathname: pattern });
this._handlers = handlers;
}
dispatch(request) {
let match = this._pattern.exec(request.url);
if(!match) {
return null;
}
let handler = this._handlers[request.method];
if(handler) {
return handler(request, match.pathname.groups);
}
let supportedMethods = Object.keys(this._handlers);
return new Response("405 Method Not Allowed\n", {
status: 405,
headers: {
Allow: supportedMethods.join(", "),
"Content-Type": "text/plain"
}
});
}
}
With that a semi-declarative routing table might look like this:
let ROUTES = {
root: new Route("/", {
GET: showRoot
}),
collection: new Route("/items", {
GET: showCollection,
POST: createEntity
}),
entity: new Route("/items/:slug", {
GET: showEntity,
PUT: updateEntity
})
};
function showEntity(request, { slug }) {
// …
}
Request processing is then just a matter of delegation:
function dispatch(request) {
for(let route of Object.values(ROUTES)) {
let res = route.dispatch(request);
if(res) {
return res;
}
}
return new Response("404 Not Found\n", {
status: 404,
headers: {
"Content-Type": "text/plain"
}
});
}
Reverse Routing
When generating URLs for resources within our system (yay hypermedia), we want to avoid arbitrary string stitching as that undermines our routes’ authority and encapsulation.
While waiting for
reverse routing to be standardized,
we might extend our Route
class with a crude-but-functional approximation:
url(params, query) {
let res = this._pattern.pathname;
if(params) {
for(let [key, value] of Object.entries(params)) {
res = res.replace(":" + key, value);
}
}
if(query) {
let url = new URL(res, "http://localhost");
for(let [name, value] of Object.entries(query)) {
url.searchParams.set(name, value);
}
res = url.pathname + url.search;
}
return res;
}
This then allows us to generate URLs like this:
ROUTES.entity.url({ slug: "hello-world"); // "/items/hello-world"
ROUTES.collection.url(null, { query: "lipsum" }); // "/items?query=lipsum"
A more elaborate system might want to consider type safety (if only for autocompletion purposes) and other details, but this lightweight approach has proven both sufficient and effective for me on many occasions.