Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions example/vite-app/bootstrap/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@
from fastapi_startkit.logging import LogProvider
from fastapi_startkit.vite import ViteProvider

# from config.vite import ViteConfig
from providers.fastapi_provider import FastAPIProvider

app: Application = Application(
base_path=Path(__file__).resolve().parent.parent,
providers=[
LogProvider,
FastAPIProvider,
ViteProvider,
# ViteProvider auto-binds a Jinja2Templates engine (with the vite()
# globals injected) at the configured templates directory.
(ViteProvider, {"templates_directory": "templates"}),
],
)
8 changes: 0 additions & 8 deletions example/vite-app/providers/fastapi_provider.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
from pathlib import Path

from fastapi import FastAPI
from starlette.templating import Jinja2Templates

from fastapi_startkit.fastapi import FastAPIProvider as BaseFastAPIProvider

Expand All @@ -14,11 +11,6 @@ def register(self) -> None:
)
self.app.use_fastapi(fastapi)

# Bind Jinja2Templates so ViteProvider can inject vite() globals into it.
templates_dir = Path(self.app.base_path) / "templates"
templates = Jinja2Templates(directory=str(templates_dir))
self.app.bind("templates", templates)

def boot(self) -> None:
super().boot()

Expand Down
11 changes: 5 additions & 6 deletions example/vite-app/routes/web.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
from fastapi import APIRouter, Request
from fastapi import APIRouter
from fastapi.responses import HTMLResponse

from fastapi_startkit.vite import template

web = APIRouter()


@web.get("/", response_class=HTMLResponse)
async def index(request: Request):
from bootstrap.application import app

templates = app.make("templates")
return templates.TemplateResponse(request, "index.html")
async def index():
return template("index.html")


@web.get("/api/health")
Expand Down
9 changes: 9 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/fastapi/context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from contextvars import ContextVar
from typing import Optional

from starlette.requests import Request

# Holds the request currently being handled so helpers such as the Vite
# `template()` view renderer can resolve it without it being passed explicitly.
# Set by RequestContextMiddleware for the duration of each request.
current_request: ContextVar[Optional[Request]] = ContextVar("current_request", default=None)
20 changes: 20 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/fastapi/middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from starlette.responses import Response

from fastapi_startkit.fastapi.context import current_request


class RequestContextMiddleware(BaseHTTPMiddleware):
"""Expose the active request through a ContextVar for the request lifetime.

Lets helpers that have no access to the handler signature (e.g. the Vite
``template()`` view renderer) resolve the current request implicitly.
"""

async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
token = current_request.set(request)
try:
return await call_next(request)
finally:
current_request.reset(token)
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,11 @@ def register(self) -> None:
def boot(self):
import os

from fastapi_startkit.fastapi.middleware import RequestContextMiddleware

self.commands([ServeCommand])
self._register_exception_handlers()
self.app.add_middleware(RequestContextMiddleware)

source = os.path.abspath(os.path.join(os.path.dirname(__file__), "../config/fastapi.py"))
self.publishes({source: "config/fastapi.py"})
Expand Down
3 changes: 3 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/vite/__init__.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
from .vite import Vite
from .providers.provider import ViteProvider
from .template import Template, template
from .exceptions import ViteException, ViteManifestNotFoundException

__all__ = [
"Vite",
"ViteProvider",
"Template",
"template",
"ViteException",
"ViteManifestNotFoundException",
]
2 changes: 2 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/vite/config/vite.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@ class ViteConfig:
asset_url: str = ""
static_url: str = "/build"
mount_static: bool = True
template: bool = True
templates_directory: str = "resources/templates"
19 changes: 19 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/vite/providers/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,25 @@ def register(self) -> None:

self.app.bind("vite", vite)

self.register_templates(config)

def register_templates(self, config: ViteConfig) -> None:
# Provide a template engine out of the box so fresh apps can render
# views and have the vite() globals injected during boot. An existing
# binding always wins, keeping this backward-compatible.
if not config.template or self.app.has("templates"):
return

try:
from starlette.templating import Jinja2Templates
except ImportError as exc:
raise ImportError(
"Rendering templates requires Jinja2. Install it with: pip install fastapi-startkit[vite]"
) from exc

