-
Notifications
You must be signed in to change notification settings - Fork 14
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(fastapi): Add support for FastAPI
- Loading branch information
1 parent
337b504
commit 7878c91
Showing
8 changed files
with
225 additions
and
18 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters