Skip to content

Commit

Permalink
Merge pull request #19 from volfpeter/response-header-support-in-core…
Browse files Browse the repository at this point in the history
…-decorators

#18 - Response header support in core decorators
  • Loading branch information
volfpeter authored Mar 26, 2024
2 parents f5496a3 + bc5ce27 commit cd54f96
Show file tree
Hide file tree
Showing 8 changed files with 55 additions and 25 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Key features:
- Built-in **Jinja2 templating support** (even with multiple template folders).
- Gives the rendering engine **access to all dependencies** of the decorated route.
- FastAPI **routes will keep working normally by default** if they receive **non-HTMX** requests, so the same route can serve data and render HTML at the same time.
- **Response headers** you set in your routes are kept after rendering, as you would expect in FastAPI.
- **Correct typing** makes it possible to apply other (typed) decorators to your routes.
- Works with both **sync** and **async routes**.

Expand Down
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Key features:
- Built-in **Jinja2 templating support** (even with multiple template folders).
- Gives the rendering engine **access to all dependencies** of the decorated route.
- FastAPI **routes will keep working normally by default** if they receive **non-HTMX** requests, so the same route can serve data and render HTML at the same time.
- **Response headers** you set in your routes are kept after rendering, as you would expect in FastAPI.
- **Correct typing** makes it possible to apply other (typed) decorators to your routes.
- Works with both **sync** and **async routes**.

Expand Down
5 changes: 3 additions & 2 deletions examples/custom-rendering/custom_rendering_app.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Annotated, Any

from fastapi import Depends, FastAPI, Request
from fastapi import Depends, FastAPI, Request, Response

from fasthx import hx, page

Expand Down Expand Up @@ -39,7 +39,8 @@ def index() -> None:

@app.get("/htmx-or-data")
@hx(render_user_list)
def htmx_or_data(random_number: DependsRandomNumber) -> list[dict[str, str]]:
def htmx_or_data(random_number: DependsRandomNumber, response: Response) -> list[dict[str, str]]:
response.headers["my-response-header"] = "works"
return [{"name": "Joe"}]


Expand Down
5 changes: 3 additions & 2 deletions examples/jinja-rendering/jinja_app.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import os

from fastapi import FastAPI
from fastapi import FastAPI, Response
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel

Expand All @@ -27,8 +27,9 @@ class User(BaseModel):

