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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ base = Html(
),
)

menu = Ul(Li(text="main"))
content = Div(text="Some content")
menu = Ul(Li("home"))
content = Div("Some content")

base.extend("menu", menu)
base.extend("content", content)
Expand All @@ -88,7 +88,7 @@ output
<html>
<body>
<menu>
<ul><li>main</li></ul>
<ul><li>home</li></ul>
</menu>
<div>
<div>Some content</div>
Expand Down
18 changes: 4 additions & 14 deletions docs/htmx.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,22 +141,12 @@ Meta(name="htmx-config", content=json.dumps(htmx_config)),

### Vary header

One unfortunate thing is how back navigation is handled, if the browser issues the back navigation, it believes the last request is what should be returned and that is often a htmx partial. To prevent this we can set up a middleware that assigns a `Vary` header. This tells the browser to treat the history differently based on what did the requests.
One unfortunate thing is how back navigation is handled. If the browser issues the back navigation, it believes the last request is what should be returned and that is often a htmx partial. To prevent this we can set up a middleware that assigns a `Vary` header. This tells the browser to treat the history differently based on what did the requests.

```python
@app.middleware("http")
async def add_vary_accept_header( # type: ignore
request: Request,
call_next,
) -> Response:
"""Add 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
"""
response: Response = await call_next(request)
response.headers["Vary"] = "Accept"
return response
from hypermedia.fastapi import add_htmx_middleware

add_htmx_middleware(app)
```


Expand Down
6 changes: 3 additions & 3 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ base = Html(
),
)

menu = Ul(Li(text="main"))
content = Div(text="Some content")
menu = Ul(Li("Home"))
content = Div("Some content")

base.extend("menu", menu)
base.extend("content", content)
Expand All @@ -82,7 +82,7 @@ output
<html>
<body>
<menu>
<ul><li>main</li></ul>
<ul><li>Home</li></ul>
</menu>
<header>my header</header>
<div>
Expand Down
35 changes: 33 additions & 2 deletions hypermedia/fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@

from hypermedia.models import Element

try:
from fastapi import FastAPI, Request, Response
except ImportError as ie:
raise ImportError(
"The 'fastapi' helpers function requires fastapi. "
"Install it with: `pip install 'hypermedia[fastapi]'`. "
"Or `uv add hypermedia --extras fastapi`"
) from ie

Param = ParamSpec("Param")
ReturnType = TypeVar("ReturnType")

Expand All @@ -18,15 +27,15 @@ class RequestPartialAndFull(Protocol):
"""Requires, `request`, `partial` and `full` args on decorated function."""

def __call__( # noqa: D102
self, request: Any, partial: Element, full: Element
self, request: Request, partial: Element, full: Element
) -> Coroutine[Any, Any, None]: ...


class RequestAndPartial(Protocol):
"""Requires, `request` and `partial` args on decorated function."""

def __call__( # noqa: D102
self, request: Any, partial: Element
self, request: Request, partial: Element
) -> Coroutine[Any, Any, None]: ...


Expand Down Expand Up @@ -82,3 +91,25 @@ async def wrapper(
return lambda: func(*args, **kwargs)

return wrapper


def add_htmx_middleware(app: FastAPI) -> None:
"""Instrument the app with middleware to add 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_instrumented", False):
return

@app.middleware("http")
async def add_vary_accept_header( # type: ignore
request: Request,
call_next,
) -> Response:
response: Response = await call_next(request)
response.headers["Vary"] = "Accept"
return response

app.state.hypermedia_htmx_instrumented = True
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ dependencies = [

[dependency-groups]
dev = [
"httpx>=0.28.1",
"mkdocs-material>=9.6.14",
"mypy>=1.14.1",
"pytest-cov>=6.0.0",
Expand All @@ -34,6 +35,11 @@ Documentation = "https://github.com/thomasborgen/hypermedia"
Repository = "https://github.com/thomasborgen/hypermedia"
Changelog = "https://github.com/thomasborgen/hypermedia/blob/main/CHANGELOG.md"

[project.optional-dependencies]
fastapi = [
"fastapi>=0.128.5",
]


[tool.ruff]
line-length = 79
Expand Down
36 changes: 36 additions & 0 deletions tests/fastapi/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import pytest
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from fastapi.testclient import TestClient

from hypermedia.fastapi import add_htmx_middleware


@pytest.fixture
def app() -> FastAPI:
_app = FastAPI(
title="moveit.",
description="moveit. warehouse management system",
)

@_app.get("/", response_class=HTMLResponse)
async def root() -> str:
"""Root."""
return "root"

return _app


@pytest.fixture
def client(app: FastAPI) -> TestClient:
"""Test client."""
return TestClient(app)


@pytest.fixture
def instrumented_client(
app: FastAPI,
) -> TestClient:
"""Instrumented Test client."""
add_htmx_middleware(app)
return TestClient(app)
18 changes: 18 additions & 0 deletions tests/fastapi/test_instrument_htmx.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from fastapi import status
from fastapi.testclient import TestClient


def test_get_non_instrumented(
client: TestClient,
) -> None:
response = client.get("/")
assert response.status_code == status.HTTP_200_OK
assert "Vary" not in response.headers


def test_vary_header_added_when_instrumented(
instrumented_client: TestClient,
) -> None:
response = instrumented_client.get("/")
assert response.status_code == status.HTTP_200_OK
assert "Vary" in response.headers
Loading
Loading