Skip to content
Open
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
50 changes: 26 additions & 24 deletions hypermedia/fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
Any,
Callable,
Coroutine,
ParamSpec,
Protocol,
TypeAlias,
TypeVar,
)

Expand All @@ -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
Expand All @@ -59,7 +65,7 @@ def htmx(
@wraps(func)
async def wrapper(
*,
request: Any,
request: Request,
partial: Element,
full: None | Callable[..., Element] = None,
) -> str:
Expand All @@ -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

Expand Down
Empty file added tests/fastapi/__init__.py
Empty file.
32 changes: 26 additions & 6 deletions tests/fastapi/conftest.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)

Expand Down
17 changes: 17 additions & 0 deletions tests/fastapi/index.py
Original file line number Diff line number Diff line change
@@ -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)
80 changes: 80 additions & 0 deletions tests/fastapi/test_htmx_decorator.py
Original file line number Diff line number Diff line change
@@ -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
Loading