Building a Decorator for a FastAPI Route
Simplify and Beautify Your App Routes
I've been working on yet another side project, once again built with FastAPI. I'm taking the opportunity to learn a few new skills and work with some new tools. Along the way, I found a way to use a decorator on my FastAPI routes when sending an HTML response, so I thought to write about it.
A Typical FastAPI Route With HTML Response
If you're using Jinja templates for your FastAPI application (maybe you're building a landing page, or building a blog), there are a few things that you need to do to accomplish this.
- Create a
templates
object using FastAPI'sJinja2Template
- Declare a
Request
parameter in your route/view operation - Use the the
templates
object to render aTemplateResponse
- On the response, pass the name of the appropriate template file
- Also, pass the template "context", which includes the route
Request
Now, that seems like a lot, but it's not really as bad as it sounds.
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
app = FastAPI()
# This tells FastAPI which directory contains Jinja templates
templates = Jinja2Templates(directory="path/to/templates")
@app.get("/", response_class=HTMLResponse)
async def get_page(request: Request)
return templates.TemplateResponse(
"index.html", # Name of template file
context = {"request": request} # The "template context"
)
When a client visits the "/" route, FastAPI receives the request object and passes the key-value pair {"request": Request}
within the Jinja "context". In addition, you need to tell Jinja where to find the template file you want rendered (in this case index.html
).
To send additional objects to the Jinja template, you would add the key-value pairs to the context
dictionary.
You can then access those objects from within the Jinja template.
Finding Potential Use Case for a Decorator
I started realizing that for each new route in my application, I would need to include the TemplateResponse
object, requiring its own set of parameters.
In addition, in each new module (.py file) containing routes, I would also need to define the Jinja2Templates
object pointing to my templates directory.
And I could foresee that every time I needed to add additional objects to the template context, I would need to insert it directly into my route.
For example, let's say I wanted to pass a title and description to the Jinja template in order to populate the <title>
and <meta name="description" ...>
tags in the HTML template.
My route might look like this:
@app.get("/info", response_class=HTMLResponse)
async def get_info(request: Request)
meta_title: str = "Information Page"
meta_description: str = "All the information is here!"
return templates.TemplateResponse(
"info.html", # Name of template file
context = { # Parameters sent to template
"request": request,
"meta_title": meta_title,
"meta_description": meta_description
}
In an app with a lot of routes, this can quickly get out of hand.
Maybe I could use a decorator to move some of this logic out of view?
Quick Word on Template Context
You'll note above that the meta_title
and meta_description
fields are defined directly in the route, though that isn't ideal.
Instead, I have placed the "view" logic elsewhere in my application. All my route really cares about is receiving a dictionary of key-value pairs to pass to the relevant template.
My route then looks something like:
@app.get("/info", response_class=HTMLResponse)
async def get_info(request: Request)
view_model: dict = DefaultView() # Returns a dictionary of values
return templates.TemplateResponse(
"info.html",
{
"request": request,
"meta_title": view_model.meta_title,
"meta_description": view_model.meta_description
}
Finally, Let's Get Started
So the goal is to wrap the route function and do all these things inside:
- Create the
Jinja2Template
object - Pull the
Request
object from the route - Update a new "context" with any additional key-value pairs
- Return a
TemplateResponse
containing the "context" - Bonus: Define the name of the template file in the decorator!
I begin by importing the functools.wraps
function at the top of my decorator module.
Next, I define the name of the decorator. In this case, my decorator will ultimately be responsible for creating my route's response, so I chose to call it response
. Boring, but straight to the point.
from functools import wraps # This is used further down
def response(*, template_file: str):
This will allow me to define the appropriate template file name directly in the decorator using the template_file
argument.
The *
listed at the start of the arguments means that the template_file
argument will have to be explicitly supplied as a key-value pair.
Note: The
functools.wraps
decorator takes care of some things under the hood, but I won't get into that here. Here's a stack overflow topic on what it does.
For the curious, this is what the decorator will look like when I'm done:
@response(template_file="index.html")
Defining the Inner Function
In short, a Python decorator "wraps around" a function and is able to modify behavior, either before and/or after the function executes.
As such, I'll be creating nested functions to create my custom decorator.
First, to define the inner function, and take care of initiating the templates
object:
def response(*, template_file: str):
def inner(func): # `func` i.e., the decorated function
templates = Jinja2Templates(
directory = "path/to/templates"
)
In my code, I actually have a method defined elsewhere that creates the templates
object, but for simplicity, I'll leave this here as is.
Again, this tells FastAPI where to find your template files (like index.html
or info.html
).
Wrapping the Route
Next, it's time to wrap the route method. Here, view_method
will introduce additional logic/behavior before and after we access the results of the wrapped func
(decorated function).
@wraps(func)
async def view_method(*args, **kwargs):
The *args
and **kwargs
attributes will capture the arguments passed to the decorated function.
So if my route were defined like this:
async def get_info(request=Request, some_value="Hi")
The key-value argument pairs above are passed as **kwargs
.
There are a couple of things to do before accessing the results of the wrapped function.
I want to initiate a template_response
that will eventually output with the appropriate FastAPI TemplateResponse
.
In addition, I want to extract the Request
object from the **kwargs
argument and include this key-value pair in the overall template context.
Back to the Route
Quick aside... Since my end goal is to send the valid response from the decorator function, that means that the return value of my route no longer needs to be a TemplateResponse
.
All I really need from my route is the template context, or rather, the key-value pairs of values I want to access in the template.
@app.get("/info")
async def get_home(request: Request):
"""Simplified route, returning dictionary."""
route_context: dict = DefaultView()
return route_context # Captured within the decorator
Now I can access this route_context
from within the decorator to ultimately build my response.
Creating the Template Context
In order to retrieve the route's return value, I will call it from the func
object and all its arguments. Remember, func
refers to the decorated function.
view_context = await func(*args, **kwargs)
We await
the function object because the decorated route is defined with async
.
Note: If I needed to decorate a regular function (non-async), this decorator would not work like this. (I could remove the
await
keyword, or perhaps write separate logic within decorator...)
Now, the results of decorated function (which I expect to be a dict
) need to also include the Request
object passed to the original route.
So, in case you were keeping track up above, I need to:
- Initiate a template response
- Extract the
Request
from the arguments of wrapped function - Extract the
context
, which is the return value of wrapped function - Combine the request key-value pair with the
context
to create the template context
And lastly:
- Create a
TemplateResponse
that includes the template's file name and the new template context
For the first four items above, the code looks like this:
# 1) Initiate response
template_response: Response | None = None
# 2) Extract {k,v} from wrapped function containing `Request`
request_context = {
k: v for k, v in kwargs.items() if isinstance(v, Request)
}
# 3) Retrieve view context from wrapped function
view_context = await func(*args, **kwargs)
# 4) Combine dictionaries from items 2 and 3
context: dict = view_context.to_dict() | request_context
Phew. Nearly there!
The second item up there may be a little tricky for newcomers. That's a dictionary comprehension. It cycles through the **kwargs
provided in the wrapped function, and only extracts the one containing the Request
value.
So, if your route looked like this:
async def get_home(request: Request, x="another thing"):
The request_context
would ignore x="another thing"
altogether.
Note: When combining dictionaries, I use the
|
operator, which is new as of Python 3.9+.
And Finally
In order to create the appropriate Response
, I can now use the template_file
value passed through the decorator, as well as the context
I just created to build the appropriate TemplateResponse
.
if not template_file: # Check to see if file exists
raise Exception(
f"Template file not found."
)
if isinstance(context, dict): # Build template response
template_response = template.TemplateResponse(
template_file,
context=context
)
else: # Something went wrong
raise Exception(
f"Context of type {type(context)} not valid."
)
return template_response
Let's See the Whole Thing
After it's all said and done, the decorator sends the template_response
to the template, and the routes look much cleaner and more legible.
So let's see the whole decorator in action:
# decorators.py
from functools import wraps
from fastapi.templates import Jinja2Templates
def response(*, template_file: str | None = None):
def inner(func):
templates = Jinja2Templates(
directory = "path/to/templates"
)
@wraps(func)
async def view_method(*args, **kwargs):
template_response: Response | None = None
request_context = {
k: v for k, v in kwargs.items() if isinstance(v, Request)
}
view_context = await func(*args, **kwargs)
context: dict = view_context.to_dict() | request_context
if not template_file:
raise Exception(
"Template file not found."
)
if isinstance(context, dict):
template_response = templates.TemplateResponse(
template_file,
context=context
)
else:
raise Exception(
"Context not valid."
)
return template_response
return view_method
return inner
So what does my route look like now?
# routes.py
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from decorators import response
app = FastAPI()
@app.get("/info", response_class=HTMLResponse)
@response(template_file="info.html")
async def get_info(request: Request)
"""My newly decorated function!"""
route_context = DefaultView()
return route_context
Ah. That looks much better!
First, notice that I no longer have to define the Jinja2Templates
object at the top of routes.py
. This is now handled by the decorator.
Potentially, this means that we have to instantiate that object with each route, but this could potentially be helped with caching via lru_cache
and handling instantiation elsewhere.
Another thing I like is that I can visually decipher which template file applies to my route by looking at the decorator, instead of finding it within the TemplateResponse
.
And lastly, and perhaps more importantly, let's say that my route_context
contains a multitude of variables I wanted to pass through the TemplateResponse
.
My route response could potentially be quite large.
For example, take a look at this undecorated route.
@app.get("/info", response_class=HTMLResponse)
async def get_info(request: Request)
"""My not-so-decorated function!"""
context = DefaultView()
return templates.TemplateResponse(
"info.html,
context = {
"request": Request,
"meta_title": context.meta_title,
"meta_description": context.meta_description,
"categories": context.categories,
"user": context.user
}
)
And so on...
Mind you, it is possible to send a dict
object to Jinja as well, and then unpacking the dictionary key-value pairs from within the template.
However, adding the context
directly via the decorator means that you do not have a single dict
object from within Jinja, and can therefore access the objects directly by the corresponding key.
Closing Thoughts
There are potential improvements in all of this, including the aforementioned instantiation of the Jinja2Templates
object, and perhaps in how I'm accessing the route's request: Request
argument.
Also, I could probably do a little further work with my exception handling, perhaps even defining my own exceptions for specific cases.
However, I'm very satisfied with the simplified routes. And most of all, I'm extremely happy to have gone through the process of creating my first "real world" decorator.
Hopefully it's helped you think of ways you could build your own.