Skip to content

Commit

Permalink
feat(fastapi): Add support for FastAPI
Browse files Browse the repository at this point in the history
  • Loading branch information
paveldedik committed Feb 27, 2025
1 parent 337b504 commit 7878c91
Show file tree
Hide file tree
Showing 8 changed files with 225 additions and 18 deletions.
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ repos:
- types-cachetools
- typeguard
- django
- fastapi

- repo: https://github.com/compilerla/conventional-pre-commit
rev: v3.6.0
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ Here is a table comparing Ludic to other similar tools:
| HTML rendering | Server Side | Client Side | Client Side |
| Uses a template engine | No | No | No |
| UI interactivity | [</> htmx](https://htmx.org)* | [React](https://react.dev/) | [React](https://react.dev/) |
| Backend framework | [Starlette](https://www.starlette.io), [Django](https://www.djangoproject.com/)* | [FastAPI](https://fastapi.tiangolo.com) | [FastAPI](https://fastapi.tiangolo.com) |
| Backend framework | [Starlette](https://www.starlette.io), [FastAPI](https://fastapi.tiangolo.com/), [Django](https://www.djangoproject.com/)* | [FastAPI](https://fastapi.tiangolo.com) | [FastAPI](https://fastapi.tiangolo.com) |
| Client-Server Communication | [HTML + REST](https://htmx.org/essays/how-did-rest-come-to-mean-the-opposite-of-rest/) | [JSON + REST](https://github.com/pydantic/FastUI?tab=readme-ov-file#the-principle-long-version) | [WebSockets](https://reflex.dev/blog/2024-03-21-reflex-architecture/) |

<sup>(*) HTMX as well as Starlette or Django are optional dependencies for Ludic, it does not enforce any frontend or backend frameworks. At it's core, Ludic only generates HTML and allows registering CSS.</sup>
<sup>(*) HTMX as well as Starlette, FastAPI and Django are optional dependencies for Ludic, it does not enforce any frontend or backend frameworks. At it's core, Ludic only generates HTML and allows registering CSS.</sup>

## Motivation

Expand Down Expand Up @@ -157,6 +157,7 @@ uvicorn web:app
Here is a list of integrations and a link to the guide on how to get started:

* [Starlette](https://getludic.dev/docs/web-framework)
* [FastAPI](https://getludic.dev/docs/integrations#fastapi)
* [Django](https://getludic.dev/docs/integrations#django)

### More Examples
Expand Down
5 changes: 5 additions & 0 deletions ludic/contrib/fastapi/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from ludic.web import LudicResponse

from .routing import LudicRoute

__all__ = ("LudicRoute", "LudicResponse")
96 changes: 96 additions & 0 deletions ludic/contrib/fastapi/routing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import functools
import inspect
from collections.abc import Callable
from typing import Any, ParamSpec

from fastapi._compat import lenient_issubclass
from fastapi.datastructures import Default, DefaultPlaceholder
from fastapi.dependencies.utils import get_typed_return_annotation
from fastapi.routing import APIRoute
from starlette._utils import is_async_callable
from starlette.responses import Response
from starlette.routing import get_name

from ludic.base import BaseElement
from ludic.web.responses import LudicResponse, run_in_threadpool_safe

P = ParamSpec("P")


def function_wrapper(
handler: Callable[..., Any], status_code: int = 200
) -> Callable[P, Any]:
"""Wraps endpoints to ensure responses are formatted as LudicResponse objects.
This function determines whether the handler is asynchronous or synchronous and
executes it accordingly. If the handler returns a BaseElement instance, it wraps
the response in a LudicResponse object.
Args:
handler (Callable[..., Any]): The FastAPI endpoint handler function.
status_code (int, optional): The HTTP status code for the response.
Defaults to 200.
Returns:
Callable[P, Any]: A wrapped function that ensures proper response handling.
"""

@functools.wraps(handler)
async def wrapped_endpoint(*args: P.args, **kwargs: P.kwargs) -> Any:
if is_async_callable(handler):
with BaseElement.formatter:
raw_response = await handler(*args, **kwargs)
else:
raw_response = await run_in_threadpool_safe(handler, *args, **kwargs)

if isinstance(raw_response, BaseElement):
return LudicResponse(raw_response, status_code=status_code)
return raw_response

return wrapped_endpoint


class LudicRoute(APIRoute):
"""Custom Route class for FastAPI that integrates Ludic framework response handling.
This class ensures that endpoints returning `BaseElement` instances are properly
wrapped in `LudicResponse`. If a response model is not explicitly provided, it
infers the return type annotation from the endpoint function.
Args:
path (str): The API route path.
endpoint (Callable[..., Any]): The FastAPI endpoint function.
response_model (Any, optional): The response model for OpenAPI documentation.
Defaults to None.
name (str | None, optional): The route name. Defaults to None.
status_code (int | None, optional): The HTTP status code. Defaults to None.
**kwargs (Any): Additional parameters for APIRoute.
"""

def __init__(
self,
path: str,
endpoint: Callable[..., Any],
*,
response_model: Any = Default(None), # noqa
name: str | None = None,
status_code: int | None = None,
**kwargs: Any,
) -> None:
if isinstance(response_model, DefaultPlaceholder):
return_annotation = get_typed_return_annotation(endpoint)
if lenient_issubclass(return_annotation, BaseElement) or lenient_issubclass(
return_annotation, Response
):
response_model = None
else:
response_model = return_annotation

name = get_name(endpoint) if name is None else name
wrapped_route = endpoint
if inspect.isfunction(endpoint) or inspect.ismethod(endpoint):
wrapped_route = function_wrapper(endpoint, status_code=status_code or 200)

super().__init__(
path, wrapped_route, response_model=response_model, name=name, **kwargs
)
40 changes: 25 additions & 15 deletions ludic/web/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,26 @@ def func_wrapped(*args: P.args, **kwargs: P.kwargs) -> T:
return response


def extract_response_status_headers(
raw_response: T, status_code: int | None = None, headers: Headers | None = None
) -> tuple[T, int | None, Headers | None]:
"""Extracts status code and headers from response if it is a tuple."""
if isinstance(raw_response, tuple):
if len(raw_response) == 2:
raw_response, status_or_headers = raw_response
if isinstance(status_or_headers, dict):
headers = ds.Headers(status_or_headers)
else:
status_code = status_or_headers
elif len(raw_response) == 3:
raw_response, status_code, headers = raw_response
headers = ds.Headers(headers)
else:
raise ValueError(f"Invalid response tuple: {raw_response}")

return raw_response, status_code, headers


async def prepare_response(
handler: Callable[..., Any],
request: Request,
Expand All @@ -74,21 +94,9 @@ async def prepare_response(
else:
raw_response = await run_in_threadpool_safe(handler, **handler_kw)

if isinstance(raw_response, tuple):
if len(raw_response) == 2:
raw_response, status_or_headers = raw_response
if isinstance(status_or_headers, dict):
headers = ds.Headers(status_or_headers)
else:
status_code = status_or_headers
elif len(raw_response) == 3:
raw_response, status_code, headers = raw_response
headers = ds.Headers(headers)
else:
raise ValueError(f"Invalid response tuple: {raw_response}")

if raw_response is None:
raw_response = ""
raw_response, status_code, headers = extract_response_status_headers(
raw_response, status_code, headers
)

response: Response
if isinstance(raw_response, BaseElement):
Expand All @@ -102,6 +110,8 @@ async def prepare_response(
)
elif isinstance(raw_response, Response):
response = raw_response
elif raw_response is None:
response = Response(status_code=204, headers=headers)
else:
raise ValueError(f"Invalid response type: {type(raw_response)}")

Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,18 @@ full = [
django = [
"django",
]
fastapi = [
"fastapi",
]
dev = [
"ludic[full,django,fastapi]",
"mypy",
"types-pygments",
"django-stubs",
"fastapi"
]
test = [
"ludic[dev,full,django,fastapi]",
"pytest",
"pytest-cov",
"httpx",
Expand Down
88 changes: 88 additions & 0 deletions tests/contrib/test_fastapi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
from fastapi import FastAPI, Response
from fastapi.testclient import TestClient

from ludic.base import BaseElement
from ludic.contrib.fastapi import LudicRoute
from ludic.html import div, p, span

app = FastAPI()
app.router.route_class = LudicRoute


@app.get("/component")
async def return_component() -> div:
return div(span("Hello"), p("World"))


@app.get("/dict", response_model=None)
async def return_dict() -> dict[str, str]:
return {"message": "Hello World"}


@app.get("/custom-response")
async def return_custom_response() -> Response:
return Response(content="Custom", media_type="text/plain")


@app.get("/nested")
async def nested_components() -> div:
return div(p("Level 1", span("Level 2", div("Level 3"))), id="root")


@app.get("/f-string")
async def f_string_test() -> span:
return span(f"Result: {p('This is a test')}")


@app.get("/status-code", status_code=202)
async def status_code_test() -> span:
return span("this is a test")


def test_auto_component_conversion() -> None:
client = TestClient(app)
response = client.get("/component")

assert response.status_code == 200
assert response.content == b"<div><span>Hello</span><p>World</p></div>"
assert response.headers["content-type"] == "text/html; charset=utf-8"


def test_non_component_responses() -> None:
client = TestClient(app)

response = client.get("/dict")
assert response.status_code == 200
assert response.json() == {"message": "Hello World"}

response = client.get("/custom-response")
assert response.status_code == 200
assert response.content == b"Custom"
assert response.headers["content-type"] == "text/plain; charset=utf-8"


def test_nested_components() -> None:
client = TestClient(app)
response = client.get("/nested")

assert response.status_code == 200
assert (
b'<div id="root"><p>Level 1<span>Level 2<div>Level 3</div></span></p></div>'
in response.content
)


def test_formatter_cleanup_with_f_string() -> None:
client = TestClient(app)
response = client.get("/f-string")

assert response.status_code == 200
assert b"Result: <p>This is a test</p>" in response.content
assert len(BaseElement.formatter.get()) == 0


def test_return_different_status_code() -> None:
client = TestClient(app)
response = client.get("/status-code")

assert response.status_code == 202
2 changes: 1 addition & 1 deletion tests/test_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ def test_delete_row() -> None:
with TestClient(app) as client:
assert client.get("/").status_code == 200
assert client.get("/people/").status_code == 200
assert client.delete("/people/1").status_code == 200
assert client.delete("/people/1").status_code == 204
assert client.delete("/people/123").status_code == 404
assert db.people.get("1") is None

Expand Down

0 comments on commit 7878c91

Please sign in to comment.