From ce6372e6ad310979bc703eff56eda3097bb0aca0 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 20 Jun 2026 01:34:51 -0700 Subject: [PATCH] feat(vite): auto-load templates with template helper and config flag ViteProvider now binds a Jinja2Templates engine out of the box so fresh apps get a working template engine and the vite() globals are injected during boot. An existing 'templates' binding always wins, keeping the change backward-compatible. - Add ViteConfig.template and ViteConfig.templates_directory flags - Auto-bind templates in ViteProvider.register() with a guarded jinja2 import that points users to the [vite] extra - Add Template class and template() helper (Laravel view()-style) that resolve the request from context or a per-request ContextVar - Add RequestContextMiddleware to expose the active request - Export Template and template from fastapi_startkit.vite - Cover the new behaviour with tests - Simplify example/vite-app to rely on the auto-bound engine --- example/vite-app/bootstrap/application.py | 5 +- .../vite-app/providers/fastapi_provider.py | 8 -- example/vite-app/routes/web.py | 11 ++- .../src/fastapi_startkit/fastapi/context.py | 9 ++ .../fastapi_startkit/fastapi/middleware.py | 20 +++++ .../fastapi/providers/fastapi_provider.py | 3 + .../src/fastapi_startkit/vite/__init__.py | 3 + .../src/fastapi_startkit/vite/config/vite.py | 2 + .../vite/providers/provider.py | 19 +++++ .../src/fastapi_startkit/vite/template.py | 52 ++++++++++++ fastapi_startkit/tests/vite/test_template.py | 85 +++++++++++++++++++ 11 files changed, 201 insertions(+), 16 deletions(-) create mode 100644 fastapi_startkit/src/fastapi_startkit/fastapi/context.py create mode 100644 fastapi_startkit/src/fastapi_startkit/fastapi/middleware.py create mode 100644 fastapi_startkit/src/fastapi_startkit/vite/template.py create mode 100644 fastapi_startkit/tests/vite/test_template.py diff --git a/example/vite-app/bootstrap/application.py b/example/vite-app/bootstrap/application.py index 9dc64939..0fbc882a 100644 --- a/example/vite-app/bootstrap/application.py +++ b/example/vite-app/bootstrap/application.py @@ -4,7 +4,6 @@ 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( @@ -12,6 +11,8 @@ providers=[ LogProvider, FastAPIProvider, - ViteProvider, + # ViteProvider auto-binds a Jinja2Templates engine (with the vite() + # globals injected) at the configured templates directory. + (ViteProvider, {"templates_directory": "templates"}), ], ) diff --git a/example/vite-app/providers/fastapi_provider.py b/example/vite-app/providers/fastapi_provider.py index 2ae5d739..e43b4331 100644 --- a/example/vite-app/providers/fastapi_provider.py +++ b/example/vite-app/providers/fastapi_provider.py @@ -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 @@ -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() diff --git a/example/vite-app/routes/web.py b/example/vite-app/routes/web.py index 78a3f090..73b952b1 100644 --- a/example/vite-app/routes/web.py +++ b/example/vite-app/routes/web.py @@ -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") diff --git a/fastapi_startkit/src/fastapi_startkit/fastapi/context.py b/fastapi_startkit/src/fastapi_startkit/fastapi/context.py new file mode 100644 index 00000000..625224f6 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/fastapi/context.py @@ -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) diff --git a/fastapi_startkit/src/fastapi_startkit/fastapi/middleware.py b/fastapi_startkit/src/fastapi_startkit/fastapi/middleware.py new file mode 100644 index 00000000..c855ea64 --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/fastapi/middleware.py @@ -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) diff --git a/fastapi_startkit/src/fastapi_startkit/fastapi/providers/fastapi_provider.py b/fastapi_startkit/src/fastapi_startkit/fastapi/providers/fastapi_provider.py index 8639e782..627ec535 100644 --- a/fastapi_startkit/src/fastapi_startkit/fastapi/providers/fastapi_provider.py +++ b/fastapi_startkit/src/fastapi_startkit/fastapi/providers/fastapi_provider.py @@ -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"}) diff --git a/fastapi_startkit/src/fastapi_startkit/vite/__init__.py b/fastapi_startkit/src/fastapi_startkit/vite/__init__.py index c6c8cf31..4447516b 100644 --- a/fastapi_startkit/src/fastapi_startkit/vite/__init__.py +++ b/fastapi_startkit/src/fastapi_startkit/vite/__init__.py @@ -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", ] diff --git a/fastapi_startkit/src/fastapi_startkit/vite/config/vite.py b/fastapi_startkit/src/fastapi_startkit/vite/config/vite.py index 71431700..80102997 100644 --- a/fastapi_startkit/src/fastapi_startkit/vite/config/vite.py +++ b/fastapi_startkit/src/fastapi_startkit/vite/config/vite.py @@ -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" diff --git a/fastapi_startkit/src/fastapi_startkit/vite/providers/provider.py b/fastapi_startkit/src/fastapi_startkit/vite/providers/provider.py index 4e0e7c33..22269276 100644 --- a/fastapi_startkit/src/fastapi_startkit/vite/providers/provider.py +++ b/fastapi_startkit/src/fastapi_startkit/vite/providers/provider.py @@ -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) diff --git a/fastapi_startkit/src/fastapi_startkit/vite/template.py b/fastapi_startkit/src/fastapi_startkit/vite/template.py new file mode 100644 index 00000000..8735c5ae --- /dev/null +++ b/fastapi_startkit/src/fastapi_startkit/vite/template.py @@ -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) diff --git a/fastapi_startkit/tests/vite/test_template.py b/fastapi_startkit/tests/vite/test_template.py new file mode 100644 index 00000000..d4ff6223 --- /dev/null +++ b/fastapi_startkit/tests/vite/test_template.py @@ -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("

{{ title }}

") + + 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()})