Routing

7 min read · last updated at

When 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:

MethodPathLogic
GET/render the hompeage
GET/loginrender the login form
POST/loginauthenticate 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:

Program.cs
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.

main.js
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.

plugins/Routing.kt
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.

routes/web.php
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;
@Configuration
class 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

views.py
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.

urls.py
from django.urls import path
import .views
urlpatterns = [
path('greeting', views.greet),
]

Learn more

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:

app/Http/Controllers/GreetingController.php
<?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.

routes/web.php
<?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.

lib/hello_web/controllers/greeting_controller.ex
defmodule HelloWeb.GreetingController do
use HelloWeb, :controller
def greet(conn) do
text(conn, "Hello World")
end
end

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.

lib/hello_web/router.ex
defmodule HelloWeb.Router do
use HelloWeb, :router
pipeline :api do
plug :accepts, ["json"]
end
scope "/", HelloWeb do
pipe_through :api
get "/", PageController, :greet
end
end

Given the following

app/controllers/GreetingController.scala
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:

conf/routes
GET /greet controllers.GreetingController.greet()

Given the following controller, which simply returns a string

app/controllers/greeting_controller.rb
class GreetingController < ApplicationController
def greet
render plain: "Hello World"
end
end

we can map it to a route using Rails DSL for registering routes:

config/routes.rb
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

web/src/Routes.jsx
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:

src/Controller/GreetingController.php
<?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:

config/routes.yaml
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.

app.py
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.

src/com/example/hello/GreetingController.java
package com.example.hello;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public 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.

src/Controller/GreetingController.php
<?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.

config/routes/attributes.yaml
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:

MethodPathController
GET/greetGreetingController::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

resources/views/pages/greet.blade.php
<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:

pages/api/greet.js
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.

Terminal window
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):

Terminal window
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

Terminal window
mix phx.routes
GET /users HelloWeb.UserController :index
GET /users/:id/edit HelloWeb.UserController :edit
GET /users/new HelloWeb.UserController :new
GET /users/:id HelloWeb.UserController :show
POST /users HelloWeb.UserController :create
PATCH /users/:id HelloWeb.UserController :update
PUT /users/:id HelloWeb.UserController :update
DELETE /users/:id HelloWeb.UserController :delete

and also test a url against the router, to see which controller will handle *

Terminal window
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.

Terminal window
php bin/console debug:router
---------------- ------- ------- ----- --------------------------------------------
Name Method Scheme Host Path
---------------- ------- ------- ----- --------------------------------------------
homepage ANY ANY ANY /
contact GET ANY ANY /contact
contact_process POST ANY ANY /contact
article_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:

Terminal window
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:

Terminal window
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:

Terminal window
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!

Terminal window
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

Honorable mentions:

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?

Conventional Shortcuts

Many applications follow a RESTful convention when registering their routs.

MethodPathResponse
GET/articlesList all articles
GET/articles/createShow a form for creating a new article
GET/articles/{id}Shows a detailed view of an existing article
GET/articles/{id}/editShows a form for editing an existing article
POST/articlesCreate 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