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:
router = Router()
router.register(method: "GET", path: "/greeting", logic: function() { return "Hello World"})
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:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapGet("/greeting", () => "Hello World!");
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.
const express = require("express");
const app = express();
app.get("/greeting", (request, response) => { response.send("Hello World");});
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.
import Vapor
let app = try Application(.detect())
app.get("greeting") { request in return "Hello World"}
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.
app.get("blog", "about") { request in return "Something about the blog"}
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.
package com.example.routes
import io.ktor.http.*
fun Application.configureRouting() { routing { route("/greeting") { get { call.respondText("Hello World", status = HttpStatusCode.OK) } } }}
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.
Route::get('/greeting', function () { return 'Hello World';});
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.
package com.example.hello;
@Configurationclass GreetingConfiguration { @Bean RouterFunction<ServerResponse> greet() { return route( GET("/greeting"), req -> ok().body("Hello World"), ); }}
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
from django.http import HttpResponse
def greet(request): return HttpResponse("Hello World")
and then register it in, what the documentation refrers to as the “table of contents” of the site, the urls.py
file.
from django.urls import pathimport .views
urlpatterns = [ path('greeting', views.greet),]
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:
<?php
namespace App\Http\Controllers;
class GreetingController{ public function greet(): string { return "Hello World"; }}
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
.
<?php
use Illuminate\Support\Facades\Route;use App\Http\Controllers\GreetingController;
// All requests to `/greeting` should be handled by// the `GreetingController`.Route::get('/greeting', [GreetingController::class, 'greet']);
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.
defmodule HelloWeb.GreetingController do use HelloWeb, :controller
def greet(conn) do text(conn, "Hello World") endend
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.
defmodule HelloWeb.Router do use HelloWeb, :router
pipeline :api do plug :accepts, ["json"] end
scope "/", HelloWeb do pipe_through :api
get "/", PageController, :greet endend
Given the following
package controllers
import play.api.mvc._
class GreetingController @Inject() (cc: ControllerComponents) extends AbstractController(cc) { def greet = Action { Ok("Hello World") }}
we can use the routing DSL implemented by the Play framework to register our controller:
GET /greet controllers.GreetingController.greet()
Given the following controller, which simply returns a string
class GreetingController < ApplicationController def greet render plain: "Hello World" endend
we can map it to a route using Rails DSL for registering routes:
Rails.application.routes.draw do get 'greet', to: 'greeting#greet'end
Note that unlike other frameworks, rails leans heavily on conventions and does not reference the controller by a fully qualified identifier.
TODO: Description
import { Router, Route } from "@redwoodjs/router";
const Routes = () => { return ( <Router> <Route path="/greet" page={GreetingPage} name="greet" /> </Router> );};
export default Routes;
While Symfony nowadays recommends a different approach, you can register all your routes using your config/
directory.
Therefore given the following controller class:
<?php
namespace App\Controller;
use Symfony\Component\HttpFoundation\Response;
class GreetingController{ public function greet(): Response { return new Response('Hello World'); }}
We can use the following YAML file to map it to a URL:
greet: path: /greet controller: App\Controller\GreetingController::greet
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:
class GreetingController { @Route(method: "GET", path: "/greet") function greet() { return "Hello World"; }}
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.
from fastapi import FastAPI
app = FastAPI()
@app.get("/greeting")def read_root(): return "Hello World"
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.
package com.example.hello;
import org.springframework.web.bind.annotation.GetMapping;import org.springframework.web.bind.annotation.RestController;
@RestControllerpublic class GreetingController { @GetMapping("/greeting") public String greeting() { return "Hello World"; }}
Symfony comes with the #[Route]
-attribute that marks a method or class as the handler for a route and contains the necessary metadata.
<?php
namespace App\Controller;
use Symfony\Component\HttpFoundation\Response;use Symfony\Component\Routing\Annotation\Route;
class GreetingController{ #[Route('/greeting')] public function __invoke(): Response { return new Response('Hello World'); }}
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.
controllers: resource: path: ../../src/Controller/ namespace: App\Controller type: attribute
- 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
<h1>Hello World</h1>
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:
export default function handler(request, response) { // All HTTP methods are handled by this function, // so we need to differentiate between the logic // for each method ourselves. switch (request.method) { case "GET": response.status(200).text("Hello World"); break;
default: response.status(405).text("Method not allowed"); }}
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.
php artisan route:list
GET|HEAD / ............................................................................................................... POST _ignition/execute-solution ........ ignition.executeSolution › Spatie\LaravelIgnition › ExecuteSolutionController GET|HEAD _ignition/health-check .................... ignition.healthCheck › Spatie\LaravelIgnition › HealthCheckController POST _ignition/update-config ................. ignition.updateConfig › Spatie\LaravelIgnition › UpdateConfigController GET|HEAD api/user ........................................................................................................ GET|HEAD sanctum/csrf-cookie ........................... sanctum.csrf-cookie › Laravel\Sanctum › CsrfCookieController@show
Showing [6] routes
You also have the ability print more detailed information (--verbose
):
php artisan route:list --verbose
GET|HEAD / ......................................................................................................................................................... ⇂ web POST _ignition/execute-solution .................................................. ignition.executeSolution › Spatie\LaravelIgnition › ExecuteSolutionController ⇂ Spatie\LaravelIgnition\Http\Middleware\RunnableSolutionsEnabled GET|HEAD _ignition/health-check .............................................................. ignition.healthCheck › Spatie\LaravelIgnition › HealthCheckController ⇂ Spatie\LaravelIgnition\Http\Middleware\RunnableSolutionsEnabled POST _ignition/update-config ........................................................... ignition.updateConfig › Spatie\LaravelIgnition › UpdateConfigController ⇂ Spatie\LaravelIgnition\Http\Middleware\RunnableSolutionsEnabled GET|HEAD api/user .................................................................................................................................................. ⇂ api ⇂ App\Http\Middleware\Authenticate:sanctum GET|HEAD sanctum/csrf-cookie ..................................................................... sanctum.csrf-cookie › Laravel\Sanctum › CsrfCookieController@show ⇂ web
Showing [6] routes
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
mix phx.routesGET /users HelloWeb.UserController :indexGET /users/:id/edit HelloWeb.UserController :editGET /users/new HelloWeb.UserController :newGET /users/:id HelloWeb.UserController :showPOST /users HelloWeb.UserController :createPATCH /users/:id HelloWeb.UserController :updatePUT /users/:id HelloWeb.UserController :updateDELETE /users/:id HelloWeb.UserController :delete
and also test a url against the router, to see which controller will handle *
mix phx.routes --info http://0.0.0.0:4000/users --method post Module: RouteInfoTestWeb.UserController Function: :create /home/my_app/controllers/user_controller.ex:24
The console
binary comes with the debug:router
command.
php bin/console debug:router
---------------- ------- ------- ----- --------------------------------------------Name Method Scheme Host Path---------------- ------- ------- ----- --------------------------------------------homepage ANY ANY ANY /contact GET ANY ANY /contactcontact_process POST ANY ANY /contactarticle_show ANY ANY ANY /articles/{_locale}/{year}/{title}.{_format}blog ANY ANY ANY /blog/{page}blog_show ANY ANY ANY /blog/{slug}app_lucky_number ANY ANY ANY /lucky/number/{max}---------------- ------- ------- ----- --------------------------------------------
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:
php bin/console debug:router app_lucky_number+--------------+---------------------------------------------------------------+| Property | Value |+--------------+---------------------------------------------------------------+| Route Name | app_lucky_number || Path | /lucky/number/{max} || Path Regex | {^/lucky/number/(?P<max>[^/]++)$}sDu || Host | ANY || Host Regex | || Scheme | ANY || Method | ANY || Requirements | NO CUSTOM || Class | Symfony\Component\Routing\Route || Defaults | _controller: App\Controller\LuckyNumberController::generate() || Options | compiler_class: Symfony\Component\Routing\RouteCompiler || | utf8: true |+--------------+---------------------------------------------------------------+
On top of listing routes, Symfony lets you test a path against the applications router and returns the matched route:
php bin/console router:match /lucky/number/8
[OK] Route "app_lucky_number" matches
This is especially helpful when passing the --verbose
parameter to get more detailed output:
php bin/console router:match --verbose /lucky/number/8
Route "_preview_error" does not match: Path "/_error/{code}.{_format}" does not match Route "_wdt" does not match: Path "/_wdt/{token}" does not match Route "_profiler_home" does not match: Path "/_profiler/" does not match Route "_profiler_search" does not match: Path "/_profiler/search" does not match Route "_profiler_search_bar" does not match: Path "/_profiler/search_bar" does not match Route "_profiler_phpinfo" does not match: Path "/_profiler/phpinfo" does not match Route "_profiler_xdebug" does not match: Path "/_profiler/xdebug" does not match Route "_profiler_search_results" does not match: Path "/_profiler/{token}/search/results" does not match Route "_profiler_open_file" does not match: Path "/_profiler/open" does not match Route "_profiler" does not match: Path "/_profiler/{token}" does not match Route "_profiler_router" does not match: Path "/_profiler/{token}/router" does not match Route "_profiler_exception" does not match: Path "/_profiler/{token}/exception" does not match Route "_profiler_exception_css" does not match: Path "/_profiler/{token}/exception.css" does not match
[OK] Route "app_lucky_number" matches
+--------------+---------------------------------------------------------------+| Property | Value |+--------------+---------------------------------------------------------------+| Route Name | app_lucky_number || Path | /lucky/number/{max} || Path Regex | {^/lucky/number/(?P<max>[^/]++)$}sDu || Host | ANY || Host Regex | || Scheme | ANY || Method | ANY || Requirements | NO CUSTOM || Class | Symfony\Component\Routing\Route || Defaults | _controller: App\Controller\LuckyNumberController::generate() || Options | compiler_class: Symfony\Component\Routing\RouteCompiler || | utf8: true |+--------------+---------------------------------------------------------------+
Very useful for quickly viewing the associated controller for a given request!
swift run App routes+--------+----------------+| GET | / |+--------+----------------+| GET | /hello |+--------+----------------+| GET | /todos |+--------+----------------+| POST | /todos |+--------+----------------+| DELETE | /todos/:todoID |+--------+----------------+
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:
app.get(function (request, response) { response.writeText("")})
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