Skip to content

Commit

Permalink
Add support for HTMX Headers
Browse files Browse the repository at this point in the history
  • Loading branch information
paveldedik committed Mar 12, 2024
1 parent 3ee8d64 commit 656dcda
Show file tree
Hide file tree
Showing 7 changed files with 94 additions and 6 deletions.
15 changes: 15 additions & 0 deletions docs/htmx.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,21 @@ page = Page(
)
```

## Headers

It is possible to return custom HTMX headers in responses, here is an example:

```python
from ludic import types
from ludic.html import div

@app.get("/")
def index() -> tuple[div, types.HXHeaders]:
return div("Headers Example", id="test"), {"HX-Location": {"path": "/", "target": "#test"}}
```

You can also type your endpoint with `tuple[div, types.Headers]`, however, it allows arbitrary headers.

## Rendering JavaScript

In some cases, HTMX components require some JavaScript code. For that purpose, there is the `ludic.types.JavaScript` class:
Expand Down
21 changes: 21 additions & 0 deletions docs/web.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,27 @@ This method is available on a component based endpoint. It has one small advanta
- If these attributes 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 a 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`:

```python
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 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):
Expand Down
4 changes: 2 additions & 2 deletions examples/click_to_edit.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ class ContactAttrs(Attrs):
async def index() -> Page:
return Page(
Header("Click To Edit"),
Body(*[await Contact.get(contact_id) for contact_id in db.contacts]),
Body(*[Contact(**contact.dict()) for contact in db.contacts.values()]),
)


Expand All @@ -55,7 +55,7 @@ async def put(cls, id: str, attrs: Parser[ContactAttrs]) -> Self:
for key, value in attrs.validate().items():
setattr(contact, key, value)

return await cls.get(id)
return cls(**contact.dict())

@override
def render(self) -> div:
Expand Down
24 changes: 24 additions & 0 deletions ludic/types.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
from collections.abc import Mapping
from typing import TypedDict

from .base import (
AnyChildren,
Attrs,
Expand All @@ -19,6 +22,25 @@
TChildrenArgs,
)

Headers = Mapping[str, str | Mapping[str, str]]
HXHeaders = TypedDict(
"HXHeaders",
{
"HX-Location": str | Mapping[str, str],
"HX-Push-Url": str,
"HX-Redirect": str,
"HX-Refresh": bool,
"HX-Replace-Url": str,
"HX-Reswap": str,
"HX-Retarget": str,
"HX-Reselect": str,
"HX-Trigger": str | Mapping[str, str],
"HX-Trigger-After-Settle": str | Mapping[str, str],
"HX-Trigger-After-Swap": str | Mapping[str, str],
},
total=False,
)

__all__ = (
"AnyChildren",
"Attrs",
Expand All @@ -29,6 +51,8 @@
"Element",
"ElementStrict",
"GlobalStyles",
"Headers",
"HXHeaders",
"JavaScript",
"NoAttrs",
"NoChildren",
Expand Down
25 changes: 24 additions & 1 deletion ludic/web/datastructures.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,41 @@
import json
from collections.abc import Mapping, MutableMapping
from typing import Any
from urllib.parse import urlencode

from starlette.datastructures import (
FormData,
Headers,
QueryParams,
)
from starlette.datastructures import Headers as BaseHeaders
from starlette.datastructures import (
URLPath as BaseURLPath,
)

__all__ = ("FormData", "Headers", "QueryParams", "URLPath")


class Headers(BaseHeaders):
"""An immutable, case-insensitive multi-dict representing HTTP headers."""

def __init__(
self,
headers: Mapping[str, Any] | None = None,
raw: list[tuple[bytes, bytes]] | None = None,
scope: MutableMapping[str, Any] | None = None,
) -> None:
new_headers: dict[str, str] = {}

if headers:
for key, value in headers.items():
if isinstance(value, str):
new_headers[key] = value
else:
new_headers[key] = json.dumps(value)

super().__init__(new_headers, raw, scope)


class URLPath(BaseURLPath):
"""A URL path string that may also hold an associated protocol and/or host.
Expand Down
9 changes: 7 additions & 2 deletions ludic/web/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
)

from ludic.types import BaseElement
from ludic.web import datastructures as ds
from ludic.web.parsers import BaseParser

__all__ = (
Expand Down Expand Up @@ -72,10 +73,14 @@ async def prepare_response(

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

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "ludic"
version = "0.1.0"
version = "0.1.1"
description = "Lightweight framework for building HTML pages in pure Python."
keywords = ["html", "htmx", "async", "web", "templating"]
authors = [{ name = "Pavel Dedík", email = "dedikx@gmail.com" }]
Expand Down

0 comments on commit 656dcda

Please sign in to comment.