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's Jinja2Template
  • Declare a Request parameter in your route/view operation
  • Use the the templates object to render a TemplateResponse
  • 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:

  • 1) Initiate a template response
  • 2) Extract the Request from the arguments of wrapped function
  • 3) Extract the context, which is the return value of wrapped function
  • 4) Combine the request key-value pair with the context to create the template context

And lastly:

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