@app.get("/user-list")
@jinja.hx("user-list.html") # Render the response with the user-list.html template.
def htmx_or_data() -> tuple[User, ...]:
def htmx_or_data(response: Response) -> tuple[User, ...]:
"""This route can serve both JSON and HTML, depending on if the request is an HTMX request or not."""
response.headers["my-response-header"] = "works"
return (
User(first_name="Peter", last_name="Volf"),
User(first_name="Hasan", last_name="Tasan"),
Expand Down
16 changes: 13 additions & 3 deletions fasthx/core_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from .dependencies import DependsHXRequest
from .typing import HTMLRenderer, MaybeAsyncFunc, P, T
from .utils import append_to_signature, execute_maybe_sync_func
from .utils import append_to_signature, execute_maybe_sync_func, get_response


def hx(
Expand Down Expand Up @@ -40,8 +40,13 @@ async def wrapper(
if __hx_request is None or isinstance(result, Response):
return result

response = get_response(kwargs)
rendered = await execute_maybe_sync_func(render, result, context=kwargs, request=__hx_request)
return HTMLResponse(rendered) if isinstance(rendered, str) else rendered
return (
HTMLResponse(rendered, headers=None if response is None else response.headers)
if isinstance(rendered, str)
else rendered
)

return append_to_signature(
wrapper, # type: ignore[arg-type]
Expand Down Expand Up @@ -72,10 +77,15 @@ async def wrapper(*args: P.args, __page_request: Request, **kwargs: P.kwargs) ->
if isinstance(result, Response):
return result

response = get_response(kwargs)
rendered: str | Response = await execute_maybe_sync_func(
render, result, context=kwargs, request=__page_request
)
return HTMLResponse(rendered) if isinstance(rendered, str) else rendered
return (
HTMLResponse(rendered, headers=None if response is None else response.headers)
if isinstance(rendered, str)
else rendered
)

return append_to_signature(
wrapper, # type: ignore[arg-type]
Expand Down
19 changes: 17 additions & 2 deletions fasthx/utils.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import inspect
from asyncio import iscoroutinefunction
from collections.abc import Callable
from typing import cast
from collections.abc import Callable, Mapping
from typing import Any, cast

from fastapi import Response
from fastapi.concurrency import run_in_threadpool

from .typing import MaybeAsyncFunc, P, T
Expand Down Expand Up @@ -45,3 +46,17 @@ async def execute_maybe_sync_func(func: MaybeAsyncFunc[P, T], *args: P.args, **k
return await func(*args, **kwargs) # type: ignore[no-any-return]

return await run_in_threadpool(cast(Callable[P, T], func), *args, **kwargs)


def get_response(kwargs: Mapping[str, Any]) -> Response | None:
"""
Returns the first `Response` instance from the values in `kwargs` (if there is one).
Arguments:
kwargs: The keyword arguments from which the `Response` should be returned.
"""
for val in kwargs.values():
if isinstance(val, Response):
return val

return None
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "fasthx"
version = "0.2402.2"
version = "0.2403.0"
description = "FastAPI data APIs with HTMX support."
authors = ["Peter Volf <[email protected]>"]
readme = "README.md"
Expand Down
31 changes: 16 additions & 15 deletions tests/test_core_decorators.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from typing import Any

import pytest
from fastapi import FastAPI, Request
from fastapi import FastAPI, Request, Response
from fastapi.testclient import TestClient

from fasthx import hx, page
Expand All @@ -10,9 +10,6 @@


def render_user_list(result: list[User], *, context: dict[str, Any], request: Request) -> str:
# Test that the hx() decorator's inserted params are not in context.
assert len(context) == 1

# Test that the value of the DependsRandomNumber dependency is in the context.
random_number = context["random_number"]
assert random_number == 4
Expand All @@ -35,7 +32,8 @@ def index(random_number: DependsRandomNumber) -> list[User]:

@app.get("/htmx-or-data")
@hx(render_user_list)
def htmx_or_data(random_number: DependsRandomNumber) -> list[User]:
def htmx_or_data(random_number: DependsRandomNumber, response: Response) -> list[User]:
response.headers["test-header"] = "exists"
return users

@app.get("/htmx-only") # type: ignore # TODO: figure out why mypy doesn't see the correct type.
Expand All @@ -52,20 +50,20 @@ def hx_client(hx_app: FastAPI) -> TestClient:


@pytest.mark.parametrize(
("route", "headers", "status", "expected"),
("route", "headers", "status", "expected", "response_headers"),
(
# page() - always renders the HTML result.
("/", {"HX-Request": "true"}, 200, user_list_html),
("/", None, 200, user_list_html),
("/", {"HX-Request": "false"}, 200, user_list_html),
("/", {"HX-Request": "true"}, 200, user_list_html, {}),
("/", None, 200, user_list_html, {}),
("/", {"HX-Request": "false"}, 200, user_list_html, {}),
# hx() - returns JSON for non-HTMX requests.
("/htmx-or-data", {"HX-Request": "true"}, 200, user_list_html),
("/htmx-or-data", None, 200, user_list_json),
("/htmx-or-data", {"HX-Request": "false"}, 200, user_list_json),
("/htmx-or-data", {"HX-Request": "true"}, 200, user_list_html, {"test-header": "exists"}),
("/htmx-or-data", None, 200, user_list_json, {"test-header": "exists"}),
("/htmx-or-data", {"HX-Request": "false"}, 200, user_list_json, {"test-header": "exists"}),
# hy(no_data=True) - raises exception for non-HTMX requests.
("/htmx-only", {"HX-Request": "true"}, 200, user_list_html),
("/htmx-only", None, 400, ""),
("/htmx-only", {"HX-Request": "false"}, 400, ""),
("/htmx-only", {"HX-Request": "true"}, 200, user_list_html, {}),
("/htmx-only", None, 400, "", {}),
("/htmx-only", {"HX-Request": "false"}, 400, "", {}),
),
)
def test_hx_and_page(
Expand All @@ -74,6 +72,7 @@ def test_hx_and_page(
headers: dict[str, str] | None,
status: int,
expected: str,
response_headers: dict[str, str],
) -> None:
response = hx_client.get(route, headers=headers)
assert response.status_code == status
Expand All @@ -82,3 +81,5 @@ def test_hx_and_page(

result = response.text
assert result == expected

assert all((response.headers.get(key) == value) for key, value in response_headers.items())

0 comments on commit cd54f96

Please sign in to comment.