import fastapi
from fastapi.testclient import TestClient
import chameleon_rs.fastapi as fastapi_chameleon
app = fastapi.FastAPI()
fastapi_chameleon.configure(template_folder=template_folder, auto_reload=dev_mode, app=app)
@app.get('/')
@fastapi_chameleon.template('home/index.pt')
async def index(limit: int = 2):
videos = ['intro', 'partials', 'async']
return {'message': 'Hello', 'videos': videos[:limit]}FastAPI
chameleon_rs.fastapi renders templates from FastAPI views. It has fastapi-chameleon’s API, so moving an app over is an import change, or no change at all with chameleon_rs.compat.install() (which makes import fastapi_chameleon give this module).
import chameleon_rs doesn’t import FastAPI. Only import chameleon_rs.fastapi does. There is nothing extra to install: it uses the FastAPI your app already has.
A first view
The view returns the template’s model, and @template renders it into a fastapi.Response (text/html):
with TestClient(app) as client:
print(client.get('/?limit=1').text)
print(client.get('/?limit=lots').status_code) # FastAPI's own validation<h1>Hello</h1>
<ul><li><a href="/videos/intro">intro</a></li></ul>
422
The decorated view keeps its signature, so path and query parameters, Depends, validation errors and the OpenAPI docs are exactly what they would be without @template.
configure() here is the same call as chameleon_rs.configure(), so it also tells render_partial where templates live. app= turns on the decorator-order check below.
Decorator order
@template goes below the route decorator. Written above it, FastAPI registers the bare view, the template never renders and the route quietly returns the view’s dict as JSON. An app given to configure(..., app=app) checks this when it starts:
bad = fastapi.FastAPI()
fastapi_chameleon.configure(app=bad)
router = fastapi.APIRouter()
@fastapi_chameleon.template('home/index.pt') # wrong: above the route
@router.get('/about')
def about():
return {'message': 'About', 'videos': []}
bad.include_router(router)
try:
with TestClient(bad):
pass
except Exception as error:
print(type(error).__name__)
print(str(error).split(' at ')[0])DecoratorOrderError
@template must be placed below the route decorator (@app.get, @router.get, @app.api_route, ...) on about()
The check runs inside the app’s lifespan startup, so Uvicorn and Granian report a failed startup and exit. When no lifespan runs (a server with lifespan events off, a test client used without with, a mounted sub-application) it runs before the first request instead. Routers included with include_router and mounted apps are checked too. A function registered raw on purpose (say, JSON on one route) and also decorated on another route is not flagged. fastapi_chameleon.check_views(app) runs the same check on demand.
One pattern can’t be told from the mistake: a function routed raw on this app whose @template wrapper is used somewhere else (called from another view, or routed on another app). For such an app, pass check_order=False to configure() and keep the check in a test with fastapi_chameleon.check_views(app, ignore=[that_function]).
Sync and async views
- An
async defview renders its page with render_async on the event loop. A model value that is awaitable (a coroutine, task or future) is awaited where the template uses it, so a slow query can be handed to the template unawaited. - A plain
defview renders with render(). FastAPI runs the view, render included, in its thread pool, as with fastapi-chameleon.
@template(..., concurrent=True) renders render_partial(...) calls with awaitable arguments concurrently; @template(..., offload=True) (experimental) renders in a worker thread.
The template gets what the view returns, and nothing else: unlike chameleon_rs.starlette’s endpoints, a FastAPI view’s template does not get request or url_for added, as with fastapi-chameleon (a FastAPI view has no request unless it declares one). Return them in the model if the template needs them.
What a view can return
- A model: a
dict, any other mapping, a dataclass instance (its fields, shallowly) or a Pydantic model (anything with a callablemodel_dump()). - A response: any Starlette response (
fastapi.Response,RedirectResponse,JSONResponse, …) passes through unchanged.
Anything else raises chameleon_rs.fastapi.FastAPIChameleonException.
The @template decorator
@fastapi_chameleon.template('home/index.pt') # a template path
@fastapi_chameleon.template('feed.pt', 'application/xml') # mimetype=, or content_type=
@fastapi_chameleon.template('home/created.pt', status_code=201)
@fastapi_chameleon.template # no argumentsWith no path, it renders {module}/{function}.html, where {module} is the last part of the view’s module name. If there’s no .html file in the template folder, it uses {module}/{function}.pt. It checks once, when the view is decorated.
Error pages
Inside a decorated view, not_found() and error() stop the view and render an error page:
app = fastapi.FastAPI()
@app.get('/videos/{name}')
@fastapi_chameleon.template('home/index.pt')
async def video(name: str, request: fastapi.Request):
if name != 'intro':
fastapi_chameleon.not_found(path=request.url.path) # renders errors/404.pt
return {'message': name, 'videos': [name]}
response = TestClient(app).get('/videos/missing')
print(response.status_code, response.text)404 <h1>Not found: /videos/missing</h1>
not_found(template_file='errors/404.pt', **data)renders the page with status 404.error(template_file, status_code=500, **data)renders any error page.generic_error(template_file, status_code, template_data)is fastapi-chameleon’s name and signature for error().
The names given to @template, render(), response(), not_found() / error() and render_partial() are looked up as the original libraries look them up, which allows .. and absolute paths. Write them in your code; never build one from a request (a path or query parameter, a header). chameleon_rs.starlette.Templates, the Jinja drop-in, refuses names that reach outside its folder, as Jinja does.
Rendering without the decorator
html = fastapi_chameleon.render('emails/welcome.pt', user=user) # a str
html = await fastapi_chameleon.render_async('emails/welcome.pt', user=user)
resp = fastapi_chameleon.response('feed.pt', 'application/xml', items=items)
resp = await fastapi_chameleon.response_async('feed.pt', status_code=201, items=items)Both raise FastAPIChameleonException if nothing has been configured.
Coming from fastapi-chameleon
| fastapi-chameleon | chameleon-rs |
|---|---|
global_init(folder, auto_reload=False, cache_init=True) |
Still works, and takes app=. It configures the views only, as before, and ignores a second call. configure() sets views and partials together, and every call applies. |
@template(file, mimetype), render, response, not_found, generic_error |
Same. @template also takes status_code= and content_type=; not_found also takes template_file= and template data. |
| — | error(), render_async(), response_async(), check_views(). |
FastAPIChameleonException and the not-found / generic exceptions |
Same names, now subclasses of chameleon_rs.web.TemplateError, NotFound and ErrorPage. |
Views return a dict |
Also mappings, dataclasses and Pydantic models. |
| Async views render synchronously | They render with render_async; an awaitable model value is awaited where the template uses it. |
Settings are process-wide. Unlike Flask, a FastAPI app can’t have a template folder of its own yet: the decorator has no request to find the app from.