Skip to content

Web Framework

The Ludic library provides wrappers around Starlette framework to make it easy to write asynchronous web applications based on HTMX and Ludic Components.

Ludic includes an application class LudicApp that tight together all other functionality. Here is how you can create an instance of the class:

from ludic.web import LudicApp

app = LudicApp()

The LudicApp class supports the same parameters as the Starlette class from the Starlette framework.

Routing

To register handlers in your app, you can use the routes arguments of the LudicApp class like this:

from ludic.web import LudicApp, Request
from ludic.web.routing import Route


def homepage(request: Request) -> p:
    return p("Hello, world!")


def startup():
    print('Ready to go')


routes = [
    Route("/", homepage),
]

app = LudicApp(debug=True, routes=routes, on_startup=[startup])

Instantiating The Application

class ludic.app.LudicApp(debug=False, routes=None, middleware=None, exception_handlers=None, on_startup=None, on_shutdown=None, lifespan=None)

Creates an application instance.

Parameters:

The list of parameters can be found in the Starlette documentation.

Methods:

  • app.register_route(path: str, method: str = "GET") - decorator to register function based endpoint handler
  • app.get(path: str, kwargs: Any) - decorator to register function endpoint handling GET HTTP method
  • app.post(path: str, kwargs: Any) - decorator to register function endpoint handling POST HTTP method
  • app.put(path: str, kwargs: Any) - decorator to register function endpoint handling PUT HTTP method
  • app.patch(path: str, kwargs: Any) - decorator to register function endpoint handling PATCH HTTP method
  • app.delete(path: str, kwargs: Any) - decorator to register function endpoint handling DELETE HTTP method
  • app.endpoint(path: str) - decorator to register component based endpoint
  • app.add_route(path: str, route: Callable[..., Any], method: str, kwargs: Any) - register any endpoint handler
  • app.url_path_for(name: str, /, path_params: Any) - get URL path for endpoint of given name
  • app.exception_handler(exc_class_or_status_code: int | type[Exception]) - register exception handler

Endpoints

There are three types of endpoints that you can create:

Function-Based

These are functions returning Ludic components, a tuple or the Starlette's Response class.

Here are some examples of function handlers registered in Ludic:

from ludic.web.datastructures import FormData
from ludic.web.exceptions import NotFoundError

from your_app.pages import Page
from your_app.models import Person
from your_app.components import Header


@app.get("/people/{id}")
async def show_person(id: str) -> Page:
    person: Person = db.people.get(id)

    if person is None:
        raise NotFoundError("Contact not found")

    return Page(
        Header(person.name),
        ...
    )


@app.post("/people/")
async def register_person(data: FormData) -> Page:
    person: Person = Person.objects.create(**data)
    return await show_person(person.id), 202

Component-Based

While it is possible to use function-based handlers everywhere, in the case of HTMX-based web applications, we want to also create a lot of endpoints rendering only sole form elements, tables, and so on. We don't need to always return the whole HTML document in <html> tag. We could use function-based handlers for that, however, it is often better to think of endpoints as just another components.

Component-based endpoints can only have one generic argument which is the type of attributes. They cannot have children.

Here is an example where we create two component-based endpoints:

from ludic.web import Endpoint
from ludic.web.datastructures import FormData

from your_app.pages import Page
from your_app.models import Person
from your_app.components import Header, Body


@app.get("/")
async def index() -> Page:
    return Page(
        Header("Click To Edit"),
        Body(*[await Contact.get(contact_id) for contact_id in db.contacts]),
    )


@app.endpoint("/contacts/{id}")
class Contact(Endpoint[ContactAttrs]):
    @classmethod
    async def get(cls, id: str) -> Self:
        contact = db.contacts.get(id)
        return cls(**contact.as_dict())

    @classmethod
    async def put(cls, id: str, data: FormData) -> Self:
        contact = db.contacts.get(id)
        contact.update(**data)
        return await cls.get(id)

    @override
    def render(self) -> div:
        return div(
            Pairs(items=self.attrs.items()),
            ButtonPrimary(
                "Click To Edit",
                hx_get=self.url_for(ContactForm),
            ),
            hx_target="this",
            hx_swap="outerHTML",
        )


@app.endpoint("/contacts/{id}/form/")
class ContactForm(Endpoint[ContactAttrs]):
    @classmethod
    async def get(cls, id: str) -> Self:
        contact = db.contacts.get(id)
        return cls(**contact.as_dict())

    @override
    def render(self) -> Form:
        return Form(
            # ... form fields definition here ...,
            ButtonPrimary("Submit"),
            ButtonDanger("Cancel", hx_get=self.url_for(Contact)),
            hx_put=self.url_for(Contact),
            hx_target="this",
        )

The benefit of this approach is that you can create components that generate the URL path for other component-based endpoints with the url_for method. More about that in the next section.

Reverse URL Lookups

There are two possible ways to generate the URL for a particular route handled by an endpoint:

  • Request.url_for
  • Endpoint.url_for

