Routing
7 min read · last updated atWhen a request is passed to your application, routing refers to the process of matching the request to a piece of logic that then in turn creates a response.
Route Registration
A common example for such a matching process is based on the path of the request.
This way we can render the homepage for GET
requests to /
, and render the login form for ones to /login
.
Technically this is realized using a router. This is a software component, that knows of a pre-defined set of method-path pairs, that are associated with a piece of code that returns a response. In our previous example of the homepage and the login, this list might look like this:
Method | Path | Logic |
---|---|---|
GET | / | render the hompeage |
GET | /login | render the login form |
POST | /login | authenticate the user |
As a user of a framework, the matching logic is usually abstracted away, since the router is part of the framework. Internally, they match the patterns built up by the routes known to the router against the incoming request and execute the code of the first match to return a response.
A interesting thing that differs between frameworks is how to register these routes. Sometimes frameworks even let you choose between different registration mechanisms, since each has its own set of trade-offs.
Inline
The simplest form of registration is to call a method on a router instance. This could look like the following pseudo-code:
Since there is only a handful of http methods, most frameworks choose to name the method that registers a route after a HTTP method instead of something generic like register
.
They also commonly refer to the router as the app
.
Dotnet’s Minimal APIs register their routes inline:
Notice the inclusion of the “map” term, which originates from mathematics and in this context means something like “assign”. We can then read the line of code as
app
, please assign all get requests to/greeting
to be handled by the following function.
Express merges your application and router into an app
instance.
Note that instead of simply returning a value, we need to use the send
method of the provided response
object to actually send something to the user.
Vapor merges your application and router into an app
instance.
What is also interesting about Vapor is how it registers nested paths.
If you have a path /blog/about
, most frameworks would register this as a single string.
Instead, Vapor just passes each segment of the path as a string to the get
method.
This eliminates a whole category of errors found in other web frameworks, where dangling or leading slashes can lead to unexpected results.
Other examples
Ktor utilizes Kotlin’s language feature to build a DSL for routing. You almost do not see that function definitions are involved, it just looks like declaring a few blocks.
This makes it easy to read, but harder to follow compared to other implementations.
Laravel abstracts the creation of the router instance.
Instead, we call a globally available alias Route
to access a router instance that is build by the framework behind the scenes.
While definitely not as common as the annotation-based approach, Spring also supports registering routes inline. The actual router instance is hidden away and the routes will be configured using the DI container.
TODO: Check if this actually runs. Maybe it is actually ok().text()
or something.
See https://www.baeldung.com/spring-5-functional-web for a more elaborate example.
Separate Files
Placing all your routes in a single file can get overwhelming.
Since many programming languages have a module system using classes, frameworks often group related routes into something called a controller.
If you are building a blog, you might group the routes for viewing, updating and deleting a post in a class called PostController
.
Usually the PostController
then has functions like showBlogPost
, deleteBlogPost
, or editBlogPost
.
TODO: Mention MVC
and state that e.g. Django does not use MVC, but still registers routes in a separate file.
Django encourages simplicity. We define a simple function that returns our response
and then register it in, what the documentation refrers to as the “table of contents” of the site, the urls.py
file.
In Laravel you typically define a class and then separately define your matching logic inside a routes file.
In this example we have a simple GreetingController
that returns a string:
We then register it using the Route
Facade, which resolves a Router
from the Dependency Injection Container and registers a new route GET /greeting
which is handled by our GreetingController
.
We create a controller class handling requests.
In their documentation, the parameter is not called request
, but conn
and holds similar information as the request object in other frameworks.
We then refer to it using :greet
in our Router
.
There is a bunch of boilerplate to define common configuration for an API, which you can ignore for now.
The registration happens on the highlighted line.
Given the following
we can use the routing DSL implemented by the Play framework to register our controller:
Given the following controller, which simply returns a string
we can map it to a route using Rails DSL for registering routes:
Note that unlike other frameworks, rails leans heavily on conventions and does not reference the controller by a fully qualified identifier.
TODO: Description
While Symfony nowadays recommends a different approach, you can register all your routes using your config/
directory.
Therefore given the following controller class:
We can use the following YAML file to map it to a URL:
As with all config/
files, you are also free to use PHP or XML files.
Visit the official documentation to learn how.
Annotations
A simple approach like the inline one is easy to understand, since you can instantly see whats happening: a method-path-pair is associated with a piece of code that runs for matching requests. However, when you have complex logic or lots of routes, the resulting files can get quite large. TODO: can seperate up into multiple files via the languages module system
Since some languages like Java naturally divide their logic up into classes and methods, some frameworks have opted to annotate pieces of code with the method-path-pair. This could look something like this pseudo-code:
The information is similar to the inline approach, however the router
instance we saw earlier is now hidden away from us.
It is somehow built up by the framework, which usually also provides the necessary hooks for us to implement our own Router
class or customize the existing one.
Behind the scenes, many annotation-based approaches have a discovery process where they search the classes, functions and other parts of the source codes for annotations.
Once all those pieces are discovered, the metadata from the annotations is iterated upon and similar methods like the one shown in the inline approach is used to register the routes in a router
instance created by the framework.
Examples include Symfony, in which developers list paths that should be searched for annotations in a YAML file.
FastAPI constructs an app
instance, which is then used to register routes via an Python decorator.
This is a bit of a mix of the inline approach and the annotation-based one.
Note that FastAPI has features and conventions to make it easier to split up the route registration into separate files and folders.
Spring provides the @RestController
-annotation, which marks a class as containing methods that respond to requests.
These are annotated with @GetMapping
, which contains more metadata, such as which paths or HTTP methods should be routed to this method.
Symfony comes with the #[Route]
-attribute that marks a method or class as the handler for a route and contains the necessary metadata.
Compared to the previously shown approach, we colocated our registration logic with the rest of our logic regarding that endpoint.
This also means, that if we want to delete this route, we can simply delete the GreetingController.php
file.
However, it is not as straight forward to see all of our possible routes.
This now requires running a terminal command that Symfony provides out of the box.
Note that the registration via attributes is only possible, when you configure Symfony that way (which it is by default though).
This is the one mentioned by the official documentation, which instructs the framework to look for attributes in the App\Controllers
namespace.
- TODO: Also show how a prefix may be added by annotating the controller class
Using The Filesystem
The previous methods all contain some form of duplication. Consider the following example:
Method | Path | Controller |
---|---|---|
GET | /greet | GreetingController::greet |
The GreetingController
has a greet
function, which handles GET
requests to /greet
.
All of this likely resides somewhere in the src/controllers/
folder in a file called greeting_controller.something
.`
Your framework may get around some of the duplication, e.g. by allowing directly using functions instead of having to attach them to a class, but surely not all of it.
A solution to this problem is using conventions and the filesystem:
After installing the first-party Laravel Folio package, all templates in resources/views/pages/
will be mapped to a route.
The path is based on the file path relative to the pages/
directory.
For example this file
lives in resources/views/pages/greet.blade.php
and therefore it will be rendered to all GET
requests to /greet
.
Note that this approach is limited to GET
requests!
If you want to learn how to configure other directories or handle requests to /
, have a look at the official documentation.
In the Next.js page router, everything in the pages/
directory is mapped based on its file path.
The following code lives in pages/api/greet.js
, so all requests to /api/greet
will be handled by the exported handler
function:
There are also other conventions, e.g. for handling dynamic paths, that you can read about in the official documentation
The downside of this apporoach is that now tooling is required to e.g
Programatically Listing Routes
When you register your routes in a single file, you might have a nice overview of all routes in your application. However, this often includes middleware configuration, grouping and for large applications you might want to split up e.g. all admin routes into their separate file. When using annotations to register your routes, you would need to look into many files to see available endpoints.
This is why many frameworks include a way of showing all routes programatically (similar to the tables you see in this guide). Note that this can have multiple use cases. For local development, it is useful to know, which class, method or function handles requests to a given path.
However, users (as in other developers) of your JSON-based API are also very much interested in a list of available endpoints. They might care more about what data they need to put in the request body, and what the response will look like.
CLI
If a framework includes a commandline interface, it likely comes with a command to print out all routes to the console. Oftentimes this also contains additional information, like attached middleware, route groups or a reference to the function handling the request.
python manage.py show_urls
from django-extensions
Laravel’s artisan
commandline interface comes with the route:list
command.
You also have the ability print more detailed information (--verbose
):
It supports several filtering methods like only showing routes for a certain path, http method, or only the ones registered by third-party packages or your application. All of this information can be output as JSON to enable programmatic consumption like editor integration.
It can list all routes
and also test a url against the router, to see which controller will handle *
The console
binary comes with the debug:router
command.
The list also supports more detailed information and multiple output formats, including JSON.
If you want detailed information about a single route you can pass its name:
On top of listing routes, Symfony lets you test a path against the applications router and returns the matched route:
This is especially helpful when passing the --verbose
parameter to get more detailed output:
Very useful for quickly viewing the associated controller for a given request!
Http
- Spring: Actuator exposes
- FastAPI provides Swagger UI that also lists routes. Maybe this should also be listed and linked separately?
Editor Integration
Note: This section might get out of hand quickly, since we have
number of editors * number of frameworks
entries here.
- Spring: use IntelliJ or log them at application start. Also actuator exposes
Dynamic Paths
- Type safety
- Syntaxes
articles/:id
(Spring?)articles/{id}
(Laravel)['/articles', ':id']
(Vapor) https://docs.vapor.codes/basics/routing/#path-componentarticles/<id>
(Django)- Raw regexes?
Honorable mentions:
- dotnet supports something like
/api/[controller]
where[controller]
will be replaced by the name of the controller class without theController
suffix: https://learn.microsoft.com/en-us/aspnet/core/tutorials/first-web-api#routing-and-url-paths
Safe Path Parameters
Many frameworks safe the route parameters as key-value pairs that needs to be accessed on runtime.
Grouping Routes
TODO: Maybe separate out into its own concept? Maybe put it into the middleware concept?
- Vapor (https://docs.vapor.codes/basics/routing/#route-groups)
- FastAPI
- Laravel Resource controllers and middleware groups
- Rails
- Django
- Phoenix https://hexdocs.pm/phoenix/routing.html#resources
Conventional Shortcuts
Many applications follow a RESTful convention when registering their routs.
Method | Path | Response |
---|---|---|
GET | /articles | List all articles |
GET | /articles/create | Show a form for creating a new article |
GET | /articles/{id} | Shows a detailed view of an existing article |
GET | /articles/{id}/edit | Shows a form for editing an existing article |
POST | /articles | Create a new article based on the forms data |
PUT | /articles/{id} | Update an existing article based on the forms data |
DELETE | /articles/{id} | Deletes an existing article |
If your resource are not blog posts, simply replace /articles
with something else, e.g. /products
for a online store.
The same conventions still apply and make sense.
Since this pattern is so common, some frameworks created shortcuts for registering routes adhering to it. That way you only need one call.
Validation And Type Safety
What if you have a typo in your route parameters? Some would say, that your tests should catch this case. Others prefer static or other type of validation, to ensure your routes are properly configured.
instead of
More Advanced Routers
- Authenticated vs. Non-Authenticated Routes (Route-Middleware)
Route Dependencies
Request Object
- Symfony / Laravel
- Express
- Vapor
Mapping The Request Body To A Type
Link to “Validation” article
- Symfony
- Laravel FormRequest
- Spring
Dependency Injection
- Symfony
- Laravel
Returning a Response
A handler for a route is commonly defined as a function or method.
Return Nothing
Express does this:
Return Something
Some frameworks let you return anything from your handler and try to turn it into a response.
- Laravel
- Interesting behavior for models that have just been created leading to a 201 status code
- Symfony?
- Spring
- Pluggable serializers like Jackson
Return Response Object
- DI
- Request