Skip to content

Commit 4ed6649

Browse files
authored
Merge pull request #22 from volfpeter/feat/form-handling
Form handling
2 parents 0f05a43 + e143e25 commit 4ed6649

File tree

26 files changed

+802
-46
lines changed

26 files changed

+802
-46
lines changed

README.md

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,12 +86,6 @@ You can discover even more features by exploring the [test application](https://
8686

8787
If you are looking for the simplest possible application you can create, then can find it in the [examples/minimal](https://github.com/volfpeter/holm/tree/main/examples/minimal) directory of the repository.
8888

89-
## Security
90-
91-
At the moment, the library **does not** provide security features like automatic **CSRF prevention** out of the box. A custom form component and a corresponding middleware may be added to the library later, but of course 3rd party implementations are very welcome! Alternative solutions include JavaScript, but the library aims to remain unopinionated, especially on the client front. If you use HTMX, you should check out their [CSRF prevention](https://htmx.org/docs/#csrf-prevention) recommendation, just keep in mind that the `<body>` tag is not swapped for boosted requests.
92-
93-
Also, do not forget about **XSS prevention**, when rendering untrusted data with custom components!
94-
9589
## AI assistance
9690

9791
The library and all its dependencies are registered at [Context7](https://context7.com/volfpeter).

docs/application-components.md

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ This section summarizes the different components of `holm` applications. If you
99
Rules for *layouts*:
1010

1111
- The root layout must always return a `htmy.Component`.
12-
- The `layout` variable must be a callable and it must accept a positional argument (other than `self` if the layout is a method of a class), the data / properties of the layout that are returned by the pages or layouts this layout directly wraps.
13-
- The `layout` variable can have additional arguments (position or keyword and keyword-only, but not positional-only). These arguments must be FastAPI dependencies. They will be automatically resolved during each request.
12+
- `layout` must be a callable and it must accept a positional argument (other than `self` if the layout is a method of a class), the data / properties of the layout that are returned by the pages or layouts this layout directly wraps.
13+
- `layout` can have additional arguments (position or keyword and keyword-only, but not positional-only). These arguments must be FastAPI dependencies. They will be automatically resolved during each request.
1414
- Returning a tuple or a list from a layout is **not allowed** unless the value is a `htmy.ComponentSequence`. Tuples and lists are always interpreted and treated as component sequences, so you don't need to track what kinds of components pages and layouts return. See `htmy.is_component_sequence()` for more information.
1515

1616
Layouts automatically wrap all layouts and pages in subpackages.
@@ -21,10 +21,13 @@ Layouts automatically wrap all layouts and pages in subpackages.
2121

2222
Rules for *pages*:
2323

24-
- The `page` variable must be a FastAPI dependency, meaning it can have any arguments as long as they can all be resolved by FastAPI as dependencies.
25-
- The `page` variable must return the properties object for the layout that directly wraps it.
24+
- `page` must be a FastAPI dependency, meaning it can have any arguments as long as they can all be resolved by FastAPI as dependencies.
25+
- `page` must return the properties object for the layout that directly wraps it.
2626
- If a page is not wrapped by a layout, then it must return a `htmy.Component`.
2727
- Returning a tuple or a list from a page is **not allowed** unless the value is a `htmy.ComponentSequence`. Tuples and lists are always interpreted and treated as component sequences, so you don't need to track what kinds of components pages and layouts return. See `htmy.is_component_sequence()` for more information.
28+
- `page` can directly return a FastAPI `Response` as well, which is always returned as is.
29+
30+
### Page metadata
2831

2932
`page.py` modules can have a `metadata` variable, which can be an arbitrary mapping or a FastAPI dependency that returns an arbitrary mapping.
3033

@@ -34,6 +37,18 @@ The metadata provided by the currently served page is made available to every `h
3437

3538
This feature is particularly useful when page-specific information - for example title, description, or keywords - must be set dynamically (for example, in layouts) on a page-by-page basis. `Metadata` implements the `Mapping` protocol, so once loaded from the `htmy` rendering context in a component with `metadata = Metadata.from_context(context)`, you can use it simply like this to set page-specific information in any layout, component, or the page itself: `htmy.html.title(metadata["title"])`.
3639

40+
### Page submit handlers
41+
42+
`page.py` modules can also define a callable `handle_submit` variable. The same rendering logic and rules apply to it as to the `page` variable itself. The only difference is that for `handle_submit`, a HTTP `POST` route is created.
43+
44+
`handle_submit`, together with `page`, offer a convenient way to handle form submission in your application.
45+
46+
The default HTML `<form>` action is to submit the form to the current URL using a HTTP GET request. This means if you have a form in your `page` (or `layout`), and you do not set `action` and `method`, your form's submission will be handled by your current `page` by default (your GET route). This is useful for search and filtering forms for example.
47+
48+
For forms that trigger state change on the server, you should set the form `method` to `POST`. This is important from a CSRF prevention perspective, and it also ensures the submitted form will be handled by your `handle_submit` function (your POST route), instead of your `page` function.
49+
50+
You can of course set `action` to some URL. In that case the same logic applies, but instead of triggering the `page` or `handle_submit` function that belong to the current URL, it will trigger them in the `page.py` or `api.py` module that handles the given URL.
51+
3752
## APIs
3853

3954
APIs are defined in the `api.py` module or packages as an `APIRouter` variable or a callable that returns an `APIRouter`.

docs/guides/forms.md

Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
# Forms
2+
3+
Let's explore how `holm` simplifies form handling by building a functional TODO application.
4+
5+
We will cover:
6+
7+
- How to handle HTTP GET forms, by creating a global search bar with a form that uses a query parameter for filtering.
8+
- How to handle HTTP POST forms, by implementing a form for creating new TODOs using `POST` requests, utilizing `holm`'s submit handler feature.
9+
- Basic server-side form validation and error rendering.
10+
- How to create reusable user interface components.
11+
12+
The guide assumes you are familiar with the core concepts of `holm`, including [layouts](../application-components.md#layouts-and-pages), [pages](../application-components.md#layouts-and-pages), and [submit handlers](../application-components.md#page-submit-handlers), and you are looking to put these features into practice.
13+
14+
The entire source code of this application can be found in the [examples/forms](https://github.com/volfpeter/holm/tree/main/examples/forms) directory of the repository.
15+
16+
Before you continue, ensure you have installed `holm` and either `uvicorn` or `fastapi-cli` (`pip install holm uvicorn` or `pip install holm fastapi-cli`)!
17+
18+
## Create the application structure
19+
20+
First, create the following directory structure:
21+
22+
```
23+
my_app/
24+
├── main.py # Application entry point
25+
├── layout.py # Root layout with global search bar
26+
├── page.py # Home page with a TODO creation form and a TODO list
27+
└── todo_service.py # Business logic for TODOs
28+
```
29+
30+
## Initialize your application
31+
32+
Create `main.py` first, our application's entry point.
33+
34+
If you've read the [quick start guide](quick-start-guide.md), then the application setup will already be familiar to you. If not, it's just two lines of code:
35+
36+
```python
37+
from holm import App
38+
39+
app = App()
40+
```
41+
42+
That's it. When you start the application, `holm` automatically discovers your application's structure and registers all routes for you.
43+
44+
## Data model and services
45+
46+
Let's continue by implementing the `todo_service.py` module. It acts as our data layer, and defines a `Todo` model and two services for listing and creating TODOs.
47+
48+
```python
49+
from dataclasses import dataclass
50+
51+
52+
@dataclass(frozen=True, slots=True)
53+
class Todo:
54+
"""A simple TODO item."""
55+
56+
title: str
57+
description: str
58+
59+
60+
todos = [
61+
Todo(
62+
title="Home page",
63+
description="It should show the list of TODOs.",
64+
),
65+
Todo(
66+
title="Add filtering",
67+
description="Add a form that filters the TODO list using substring search on the title.",
68+
),
69+
Todo(
70+
title="Add a creation form",
71+
description="Add a form that submits a creation form with a POST request to a submit handler.",
72+
),
73+
]
74+
"""The list of existing TODO items. This list acts as a database for the application."""
75+
76+
77+
def find_todos(query: str) -> list[Todo]:
78+
"""Returns a list of TODOs whose title contains the given query string."""
79+
query = query.lower()
80+
return [todo for todo in todos if query in todo.title.lower()]
81+
82+
83+
def create_todo(title: str, description: str) -> Todo:
84+
"""Creates a new TODO with the given title and description and stores it in the database."""
85+
todo = Todo(title=title, description=description)
86+
todos.append(todo)
87+
return todo
88+
```
89+
90+
We now have all the basics in place. Let's move on to the interesting part, the user interface.
91+
92+
## Layout with a global search form
93+
94+
The layout of this application will be a bit more complex than the one in the quick start guide:
95+
96+
- We will move the entire HTML `head` declaration into a separate component. This way the layout itself does not need to be a `htmy` component, because it does not need to access the `htmy` rendering context.
97+
- The `header` component will contain a form that can be used for filtering on all pages.
98+
99+
With that said, let's create `layout.py`, import everything we need, and start by implementing the mentioned custom HTML `head` component:
100+
101+
```python
102+
from fastapi import Request
103+
from htmy import Component, ComponentType, Context, XBool, component, html
104+
105+
from holm import Metadata
106+
107+
108+
@component.context_only
109+
def head(context: Context) -> html.head:
110+
"""
111+
Helper component that returns the entire head element of the page.
112+
113+
It uses `Metadata` to correctly set the page title. This way we do not need
114+
to access the `htmy` context in the layout itself, so that doesn't need to
115+
be a `htmy` component, it can be a simple `holm` layout function with dependencies.
116+
"""
117+
metadata = Metadata.from_context(context)
118+
return html.head(
119+
html.title(metadata.get("title", "TODO App")),
120+
html.meta(charset="utf-8"),
121+
html.meta(name="viewport", content="width=device-width, initial-scale=1"),
122+
html.link( # Use PicoCSS to add some default styling.
123+
rel="stylesheet", href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"
124+
),
125+
)
126+
```
127+
128+
`head` is a simple, context-only `htmy` function component. The only `holm`-specific part in this bit of code is `Metadata`, which gives the component access to the metadata that's provided by the currently rendered page.
129+
130+
Next we can add the search form:
131+
132+
```python
133+
def search_form(q: str, *, autofocus: bool) -> html.form:
134+
"""
135+
Search form for the layout.
136+
137+
Arguments:
138+
q: The current value of the `q` input.
139+
autofocus: Whether the search input should be focused on page load.
140+
"""
141+
return html.form(
142+
html.input_(
143+
type="search",
144+
name="q", # The name of our filter query parameter.
145+
value=q, # Keep the value of the input field.
146+
placeholder="Search...",
147+
# Focus automatically after GET requests. autofocus is a bool HTML
148+
# attribute, so to disable it, we must either omit the attribute or
149+
# use the XBool htmy utility.
150+
autofocus=XBool.true if autofocus else XBool.false,
151+
),
152+
html.button("Find", type="submit"),
153+
role="search",
154+
)
155+
```
156+
157+
The most important thing to note in the search form is that the input's `name` attribute is set to `"q"`. This is the name the browser will use to submit the value of our search input. As a consequence, our layout must have a `q: str` query parameter dependency in order to access the submitted value. Of course, pages that do filtering should also have the same dependency.
158+
159+
Okay, we're done with all the complex parts! We can add the `layout` itself, which is quite simple: just a FastAPI dependency with the usual `children` property, a `request: Request` dependency that we use to decide whether to autofocus the search input, and the `q: str` query parameter dependency that we use to persist the search query between page loads.
160+
161+
```python
162+
def layout(children: ComponentType, request: Request, q: str = "") -> Component:
163+
"""Root layout wrapping all pages."""
164+
return (
165+
html.DOCTYPE.html,
166+
html.html(
167+
# Use our head component.
168+
head(),
169+
html.body(
170+
html.header(
171+
# Global search form, present in all pages, always submitted
172+
# with a GET request to the current URL. The input named "q"
173+
# contains the search query in the HTML, this is why the
174+
# layout has a matching q query parameter dependency.
175+
# Pages can use the same dependency for filtering.
176+
search_form(q, autofocus=request.method == "GET"),
177+
class_="container",
178+
),
179+
html.main(children, class_="container"),
180+
html.footer(html.p("© 2025 TODO App"), class_="container"),
181+
class_="container-fluid",
182+
),
183+
),
184+
)
185+
```
186+
187+
## Home page with a TODO creation form
188+
189+
Create `page.py` for your home page. The page will:
190+
191+
- Show a form for creating new TODO items, demonstrating `holm`'s submit handler feature.
192+
- Display the list of TODOs, always filtered by the search query (coming from the search form in our layout) if there is one.
193+
- Do server-side form validation and error rendering, to forbid TODOs with empty or whitespace-only titles and descriptions.
194+
- Show the fresh TODO list after successful creation, with an empty TODO creation form.
195+
- Show the TODO list after failed creation, keeping the form data and marking invalid inputs (using the `aria-invalid` attribute).
196+
197+
The description implies that the page looks essentially the same both on initial load and after TODO creation attempts, so it makes sense to extract the page content into a function (let's call it `page_content`) that we can use both in `page()` (our GET route) and in `handle_submit()` (our creation/POST route). Let's start with this component, which will actually be the bulk of our `page.py` module:
198+
199+
```python
200+
from typing import Annotated
201+
202+
from fastapi import Form
203+
from htmy import Component, XBool, html
204+
from todo_service import create_todo, find_todos
205+
206+
207+
def page_content(
208+
q: str,
209+
*,
210+
title: str = "",
211+
title_invalid: bool = False,
212+
description: str = "",
213+
description_invalid: bool = False,
214+
) -> html.div:
215+
"""
216+
Returns the common page content for `page()` and `handle_submit()`.
217+
218+
Arguments:
219+
q: The current query string for TODO list filtering.
220+
title: The text to show in the title input.
221+
title_invalid: Whether the title should be marked as invalid.
222+
description: The text to show in the description input.
223+
description_invalid: Whether the description should be marked as invalid.
224+
"""
225+
return html.div(
226+
html.form(
227+
# TODO creation form. It has a `title` and a `description` input field.
228+
html.input_(
229+
type="text",
230+
name="title",
231+
value=title,
232+
autofocus="", # Try to focus this input by default.
233+
# aria-invalid is a bool HTML attribute, so to disable it, we must
234+
# either omit the attribute or use the XBool utility from htmy.
235+
aria_invalid=XBool.true if title_invalid else XBool.false,
236+
),
237+
html.input_(
238+
type="text",
239+
name="description",
240+
value=description,
241+
# aria-invalid is a bool HTML attribute, so to disable it, we must
242+
# either omit the attribute or use the XBool utility from htmy.
243+
aria_invalid=XBool.true if description_invalid else XBool.false,
244+
),
245+
html.button("Create TODO", type="submit"),
246+
# We don't need to set the form action, the default browser behavior is
247+
# to submit forms to the current URL. We must set the method to POST
248+
# though, to direct the request to our `handle_submit()` function,
249+
# which is our POST request handler!
250+
method="POST",
251+
),
252+
html.hr(),
253+
html.div(
254+
*( # Create a PicoCSS Card for each TODO.
255+
html.article(
256+
html.header(html.strong(todo.title)),
257+
html.p(todo.description),
258+
)
259+
for todo in find_todos(q)
260+
)
261+
),
262+
)
263+
```
264+
265+
That's quite a few lines of code, but not too bad if you skip the in-code explanations.
266+
267+
Let's also add the page metadata and our `page` function (our GET route):
268+
269+
```python
270+
# Static metadata
271+
metadata = {"title": "Home | TODO App"}
272+
273+
274+
def page(q: str = "") -> Component:
275+
"""Home page content."""
276+
return page_content(q)
277+
```
278+
279+
As you can see, we use the same `q: str` query parameter as in our layout, so we can filter the TODO list.
280+
281+
`handle_submit` (our TODO creation/POST route) will be slightly more complex, because it needs to do basic input validation and TODO creation as well. Apart from this extra bit of logic, this function is also just a `page_content` call:
282+
283+
```python
284+
def handle_submit(
285+
title: Annotated[str, Form()],
286+
description: Annotated[str, Form()],
287+
q: str = "",
288+
) -> Component:
289+
"""
290+
Submit handler that expects a TODO title and description as form data.
291+
292+
It returns the page content the same way as `page()` does. The returned
293+
component is wrapped in the page's layout the same way as it happens
294+
with `page()`.
295+
"""
296+
title, description = title.strip(), description.strip()
297+
title_invalid, description_invalid = title == "", description == ""
298+
if not (title_invalid or description_invalid):
299+
create_todo(title, description) # Create the todo
300+
# Reset the value of form fields before rendering the page.
301+
title = description = ""
302+
303+
return page_content(
304+
q,
305+
title=title,
306+
title_invalid=title_invalid,
307+
description=description,
308+
description_invalid=description_invalid,
309+
)
310+
```
311+
312+
That's it, the application is ready. This was a long guide, congratulations if you've made it all the way through!
313+
314+
If you found this topic complex, don't worry, we covered a lot and you've already built up the necessary intuition to start building interactive applications with `holm` on your own!
315+
316+
## Run your application
317+
318+
You can now run your application using `uvicorn` or `fastapi-cli`, and see your work in action at `http://localhost:8000`:
319+
320+
```bash
321+
uvicorn main:app --reload
322+
```
323+
324+
Or with the FastAPI CLI if installed:
325+
326+
```bash
327+
fastapi dev main.py
328+
```
329+
330+
*Don't be surprised: the application uses an in-memory data layer (`todo_service.py` module), so it will be reset every time the application restarts.*

docs/guides/quick-start-guide.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,6 @@ fastapi dev main.py
180180

181181
Visit these URLs to see your application in action:
182182

183-
- `http://localhost:8000/`: Home page
183+
- `http://localhost:8000`: Home page
184184
- `http://localhost:8000/about`: About page
185185
- `http://localhost:8000/about?featured=true`: About page with dynamic content

0 commit comments

Comments
 (0)