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)
...