Request.url_for(endpoint: Callable[..., Any] | str, ...)

This method is available on the ludic.web.requests.Request object. It generates and URLPath object for a given endpoint.

Endpoint.url_for(endpoint: type[RoutedProtocol] | str, ...)

This method is available on a component-based endpoint. It has one small advantage over the request's method -- if the destination component defines the same attributes, the path parameters are automatically extracted so you don't need to pass them via key-word arguments. Here are examples:

  • ContactForm(...).url_for(Contact) - Even though the ContactForm endpoint requires the id path parameter, it is automatically taken from ContactForm(...).attrs since the type of ContactForm[ContactAttrs] and Contact[ContactAttrs] are the same.
  • If these attribute types are not equal, you need to specify the URL path parameter explicitly, e.g. ContactForm(...).url_for(Foo, id=self.attrs["foo_id"])
  • if the first argument to url_for is the name of the endpoint, you need to always specify the URL path parameters explicitly.

Handler Responses

Your handler is not required to return only a valid element or component, you can also modify headers, status code, or return a JSONResponse:

from ludic import types
from ludic.html import div

@app.get("/")
def index() -> tuple[div, types.Headers]:
    return div("Headers Example"), {"Content-Type": "..."}

When it comes to the handler's return type, you have the following options:

  • BaseElement - any element or component
  • tuple[BaseElement, int] - any element or component and a status code
  • tuple[BaseElement, types.Headers] - any element or component and headers as a dict
  • tuple[BaseElement, int, types.Headers] - any element or component, status code, and headers
  • starlette.responses.Response - valid Starlette Response object

Handler Arguments

Here is a list of arguments that your handlers can optionally define (they need to be correctly type-annotated):

  • <name>: <type> - if the endpoint accepts path parameters, they can be specified in the handler's arguments
  • request: Request - the Ludic's slightly modified ludic.web.requests.Request class based on Starlette's one.
  • params: QueryParams - contain query string parameters and can be imported from ludic.web.datastructures.QueryParams
  • data: FormData - an immutable multi-dict, containing both file uploads and text input from form submission
  • headers: Headers - HTTP headers exposed as an immutable, case-insensitive, multi-dict

Parsers

Experimental

This module is in an experimental state. The API may change in the future.

The ludic.parsers module contains helpers for parsing FormData. The way it works is that you define Attrs class with Annotated arguments like here:

class PersonAttrs(Attrs):
    id: NotRequired[int]
    name: Annotated[str, parse_name]
    email: Annotated[str, parse_email]

Now you can use the Parser class to annotate arguments of your handler. The parser will attempt to parse form data if any Callable is found in the metadata argument of Annotated:

from ludic.web.parsers import Parser


@app.put("/people/{id}")
async def update_person(cls, id: str, data: Parser[PersonAttrs]) -> div:
    person = db.people.get(id)
    person.update(data.validate())
    return div(...)  # return updated user

The Parser.validate() uses typeguard to validate the form data. If the validation fails, the method raises ludic.parsers.ValidationError if the request's form data are not valid. If unhandled, this results in 403 status code.

Error Handlers

You can use error handlers for custom pages for non-ok HTTP status codes. You can register a handler with the app.exception_handler decorator:

from your_app.pages import Page


@app.exception_handler(404)
async def not_found() -> Page:
    return Page(
        Header("Page Not Found"),
        Body(Paragraph("The page you are looking for was not found.")),
    )


@app.exception_handler(500)
async def server_error() -> Page:
    return Page(
        Header("Server Error"),
        Body(Paragraph("Server encountered an error during processing.")),
    )

Optionally, you can use the request: Request and exc: Exception arguments for the handler:

@app.exception_handler(500)
async def server_error(request: Request, exc: Exception) -> Page: ...

Exceptions

The ludic.web.exceptions contains a lot of useful exceptions that can be raised in your views and caught in your custom error handlers:

  • ClientError(HTTPException) - default status code 400
  • BadRequestError(ClientError) - default status code 400
  • UnauthorizedError(ClientError) - default status code 401
  • PaymentRequiredError(ClientError) - default status code 402
  • ForbiddenError(ClientError) - default status code 403
  • NotFoundError(ClientError) - default status code 404
  • MethodNotAllowedError(ClientError) - default status code 405
  • TooManyRequestsError(ClientError) - default status code 429
  • ServerError(HTTPException) - default status code 500
  • InternalServerError(ServerError) - default status code 500
  • NotImplementedError(ServerError) - default status code 501
  • BadGatewayError(ServerError) - default status code 502
  • ServiceUnavailableError(ServerError) - default status code 503
  • GatewayTimeoutError(ServerError) - default status code 504

WebSockets

WebSockets support is not yet fully tested in Ludic. However, Starlette has good support for WebSockets so it should be possible to use Ludic as well.

Testing

Testing Ludic Web Apps is the same as testing Starlette apps which use a TestClient class exposing the same interface as httpx library. Read more about testing in the Starlette documentation.