Skip to content

Commit

Permalink
added error rendering to core decorators + preserve response status c…
Browse files Browse the repository at this point in the history
…ode and background task, refs #26 #27 #28
  • Loading branch information
volfpeter committed Aug 27, 2024
1 parent a746309 commit 812c489
Show file tree
Hide file tree
Showing 2 changed files with 128 additions and 13 deletions.
61 changes: 51 additions & 10 deletions fasthx/core_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@


def hx(
render: HTMLRenderer[T], *, no_data: bool = False
render: HTMLRenderer[T],
*,
no_data: bool = False,
render_error: HTMLRenderer[Exception] | None = None,
) -> Callable[[MaybeAsyncFunc[P, T]], Callable[P, Coroutine[None, None, T | Response]]]:
"""
Decorator that converts a FastAPI route's return value into HTML if the request was
Expand All @@ -21,6 +24,8 @@ def hx(
Arguments:
render: The render function converting the route's return value to HTML.
no_data: If set, the route will only accept HTMX requests.
render_error: Optional render function for handling exceptions raised by the decorated route.
If not `None`, it is expected to raise an error if the exception can not be rendered.
Returns:
The rendered HTML for HTMX requests, otherwise the route's unchanged return value.
Expand All @@ -36,14 +41,33 @@ async def wrapper(
status.HTTP_400_BAD_REQUEST, "This route can only process HTMX requests."
)

result = await execute_maybe_sync_func(func, *args, **kwargs)
try:
result = await execute_maybe_sync_func(func, *args, **kwargs)
renderer = render
except Exception as e:
# Reraise if not HX request, because the checks later don't differentiate between
# error and non-error result objects.
if render_error is None or __hx_request is None:
raise e

result = e # type: ignore[assignment]
renderer = render_error # type: ignore[assignment]

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)
rendered = await execute_maybe_sync_func(renderer, result, context=kwargs, request=__hx_request)

return (
HTMLResponse(rendered, headers=None if response is None else response.headers)
HTMLResponse(
rendered,
# The default status code of the FastAPI Response dependency is None
# (not allowed by the typing but required for FastAPI).
status_code=getattr(response, "status_code", 200) or 200,
headers=getattr(response, "headers", None),
background=getattr(response, "background", None),
)
if isinstance(rendered, str)
else rendered
)
Expand All @@ -62,27 +86,44 @@ async def wrapper(

def page(
render: HTMLRenderer[T],
*,
render_error: HTMLRenderer[Exception] | None = None,
) -> Callable[[MaybeAsyncFunc[P, T]], Callable[P, Coroutine[None, None, Response]]]:
"""
Decorator that converts a FastAPI route's return value into HTML.
Arguments:
render: The render function converting the route's return value to HTML.
render_error: Optional render function for handling exceptions raised by the decorated route.
If not `None`, it is expected to raise an error if the exception can not be rendered.
"""

def decorator(func: MaybeAsyncFunc[P, T]) -> Callable[P, Coroutine[None, None, Response]]:
@wraps(func) # type: ignore[arg-type]
async def wrapper(*args: P.args, __page_request: Request, **kwargs: P.kwargs) -> T | Response:
result = await execute_maybe_sync_func(func, *args, **kwargs)
if isinstance(result, Response):
return result
try:
result = await execute_maybe_sync_func(func, *args, **kwargs)
renderer = render
except Exception as e:
if render_error is None:
raise e

result = e # type: ignore[assignment]
renderer = render_error # type: ignore[assignment]

response = get_response(kwargs)
rendered: str | Response = await execute_maybe_sync_func(
render, result, context=kwargs, request=__page_request
rendered = await execute_maybe_sync_func(
renderer, result, context=kwargs, request=__page_request
)
return (
HTMLResponse(rendered, headers=None if response is None else response.headers)
HTMLResponse(
rendered,
# The default status code of the FastAPI Response dependency is None
# (not allowed by the typing but required for FastAPI).
status_code=getattr(response, "status_code", 200) or 200,
headers=getattr(response, "headers", None),
background=getattr(response, "background", None),
)
if isinstance(rendered, str)
else rendered
)
Expand Down
80 changes: 77 additions & 3 deletions tests/test_core_decorators.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,22 @@ async def async_render_user_list(result: list[User], *, context: dict[str, Any],
return render_user_list(result, context=context, request=request)


class DataError(Exception):
def __init__(self, message: str, response: Response) -> None:
self.message = message
# Highlight how to set the response code for a route that has error rendering.
response.status_code = 499


def render_data_error(result: Exception, *, context: dict[str, Any], request: Request) -> str:
if isinstance(result, DataError):
return f'<DataError message="{result.message}" />'

raise result


@pytest.fixture
def hx_app() -> FastAPI:
def hx_app() -> FastAPI: # noqa: C901
app = FastAPI()

@app.get("/")
Expand All @@ -36,17 +50,50 @@ def htmx_or_data(random_number: DependsRandomNumber, response: Response) -> list
response.headers["test-header"] = "exists"
return users

@app.get("/htmx-only") # type: ignore # TODO: figure out why mypy doesn't see the correct type.
# There's a strange mypy issue here, it finds errors for the routes that defined later,
# regardless of the order. It seems it fails to resolve and match generic types.

@app.get("/htmx-only") # type: ignore
@hx(async_render_user_list, no_data=True)
async def htmx_only(random_number: DependsRandomNumber) -> list[User]:
return users

@app.get("/error/{kind}") # type: ignore
@hx(render_user_list, render_error=render_data_error)
def error_in_route(kind: str, response: Response) -> list[User]:
if kind == "data":
raise DataError("test-message", response)
elif kind == "value":
raise ValueError("Value error was requested.")

return users

@app.get("/error-no-data/{kind}") # type: ignore
@hx(render_user_list, render_error=render_data_error, no_data=True)
def error_in_route_no_data(kind: str, response: Response) -> list[User]:
if kind == "data":
raise DataError("test-message", response)
elif kind == "value":
raise ValueError("Value error was requested.")

return users

@app.get("/error-page/{kind}") # type: ignore
@page(render_user_list, render_error=render_data_error)
def error_in_route_page(kind: str, response: Response) -> list[User]:
if kind == "data":
raise DataError("test-message", response)
elif kind == "value":
raise ValueError("Value error was requested.")

return users

return app


@pytest.fixture
def hx_client(hx_app: FastAPI) -> TestClient:
return TestClient(hx_app)
return TestClient(hx_app, raise_server_exceptions=False)


@pytest.mark.parametrize(
Expand Down Expand Up @@ -83,3 +130,30 @@ def test_hx_and_page(
assert result == expected

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


@pytest.mark.parametrize(
("route", "headers", "status", "expected"),
(
("/error/data", {"HX-Request": "true"}, 499, '<DataError message="test-message" />'),
("/error/data", None, 500, None), # No rendering, internal server error
("/error/value", {"HX-Request": "true"}, 500, None), # No rendering for value route
("/error-no-data/data", {"HX-Request": "true"}, 499, '<DataError message="test-message" />'),
("/error-no-data/data", None, 400, None), # No data, bad request
("/error-no-data/value", {"HX-Request": "true"}, 500, None), # No rendering for value route
("/error-page/data", {"HX-Request": "true"}, 499, '<DataError message="test-message" />'),
("/error-page/data", None, 499, '<DataError message="test-message" />'), # Rendering non-HX request
("/error-page/value", {"HX-Request": "true"}, 500, None), # No rendering for value route
),
)
def test_hx_and_page_error_rendering(
hx_client: TestClient,
route: str,
headers: dict[str, str] | None,
status: int,
expected: str | None,
) -> None:
response = hx_client.get(route, headers=headers)
assert response.status_code == status
if expected is not None:
assert response.text == expected

0 comments on commit 812c489

Please sign in to comment.