153 lines
7.4 KiB
Text
153 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]),
|
||
|
|
}
|