Rustelo/resources/nickel/forms/contracts.ncl
Jesús Pérez f1010e9d07
chore: update
2026-07-18 20:16:16 +01:00

152 lines
7.4 KiB
Text

# Form Contracts — declarative HTML forms, served and delivered WITHOUT a build.
#
# ─────────────────────────────────────────────────────────────────────────────
# WHY THIS EXISTS
#
# Before this, every form meant a typed `struct` in Rust, a handler, a `nest(...)` and a
# RECOMPILE of the shared image. So a form was the one part of a page you could not add: the
# template, the Fluent text, the content and (since `merge_site_routes`) the route were all
# already the consumer's. The endpoint was the last baked thing.
#
# The result was predictable and it is on the record: `work-request` — a public
# "request a project quote" form with eleven fields — has a template and NO handler.
# `POST /api/work-request` is registered nowhere in the stack. The page renders, the visitor
# fills it in, and the submission goes to a route that does not exist. It has been like that
# long enough that nobody noticed, because a form that renders LOOKS like a form that works.
#
# And the naive repair was worse. `ContactForm` (htmx_contact.rs) declares four fields and does
# NOT set `deny_unknown_fields`, so serde SILENTLY DROPS anything it does not know. Wiring
# work-request to it would have returned 200, said "sent", and delivered an email containing the
# name and the email address — with the project description, the budget and the timeline gone.
# A form that appears to work and throws the data away is worse than one that visibly fails.
#
# THE FIX IS THE SAME MOVE AS THE ROUTES. The form is DECLARED, in NCL, by the site. One generic
# handler reads the declaration and delivers what the declaration names. A new form is a `.ncl`
# and a template — no Rust, no build.
#
# And it can be NCL rather than TOML precisely because the runtime already reads NCL: the htmx
# shell loads `routes.ncl` and `footer.ncl` through `rustelo_config::format::load_config`, which
# dispatches `.ncl` to `nickel::export_to_json`. No new dependency; the one that is already there.
#
# WHAT THE CONTRACT BUYS OVER A BARE MAP. The handler accepts a `HashMap<String,String>` — no
# per-form struct, which is the whole point. The contract is what keeps that from becoming a
# free-for-all:
#
# · a field not in `fields` is REFUSED, not silently dropped. The failure mode that made this
# necessary cannot recur here — it is exactly what serde's unknown-field default did.
# · `required` is checked before delivery, not after.
# · `deliver_to` names an env var, never a literal address: a recipient in git is a recipient
# harvested.
# · every declared field appears in the composed body, in declaration order. What the form
# collects is what the owner receives.
# ─────────────────────────────────────────────────────────────────────────────
let field_kind = [|
'Text,
'Email,
'Textarea,
'Select,
# A honeypot. Rendered hidden; a non-empty value means a bot, and the handler confirms
# WITHOUT delivering, so the bot gets no signal that it was caught.
'Honeypot,
|] in
let field_type = {
# The form-encoded name. This is the contract with the template: a field the template posts
# and the spec does not declare is REFUSED by the handler.
name | String,
kind | field_kind | default = 'Text,
required | Bool | default = false,
# Fluent key for the label. The text lives where all text lives.
label_key | String | default = "",
# Fluent key for the placeholder / help text.
hint_key | String | default = "",
# For 'Select: the option values. Their labels are `<label_key>-<value>` in Fluent.
options | Array String | default = [],
# Included in the delivered body. False for a honeypot, and for anything the owner does not
# need to read. Default true: a field that is collected and NOT delivered is the bug this
# whole contract was written after.
deliver | Bool | default = true,
} in
let RequiredFieldsExist = std.contract.from_validator (fun spec =>
let names = spec.fields |> std.array.map (fun f => f.name) in
let missing = spec.required |> std.array.filter (fun r => !(std.array.elem r names)) in
if std.array.length missing == 0 then
'Ok
else
'Error {
message = "`required` names fields that are not declared: " ++ std.string.join ", " missing,
notes = ["A required field the form never renders can never be filled in."],
}
) in
# Nothing declared twice: two entries for one field name is not a form, it is a race between
# whichever the handler reads last.
let FieldNamesUnique = std.contract.from_validator (fun spec =>
let names = spec.fields |> std.array.map (fun f => f.name) in
let dups =
names
|> std.array.filter (fun n => (names |> std.array.filter (fun m => m == n) |> std.array.length) > 1)
in
if std.array.length dups == 0 then
'Ok
else
'Error { message = "duplicate field name: " ++ std.string.join ", " dups }
) in
# The recipient is an ENV VAR NAME, never an address. An address in a config file is an address
# in git, and an address in git is an address in a scraper's list.
let RecipientIsEnvVar = std.contract.from_validator (fun v =>
if std.string.is_match "^[A-Z][A-Z0-9_]*$" v then
'Ok
else
'Error {
message = "`deliver_to` must be an ENV VAR NAME (e.g. WORK_REQUEST_RECIPIENT), got: " ++ v,
notes = ["A literal address here is a literal address in the repository."],
}
) in
let form_spec_type = {
# Matches the URL: POST /api/htmx/form/<id>, and the file name `<id>.ncl`.
id | String,
# The env var holding the destination address. Unset at runtime ⇒ the submission is logged and
# the visitor still gets a confirmation: a form that 500s because the OWNER forgot to configure
# it punishes the wrong person.
deliver_to | String | RecipientIsEnvVar,
# The subject line. `{field}` interpolates a submitted value; an unknown field interpolates to
# nothing rather than failing, because a subject line is not worth losing a lead over.
subject | String | default = "",
# Fluent keys for the two response fragments the handler returns.
success_key | String | default = "",
error_key | String | default = "",
fields | Array field_type,
required | Array String | default = [],
} in
{
FieldKind = field_kind,
Field = field_type,
FormSpec = std.contract.Sequence [
form_spec_type,
RequiredFieldsExist,
FieldNamesUnique,
],
# Build a spec. `required` defaults to every field marked `required = true`, so the two cannot
# disagree — an earlier design had them as independent lists and that is a contradiction waiting.
make_form = fun data =>
# The contract must be applied to each field BEFORE reading `required` off it — a raw record
# carries no defaults, and `f.required` on one is a missing field, not `false`.
let flds = data.fields |> std.array.map (fun f => field_type & f) in
let req = flds |> std.array.filter (fun f => f.required) |> std.array.map (fun f => f.name) in
# `data` must lose its own `fields` before the merge: `data & { fields = <derived from
# data.fields> }` is a record referring to itself, and Nickel calls that what it is.
((std.record.remove "fields" data) & { fields = flds, required = req })
| (std.contract.Sequence [form_spec_type, RequiredFieldsExist, FieldNamesUnique]),
}