diff --git a/hypermedia/fastapi.py b/hypermedia/fastapi.py index 00afda3..2988bbe 100644 --- a/hypermedia/fastapi.py +++ b/hypermedia/fastapi.py @@ -3,8 +3,8 @@ Any, Callable, Coroutine, - ParamSpec, Protocol, + TypeAlias, TypeVar, ) @@ -19,29 +19,35 @@ "Or `uv add hypermedia --extras fastapi`" ) from ie -Param = ParamSpec("Param") -ReturnType = TypeVar("ReturnType") +T = TypeVar("T", bound="Element") +LazyElement: TypeAlias = Callable[..., Element] -class RequestPartialAndFull(Protocol): - """Requires, `request`, `partial` and `full` args on decorated function.""" + +class PartialHTMXRequest(Protocol): + """Requires, `request`, `partial` args on decorated function.""" def __call__( # noqa: D102 - self, request: Request, partial: Element, full: Element + self, + request: Request, + partial: Element, ) -> Coroutine[Any, Any, None]: ... -class RequestAndPartial(Protocol): - """Requires, `request` and `partial` args on decorated function.""" +class FullHTMXRequest(Protocol): + """Requires, `request`, `partial` and `full` args on decorated function.""" def __call__( # noqa: D102 - self, request: Request, partial: Element + self, + request: Request, + partial: Element, + full: LazyElement, ) -> Coroutine[Any, Any, None]: ... def htmx( - func: RequestPartialAndFull | RequestAndPartial, -) -> Callable[..., str]: + func: PartialHTMXRequest | FullHTMXRequest, +) -> PartialHTMXRequest | FullHTMXRequest: """Wrap a FastAPI endpoint, to enable partial and full rendering. The endpoint function _must_ have a partial render dependency, and @@ -59,7 +65,7 @@ def htmx( @wraps(func) async def wrapper( *, - request: Any, + request: Request, partial: Element, full: None | Callable[..., Element] = None, ) -> str: @@ -77,29 +83,25 @@ async def wrapper( return wrapper # type: ignore -def full( - func: Callable[Param, ReturnType], -) -> Callable[Param, Coroutine[Any, Any, Callable[[], ReturnType]]]: - """Wrap the full page render dependency and makes it lazy.""" +def full(func: Callable[..., T]) -> Callable[..., Callable[..., T]]: + """Mark a function as a full renderer. + + This will prevent the function from being evaluated before it is needed + """ @wraps(func) - async def wrapper( - *args: Param.args, - **kwargs: Param.kwargs, - ) -> Callable[[], ReturnType]: - """Wrap function.""" + def deferred_renderer(*args: Any, **kwargs: Any) -> Callable[..., T]: return lambda: func(*args, **kwargs) - return wrapper + return deferred_renderer def add_htmx_middleware(app: FastAPI) -> None: - """Instrument the app with middleware to add Vary: Accept header. + """Add middleware to the app that adds the Vary: Accept header. This allows the browser to cache the responses based on caller, which should prevent the browser from caching htmx responses as a full page """ - # Check if we've already instrumented if getattr(app.state, "hypermedia_htmx_middleware", False): return diff --git a/tests/fastapi/__init__.py b/tests/fastapi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fastapi/conftest.py b/tests/fastapi/conftest.py index 6de3cc8..ed23bc3 100644 --- a/tests/fastapi/conftest.py +++ b/tests/fastapi/conftest.py @@ -1,9 +1,13 @@ +from typing import Annotated + import pytest -from fastapi import FastAPI +from fastapi import Depends, FastAPI, Request from fastapi.responses import HTMLResponse from fastapi.testclient import TestClient -from hypermedia.fastapi import add_htmx_middleware +from hypermedia.fastapi import LazyElement, add_htmx_middleware, full, htmx +from hypermedia.models import Element +from tests.fastapi.index import render_index, render_index_partial @pytest.fixture @@ -14,15 +18,31 @@ def app() -> FastAPI: ) @_app.get("/", response_class=HTMLResponse) - async def root() -> str: - """Root.""" - return "root" + @htmx + async def index( + request: Request, + partial: Annotated[Element, Depends(render_index_partial)], + full: Annotated[LazyElement, Depends(full(render_index))], + ) -> None: + """Return the index page.""" + pass + + @_app.get("/partial", response_class=HTMLResponse) + @htmx + async def index_partial( + request: Request, + partial: Annotated[Element, Depends(render_index_partial)], + ) -> None: + """Return the index page.""" + pass return _app @pytest.fixture -def client(app: FastAPI) -> TestClient: +def client( + app: FastAPI, +) -> TestClient: """Test client.""" return TestClient(app) diff --git a/tests/fastapi/index.py b/tests/fastapi/index.py new file mode 100644 index 0000000..7ada2f7 --- /dev/null +++ b/tests/fastapi/index.py @@ -0,0 +1,17 @@ +from typing import Annotated + +from fastapi import Depends + +from hypermedia import Div, Element + + +def render_index_partial() -> Element: + """Return partial HTML.""" + return Div("partial") + + +def render_index( + partial: Annotated[Element, Depends(render_index_partial)], +) -> Element: + """Return full HTML.""" + return Div("full", partial) diff --git a/tests/fastapi/test_htmx_decorator.py b/tests/fastapi/test_htmx_decorator.py new file mode 100644 index 0000000..d671f92 --- /dev/null +++ b/tests/fastapi/test_htmx_decorator.py @@ -0,0 +1,80 @@ +from unittest.mock import Mock + +from fastapi import FastAPI, status +from fastapi.testclient import TestClient + +from hypermedia import Div +from tests.fastapi.index import render_index, render_index_partial + + +def test_full_html_returned( + client: TestClient, +) -> None: + response = client.get("/") + assert response.status_code == status.HTTP_200_OK + assert "full" in response.text + assert "partial" in response.text + + +def test_only_partial_html_returned( + client: TestClient, +) -> None: + response = client.get("/", headers={"HX-Request": "true"}) + print(response.text) + assert response.status_code == status.HTTP_200_OK + assert "full" not in response.text + assert "partial" in response.text + + +def test_render_index_partial_called( + app: FastAPI, + client: TestClient, +) -> None: + mock_partial = Mock(return_value=Div("mocked")) + app.dependency_overrides[render_index_partial] = lambda: mock_partial() + + client.get("/", headers={"HX-Request": "true"}) + + mock_partial.assert_called_once() + + +def test_render_index_not_called( + app: FastAPI, + client: TestClient, +) -> None: + mock_full = Mock(return_value=Div("mocked")) + app.dependency_overrides[render_index] = lambda: mock_full() + + client.get("/", headers={"HX-Request": "true"}) + + mock_full.assert_not_called() + + +def test_render_index_partial_called_only_once_on_full( + app: FastAPI, + client: TestClient, +) -> None: + mock_partial = Mock(return_value=Div("mocked")) + app.dependency_overrides[render_index_partial] = lambda: mock_partial() + + client.get("/") + + mock_partial.assert_called_once() + + +def test_render_partial_data_only_when_no_full_available( + client: TestClient, +) -> None: + response = client.get("/partial") + + assert "full" not in response.text + assert "partial" in response.text + + +def test_render_partial_data_only_when_no_full_available_htmx( + client: TestClient, +) -> None: + response = client.get("/partial", headers={"HX-Request": "true"}) + + assert "full" not in response.text + assert "partial" in response.text