Routing

Interaction with your website or application will be done via URLs like /docs or /api/users. To make this work you need to define code that will be executed when a user visits these URLs. This is what routing is: you declare routes which associate a URL and a function, in the basic cases.

from expanse.routing.router import Router


def say_hello() -> str:
    return "Hello world!"


router = Router()
router.get("/hello", say_hello)

Using route declaration files

To make route declaration more easily discoverable and manageable, it is recommended to use route declaration files located in the app/routes directory. These files are standard Python files which need to expose a routes function that accepts a Registrar instance.

from expanse.contracts.routing.registrar import Registrar


def say_hello() -> str:
    return "Hello world!"


def routes(router: Registrar) -> None:
    router.get("/hello", say_hello)

By default, Expanse defines two route files: app/routes/web.py and app/routes/api.py. These files are automatically loaded by your application's RouteServiceProvider, located in the app/providers/route_service_provider.py file. You can remove them or add more as needed.

The app/routes/web.py file defines routes that are for your web interface. They are automatically assigned the web middleware group, which provides standard features like session management and CSRF protection.

The app/routes/api.py file defines stateless routes with the api middleware group. These routes are automatically assigned the /api URI prefix and are part of the api group, meaning that all route names start with api..

Unless you use Expanse to implement only an API (in that case you can remove the app/routes/web.py file) you will start defining routes in the app/routes/web.py file. These routes are easily accessible in a web browser by entering the defined URL. For instance, the following route would be accessible by going to http://localhost:8000/articles:

from expanse.contracts.routing.registrar import Registrar


def list_articles() -> Response:
    ...


def routes(router: Registrar) -> None:
    router.get("/articles", list_articles)

Routes defined in the app/routes/api.py file are part of a route group. This group is assigned the /api URI prefix which will be automatically applied, so you do not need to manually apply it to every route in the file. You may modify the prefix and other route group options by modifying your RouteServiceProvider class.

Route handler

The route handler is responsible for processing the incoming request and returning a response or stopping the request by raising an exception.

The handler can be a simple function or a controller method:

from expanse.contracts.routing.registrar import Registrar

from app.http.controllers.article_controller import ArticleController


def routes(router: Registrar) -> None:
    router.get("/articles", ArticleController.index)

If the handler is a controller method like in the example above, Expanse will automatically create an instance of the controller class during the HTTP request using the service container and execute the corresponding method.

For more information about controllers, you can refer to the Controllers section.

If the controller has no dependencies or does not depend on scoped services to be instantiated, you can declare the controller as a singleton in the service container. This will improve performance by avoiding the overhead of creating a new instance of the controller for every request.

self._app.singleton(ArticleController)

Available methods

You can register routes that correspond to any HTTP verbs:

router.get(uri, function)
router.post(uri, function)
router.put(uri, function)
router.patch(uri, function)
router.delete(uri, function)
router.options(uri, function)

Using decorators to declare routes

If you prefer to keep your route definitions close to the corresponding handlers, you can leverage the decorators provided by Expanse:

from expanse.contracts.routing.registrar import Registrar
from expanse.routing.helpers import get


@get("/hello", name="hello")
def say_hello() -> str:
    return "Hello world!"


def routes(router: Registrar) -> None:
    router.handler(say_hello)

You can use them on controllers as well:

from expanse.contracts.routing.registrar import Registrar
from expanse.http.response import Response
from expanse.routing.helpers import get


class ArticleController:
    @get("/articles", name="articles.index")
    def index(self) -> Response:
        ...


def routes(router: Registrar) -> None:
    router.controller(ArticleController)

Finally, if you want to have all routes of a controller to belong to a specific group, you can use the group decorator on the controller:

from expanse.http.response import Response
from expanse.routing.helpers import get, group


@group("articles", prefix="/articles")
class ArticleController:
    @get("/articles", name="articles.index")
    def index(self) -> Response:
        ...

Dependency injection

You may type-hint any dependencies required by your route in your route's function signature. The declared dependencies will automatically be resolved and injected into the function by the Expanse service container. For example, you may type-hint the expanse.http.request.Request class to have the current HTTP request automatically injected into your route function:

from expanse.http.request import Request


def say_hello(request: Request) -> str:
    return "Hello world!"

Route parameters

You can specify that parts of your routes URI is a parameter or variable that need to be captured and, optionally, validated. This is typically the case of retrieving the user's ID from the URL:

router.get("/users/{id}", lambda id: f"User {id}")
URL id
/users/1 1
/users/john-doe john-doe

As you can see, parameters are declared in the same way as Python's format strings, i.e. encased in {} braces.

A URI can specify multiple parameters if necessary, as long as they have unique names:

def get_post(id: str, post_id: str) -> str:
    return f"User {id}, post {post_id}"


router.get("/users/{id}/posts/{post_id}", get_post)
URL id post_id
/users/1/posts/4 1 4
/users/john-doe/posts/foo john-doe foo

Validating and converting parameters

By default, parameters will match any character except for /. If you want to specify a different format you can do so by specifying a regular expression:

router.get("/users/{id:\d+}", lambda id: f"User {id}")

In this example, the id parameter will only match digits.

This allows you to declare multiple routes with the same URI but different parameter formats:

router.get("/users/{id:\d+}", lambda id: f"User {id}", name="route1")
router.get("/users/{id:\w+}", lambda id: f"User {id}", name="route2")
URL id Route
/users/1 1 route1
/users/john-doe john-doe route2

While specifying the format of parameters helps the router to match the correct route and act as a first layer of validation, the route handler will still retrieve the parameter as a string by default. If you want to convert the parameter to a specific type, you can type-hint the parameter in your route handler:

def get_user(id: int) -> str:
    return f"User {id}"


router.get("/users/{id:\d+}", get_user)

The id parameter will be automatically converted to an integer before being passed to the get_user function.

This works with any type that can be converted from a string using the __init__ method of the type. For instance, you can use the uuid.UUID type to convert a parameter to a UUID or use an enum to convert a parameter to an enum value:

from enum import StrEnum


class Category(StrEnum):
    SPORTS = "sports"
    INTERNATIONAL = "international"


def list_articles(category: Category) -> list[str]:
    ...


router.get("/categories/{category}", list_articles)

If the router cannot convert the parameter to the specified type, it will continue walking through registered routes, if no routes match it will return a 404 Not Found HTTP error.

Named routes

You can optionally give a unique name to your routes. This is a convenient way to generate URLs to specific routes. To specify a name to a route, you can use the name keyword argument when declaring your route:

router.get("/user/{id}", lambda id: f"User {id}", name="users.retrieve")

Generating named routes URLs

Once routes have a dedicated name, you can easily generate the corresponding URL by using the route() method of the Router:

url = router.route("users.list")

Similarly, you can easily redirect to a named route with a Redirect instance:

from expanse.http.redirect import Redirect


def handler(redirect: Redirect) -> Response:
    return redirect.to_route("user.list")

If the named route has parameters, you can specify them as a dictionary:

router.route("users.retrieve", {"id": 1})
redirect.to_route("users.retrieve", {"id": 1})

Any additional parameters will be added to the query string of the generated URL:

router.route("users.retrieve", {"id": 1, "extended": "true"})

# /users/1?extended=true

Grouping routes

Route groups are a convenient way to organize your routes and share common elements like middleware or prefix between them.

Route groups are created via the group() method of the Router:

from expanse.contracts.routing.registrar import Registrar

router: Registrar
with router.group("api") as group:
    group.get("/users/{id}", get_user, name="users.retrieve")

Note that group names and route names are appended, so this example the route you have api.users.retrieve.

Prefixing routes in a group

The URI of routes pertaining to a group can be prefixed by using the prefix keyword argument:

from expanse.contracts.routing.registrar import Registrar

router: Registrar
with router.group("api", prefix="/api") as group:
    group.get("/users/{id}", get_user, name="retrieve")

In this example, the generate URL for the retrieve route would be /api/users/{id}.

Middleware

If you need to share a set of middleware to every route in a group you can use the middleware() method:

from expanse.contracts.routing.registrar import Registrar

router: Registrar
with router.group("users") as group:
    group.middleware(FirstMiddleware, SecondMiddleware)
    ...