templates_dir = self.app.base_path / config.templates_directory
self.app.bind("templates", Jinja2Templates(directory=str(templates_dir)))

def boot(self) -> None:
vite: Vite = self.app.make("vite")
config = self.app.make("config").get(self.provider_key)
Expand Down
52 changes: 52 additions & 0 deletions fastapi_startkit/src/fastapi_startkit/vite/template.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from typing import Optional

from .exceptions import ViteException


def _resolve_request(context: dict):
"""Return the request from the context, falling back to the request ContextVar."""
request = context.pop("request", None)
if request is not None:
return request

try:
from fastapi_startkit.fastapi.context import current_request
except ImportError:
return None

return current_request.get()


def template(name: str, context: Optional[dict] = None):
"""Render a Jinja2 template by name, Laravel ``view()`` style.

The current request does not need to be passed explicitly: it is taken from
``context['request']`` when given, otherwise from the per-request ContextVar
set by ``RequestContextMiddleware``.
"""
from fastapi_startkit.application import app as container

if not container().has("templates"):
raise ViteException(
"No 'templates' binding found. Register the ViteProvider (with "
"`template` enabled) or bind a Jinja2Templates instance as 'templates'."
)

templates = container().make("templates")
context = dict(context or {})
request = _resolve_request(context)

try:
return templates.TemplateResponse(request, name, context)
except TypeError:
# Starlette < 0.29 only supports the legacy signature where the request
# is supplied inside the context dict.
return templates.TemplateResponse(name, {"request": request, **context})


class Template:
"""Static-style accessor for rendering templates."""

@staticmethod
def render(name: str, context: Optional[dict] = None):
return template(name, context)
85 changes: 85 additions & 0 deletions fastapi_startkit/tests/vite/test_template.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import pytest
from starlette.requests import Request
from starlette.responses import Response

from fastapi_startkit.application import Application
from fastapi_startkit.providers import Provider
from fastapi_startkit.vite import Template, ViteProvider, template
from fastapi_startkit.vite.exceptions import ViteException


def make_request() -> Request:
return Request(
{
"type": "http",
"method": "GET",
"path": "/",
"headers": [],
"query_string": b"",
}
)


def make_app(tmp_path, providers=None, write_template=False) -> Application:
if write_template:
templates_dir = tmp_path / "resources" / "templates"
templates_dir.mkdir(parents=True)
(templates_dir / "index.html").write_text("<h1>{{ title }}</h1>")

return Application(
base_path=tmp_path,
env="testing",
providers=providers or [ViteProvider],
)


class _SentinelTemplatesProvider(Provider):
provider_key = "sentinel_templates"

def register(self) -> None:
self.app.bind("templates", "SENTINEL")


class TestTemplateBinding:
def test_binds_templates_when_enabled_and_none_prebound(self, tmp_path):
app = make_app(tmp_path)
assert app.has("templates")

def test_respects_existing_templates_binding(self, tmp_path):
app = make_app(tmp_path, providers=[_SentinelTemplatesProvider, ViteProvider])
assert app.make("templates") == "SENTINEL"

def test_skips_binding_when_template_disabled(self, tmp_path):
app = make_app(tmp_path, providers=[(ViteProvider, {"template": False})])
assert not app.has("templates")

def test_vite_globals_injected_after_boot(self, tmp_path):
app = make_app(tmp_path)
env_globals = app.make("templates").env.globals
assert "vite" in env_globals
assert "vite_asset" in env_globals
assert "vite_react_refresh" in env_globals


class TestTemplateRendering:
def test_template_helper_returns_template_response(self, tmp_path):
make_app(tmp_path, write_template=True)
response = template("index.html", {"request": make_request(), "title": "Hi"})
assert isinstance(response, Response)

def test_template_class_render_uses_request_contextvar(self, tmp_path):
make_app(tmp_path, write_template=True)
from fastapi_startkit.fastapi.context import current_request

token = current_request.set(make_request())
try:
response = Template.render("index.html", {"title": "Hi"})
finally:
current_request.reset(token)

assert isinstance(response, Response)

def test_template_raises_when_no_binding(self, tmp_path):
make_app(tmp_path, providers=[(ViteProvider, {"template": False})])
with pytest.raises(ViteException):
template("index.html", {"request": make_request()})
Loading