Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add FastAPI Support #2

Merged
merged 6 commits into from
Sep 18, 2022
Merged

Add FastAPI Support #2

merged 6 commits into from
Sep 18, 2022

Conversation

tataraba
Copy link
Contributor

@tataraba tataraba commented Sep 12, 2022

I added FastAPI support (under fastapi.py). I generally tried to follow the same pattern as flask/quart, but have a slightly different approach.

With FastAPI, you have to declare a Jinja2Templates object, which is a subclass of the Jinja Environment class. This is declared outside of the route/view. And then, in order to render the template, you have to use a Jinja2Templates method. This is what it looks like ordinarily:

from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates

app = FastAPI()

templates = Jinja2Templates(directory="templates")

@app.get("/items/{id}", response_class=HTMLResponse)
async def read_item(request: Request, id: str):
    return templates.TemplateResponse("item.html", {"request": request, "id": id})

I wanted to preserve this approach as much as possible, but allow the possibility to render_block--thus sending only the applicable block content.

My approach was to use a decorator to define the block_name. Everything else stays pretty much the same from the FastAPI perspective.

The end result/usage ends up looking kind of like this:

app = FastAPI()

templates = Jinja2Templates(directory="templates")

@app.get("/items/{id}", response_class=HTMLResponse)
@render_block(templates, block_name="content")
async def read_item(request: Request, id: str):
    return templates.TemplateResponse("item.html", {"request": request, "id": id})

The result is the same as render_block in the other applications (Flask/Quart).

the render_block decorator takes the Jinja2Templates object as the first (positional) parameter. This is needed in order to extract the Jinja Environment object. The template_name, as well as the necessary request k,v pair, are both extracted from the FastAPI TemplateResponse method (from within the decorator). Then, the response is rebuilt with the jinja2_fragments.render_block method.

Note: The decorator also takes additional key/value pairs and inserts those into the context as needed. Also, if the block_name is excluded from the decorator, the TemplateResponse will be rendered normally.

Last thing:
This is my first contribution to a library, so I apologize if anything is a little clunky. I've yet to add tests on my end, but I was able to confirm that it was working as expected on one of my own apps.

@tataraba
Copy link
Contributor Author

Added tests and README update. Had a tough time with the tests. Not entirely sure if it was an encoding thing with the way FastAPI uses the the Jinja Environment, or if it's an OS thing. By removing whitespace/newlines, I was able to have my assertions pass. Wrote comments to explain what was being done.

@sponsfreixes
Copy link
Owner

sponsfreixes commented Sep 16, 2022

First of all, thanks for the PR @tataraba! Having support for FlastAPI is a great contribution.

My main concern is that using a decorator instead of a function you cannot have the same route/view return two different responses. I don't know how frequently users would do that, but they would have more control if it was just a function or some callable.

If it was a simple function, the Jinja2Templates object could be passed as an argument. Following your code example, it would look like:

templates = Jinja2Templates(directory="templates")

@app.get("/items/{id}", response_class=HTMLResponse)
async def read_item(request: Request, id: str):
    return render_block(templates, "item.html", "content", {"request": request, "id": id})

This would enable the pattern:

templates = Jinja2Templates(directory="templates")

@app.get("/items/{id}", response_class=HTMLResponse)
async def read_item(request: Request, id: str):
    if request.headers.get("HX-Request"):
        return render_block(templates, "item.html", "content", {"request": request, "id": id})
    return templates.TemplateResponse("item.html", {"request": request, "id": id})

I can see how having to always pass the Jinja2Templates object might seem a bit clunky, so and alternative could be to create a class to emulate/complement/replace Jinja2Templates. The usage would be something like:

templates = Jinja2Templates(directory="templates")
blocks = Jinja2Blocks(templates)   # We need to do this only once, just after instantiating Jinja2Templates

@app.get("/items/{id}", response_class=HTMLResponse)
async def read_item(request: Request, id: str):
    return blocks.BlockResponse("item.html", "content", {"request": request, "id": id})

Or replace the usage of Jinja2Templates with:

templates = Jinja2Blocks(directory="templates")  # This might inherit and extend Jinja2Templates

@app.get("/items/{id}", response_class=HTMLResponse)
async def read_item(request: Request, id: str):
    if request.headers.get("HX-Request"):
         return templates.BlockResponse("item.html", "content", {"request": request, "id": id})
    return templates.TemplateResponse("item.html", {"request": request, "id": id})

After writing it down, this last example might be the one I like more, as it seems to follow closer the FastAPI API and users might find it more familiar.

What are your thoughts?

@sponsfreixes
Copy link
Owner

sponsfreixes commented Sep 16, 2022

I noticed that you made block_name optional, so if don't want to have separate methods, the last example could look like:

templates = Jinja2Blocks(directory="templates")  # This might inherit and extend Jinja2Templates

@app.get("/items/{id}", response_class=HTMLResponse)
async def read_item(request: Request, id: str):
    if request.headers.get("HX-Request"):
         return templates.TemplateResponse("item.html", "content", {"request": request, "id": id}, block_name="content")
    return templates.TemplateResponse("item.html", {"request": request, "id": id})

@tataraba
Copy link
Contributor Author

Ooh, I really like the idea of Jinja2Blocks! I guess initially, I didn't think that a user might want to have two separate responses in a route, but it does give the user more flexibility. I do like keeping the block_name optional, though, because it does allow us to keep the familiar TemplateResponse from FastAPI.

In that sense, I think I'll give a crack at that last example you posted! Thanks a lot for the feedback. I'll get back to the code when I have a little spare time ⌚ (hopefully soon!)

@tataraba
Copy link
Contributor Author

Ah well, decided to give it a crack before going to bed, but it seems to be working. I got the following block to work as intended, which is to say, it renders a block of Jinja called content and passes the magic_number in the context.

templates = Jinja2Blocks(directory="src/templates")


@app.get("/", response_class=HTMLResponse)
async def get_page(request: Request):
    return templates.TemplateResponse(
        "index.html",  # Name of template file
        context={"request": request, "magic_number": 42},  # The "template context"
        block_name="content"
    )

If the code is written like this instead, it sends the whole template.

templates = Jinja2Blocks(directory="src/templates")


@app.get("/", response_class=HTMLResponse)
async def get_page(request: Request):
    return templates.TemplateResponse(
        "index.html",  # Name of template file
        context={"request": request, "magic_number": 42},  # The "template context"
    )

This also allows for your last example that checks the header for an HX-Request and sends the appropriate response with the more familiar FastAPI API.

I'll add some tests tomorrow, but hopefully this is a step in the right direction! ✨

@sponsfreixes
Copy link
Owner

Awesome, let me know once it's ready for review!

@tataraba
Copy link
Contributor Author

I've been using it locally with a project and it works like a charm, at least for me! I've updated the tests and the README as well. Should be ready for you!

@sponsfreixes sponsfreixes merged commit 570e47e into sponsfreixes:main Sep 18, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants