Menu
Public repository / master
AkurAI-Framework / site/content/docs/forms.md
Latest change d506ed551fac416b6bd5797287d71abc9af1d250 - OLI-211: ==, != and in comparisons in template {% if %} by Ólafur Búi Ólafsson
# Forms & actions
The server reads request bodies and handles plain HTML form submissions — no
client framework required. A form works with JavaScript disabled; that's the
progressive-enhancement baseline the framework is built on.
## Reading the body
Every handler receives the parsed request. The body is read from the socket
using `Content-Length` (capped at 8 MiB) and exposed on the request:
- `req.body` — the raw bytes (`Vec<u8>`).
- `req.body_str()` — the body as lossy UTF-8 text.
- `req.content_length()` — the declared length, if any.
`GET` requests and any request without a `Content-Length` have an empty body.
## Parsing a form
The `akurai_http::form` module parses `application/x-www-form-urlencoded` — what
a browser submits for `<form method="post">` — with percent-decoding and `+`
meaning space:
```rust
let pairs = akurai_http::form::parse_urlencoded(&req.body_str());
let name = akurai_http::form::field(&pairs, "name").unwrap_or("");
```
## The Post/Redirect/Get pattern
A submission should validate, act, and then **redirect** to a GET, so a browser
refresh never resubmits:
```rust
if name.is_empty() {
return render_form_with_error(...); // re-render, keep what they typed
}
store.append(&record)?; // do the work
Response::new(303).with_header("Location", "/guestbook") // redirect back
```
## Worked example: the guestbook
The live `/guestbook` is the full pattern end to end:
- `GET /guestbook` lists entries.
- `POST /guestbook` validates that name and message are present, stores the
entry (durably — see [Storage](/docs/storage)), and 303-redirects to the list.
- On a validation error it re-renders the form with the message and the values
already typed.
## Declarative forms (`[[forms]]`)
Declare a form in `akurai.toml` and the server handles the whole submission —
no handler code. A `POST` to the form's `path` runs, in order: honeypot check,
per-IP rate limit, validation, durable store, optional mail notification, then a
`303` redirect (Post/Redirect/Get).
```toml
[[forms]]
name = "contact" # required; [A-Za-z0-9_-]
path = "/contact" # POST target (default /forms/<name>)
success = "/contact?sent=1" # 303 target (default <path>?sent=1)
template = "contact" # page re-rendered on error (default: last path segment)
layout = "layout" # optional layout for the re-render
honeypot = "website" # bots filling this get a fake success, nothing stored
test_mode = false # true: validate and redirect only — no store, no mail, no rate limit (staging/QA; keep off in production)
rate_limit = 5 # submissions per window per IP (0 = off, default 5)
rate_window = 600 # seconds (default 600)
trust_proxy = false # true: key the limit on X-Forwarded-For
notify_to = "team@example.com" # omit to skip mail
notify_from = "Site <forms@example.com>"
notify_subject = "New contact message"
reply_to = "email" # field whose value becomes Reply-To (must be a valid email)
smtp = "127.0.0.1:25" # local MTA (default)
[[forms.fields]]
name = "name"
required = true
max = 100
[[forms.fields]]
name = "email"
type = "email" # text | textarea | email | tel | url | number
required = true
[[forms.fields]]
name = "message"
type = "textarea"
required = true
min = 10
max = 4000
```
**Validation.** `required`, per-type format checks, and `min`/`max` (length for
text types, value for `number`). Text is capped at 5000 characters unless `max`
says otherwise. Values are trimmed.
**Storage.** Each form gets a collection `form_<name>` in `data/forms.db`
(fields plus `id`/`created`), written before any mail is attempted.
**Notification.** A plain SMTP conversation with `smtp` (a local Postfix),
sent on a background thread; a failed send is logged and never fails the
request. The body is base64 UTF-8 and header values are stripped of CR/LF.
Only point test configs at your own mailbox.
**Error re-render.** On failure the `template` page is rendered with status
`422` (`429` when rate limited, with `Retry-After`) and this context:
- `form.errors.<field>` — the localised message for that field
- `form.values.<field>` — what the user typed
- `form.error` — a form-level message (rate limit, storage failure)
```html
<form method="post" action="/contact">
<div class="field{% if form.errors.email %} is-invalid{% endif %}">
<label for="email">Email</label>
<input id="email" name="email" type="email" value="{{ form.values.email }}"
aria-describedby="email-err" required />
<p class="field-error" id="email-err">{{ form.errors.email }}</p>
</div>
<input class="hp" name="website" tabindex="-1" autocomplete="off" aria-hidden="true" />
<div class="form-actions"><button type="submit">Send</button></div>
</form>
```
**Path-prefix locales.** With `"locale_prefix": true`, a form is also reachable at `/<code><path>` (e.g. `POST /en/contact`): errors render in that locale and the `success` redirect is prefixed (`/en/sent`). Point the non-default form's `action` at the prefixed path.
**i18n.** Messages come from the locale catalogs (`akurai-lang` cookie, else the
default locale) under `forms.error.required|email|tel|url|number|min|max|rate_limited|generic`;
`{n}` is replaced with the limit. English defaults apply when a key is missing.
### Restoring select / radio state without JS
`{% if %}` supports `==`, `!=` and `in`, so a re-rendered form can restore
choices server-side:
```html
<select name="topic">
{% for t in topics %}
<option value="{{ t }}"{% if t == form.values.topic %} selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
```
Operands are context paths, `"quoted strings"` or integers. Scalars compare by
their rendered form (`3 == "3"`); a missing path equals nothing.
`{% if "x" in list %}` tests array membership (or substring for strings).
## Recipe: bilingual contact form
A marketing-site contact form: name, company, email, message, and a required
consent checkbox, with localised errors and a mail to the team.
```toml
[[forms]]
name = "contact"
path = "/contact"
honeypot = "website"
notify_to = "team@example.com"
notify_subject = "New contact message"
reply_to = "email"
[[forms.fields]]
name = "name"
required = true
[[forms.fields]]
name = "company"
[[forms.fields]]
name = "email"
type = "email"
required = true
[[forms.fields]]
name = "message"
type = "textarea"
required = true
min = 10
[[forms.fields]]
name = "consent"
required = true
```
Render the fields with the `form.errors` / `form.values` markup above, add the
`hp` honeypot input, and put the translated labels and `forms.error.*`
messages in each locale catalog (see [i18n](/docs/i18n)). Test configs must
notify only your own mailbox.
## Safety
User input rendered with `{{ }}` is HTML-escaped by the template engine, so a
submitted `<script>` shows as text, not markup. Use `{{{ }}}` only for content
you trust to be safe HTML.