HTML Forms
Some services expose no API at all — only pages with forms on them. capi_html_forms
makes such a site an endpoint: one query() fetches the page, finds the form, fills
it, submits it, and decodes what comes back. The lifecycle is two round trips inside
one decoder, and everything about it rides the framework’s ordinary machinery — the
submission is a follow-up through the decoder’s door, so the config’s pipeline, the
seal, the cookie jar, and the request budget all apply to it.
The six steps
- Fetch the page — the endpoint’s own
GET, an ordinary exchange whose response must be a success: the decoder buffers the HTML and turns any other status intounexpected_status_with_headers,Locationincluded. (A page that redirects isapi.follow_redirects(n).query(flow)’s job; see below.) - Extract the form by the endpoint’s
form_selector()—FormSelector::id("edit-profile"),::name("login"), or::css("form.login"). - Apply the updates the endpoint’s
form_update()returns to the form’s fields; a field the form does not have is aFormError. - Resolve the action the way a browser would: a relative
actionagainstpage_url(), an empty or missing one to the page itself, an absolute one as-is. - Submit — a follow-up exchange through the door, built as a
SubmissionEndpointover the flow endpoint. - Decode the submission’s buffered body and head through the endpoint’s
decode_submission.
The endpoint
A form-flow endpoint declares type Decoder = FormFlowDecoder and implements
FormFlow, which fixes its error type and asks for five things:
#[capi]
#[derive(Debug, Clone, WireModel, ApiEndpoint)]
#[endpoint(path_template = "/clients/{client_id}/profile")]
pub struct UpdateClientProfile {
#[endpoint(location = "path")] pub client_id: String,
pub display_name: String,
pub notify_on_update: bool,
}
impl Endpoint for UpdateClientProfile {
type ApiConfig = PortalConfig;
type Output = ();
type Error = FormFlowError<PortalError>; // fixed by FormFlow
type Decoder = FormFlowDecoder;
type Requires = Http;
fn method(&self) -> Method { Method::GET } // the page fetch
fn url(&self, config: &Self::ApiConfig, _context: &ApiContext) -> impl ToUrl {
config.base_url().path(self.path_string())
}
}
impl FormFlow for UpdateClientProfile {
type SubmissionError = PortalError;
fn page_url(&self, config: &Self::ApiConfig) -> impl ToUrl {
config.base_url().path(self.path_string()) // what relative actions resolve against
}
fn form_selector(&self) -> FormSelector {
FormSelector::id("edit-profile")
}
fn form_update(&self) -> impl FormUpdate + MaybeSend + MaybeSync {
vec![
("display_name", FieldUpdate::from(self.display_name.as_str())),
("notify", FieldUpdate::from(self.notify_on_update)),
]
}
fn decode_submission(
body: Vec<u8>, header: ResponseHead, config: &Self::ApiConfig, _context: ApiContext,
) -> Result<Self::Output, ResponseError<Self::SubmissionError>> {
if header.status.is_success() { Ok(()) } else { Err(build_portal_error(&body, config, header.status)) }
}
}
The bounds say what the flow needs. The endpoint is Clone, because
FormFlowDecoder snapshots it as its decoder State before the page request
consumes it and drives the submission from the snapshot. Its Error is
FormFlowError<Self::SubmissionError>, so the decoder can report its own failures
and your domain error through one type. Its ApiConfig implements
HasUrlEncodedLib — url_encoded_lib() -> Codec<UrlForm> — so a URL-encoded form
body can be serialized. And its lane is response-shaped
(Requires: Capability<Response = Response>), because a submission is a request
and a response.
page_url is the page’s own URL: relative actions resolve against it, an empty
action submits to it, and the submission’s Referer (always) and Origin (for any
method but GET) are derived from it. It usually equals url(), and it should not
carry query parameters that have no business on a submission — for a form with an
empty action they would ride along.
What the submission sends
The SubmissionEndpoint the decoder builds is an OverrideEndpoint over the flow
endpoint, so it keeps the flow’s config and lane and overrides only what the form
dictates: the method (the form’s, GET or POST), the url (the resolved
action), the query_string — for a GET form the field data set replaces the
action’s query entirely, per the HTML spec, including to nothing when the form has
no submittable fields — the headers (Referer, and Origin when not GET, each
only if not already present), the content_type, and the body. For a POST form
the field data is the body and the action’s query is left untouched.
The body follows the form’s enctype. HtmlForm::to_body emits URL-encoded pairs
through the config’s codec for application/x-www-form-urlencoded; a Multipart
body with repeated parts for multi-select values and file parts for populated file
inputs for multipart/form-data; and the HTML text/plain encoding algorithm — one
name=value line per entry, CRLF-terminated, no percent-encoding — for
text/plain.
Filling a form
FormUpdate is the update source: field_updates(&self) -> Vec<(String, FieldUpdate)>. Implement it on a struct of your own, or use one of the blanket
impls — () for no updates, Vec<(K, V)>, [(K, V); N], BTreeMap<K, V>, and
HashMap<K, V> for any V: Into<FieldUpdate>. FieldUpdate converts from &str,
String, f64, bool, and FileBlob, and from capi_wire_datetime’s Date,
DateTime, and Time, which it renders in the formats HTML date,
datetime-local, and time inputs accept.
The extracted HtmlForm is a real model of the page’s form: action(),
method(), enctype(), fields() (with their metadata, in DOM order),
field_names(), has_file_fields(), apply_update(&update), and to_body(..).
For a form you will fill often, the crate writes the Rust for you:
extract_and_generate_rust_code (and the _by_id / _by_name forms) turns a page
into a typed struct with FormUpdate and from_form impls, <label> text as doc
comments, and companion enums for the selects — copy-paste-ready source, not a
build-time step.
Errors and security
FormFlowError names the stage that failed: Form (extraction, a missing field),
UrlResolution, RequestBuild, Transport, or Submission(E) for your own domain
error from decode_submission. A QueryError’s api_error::<FormFlowError<E>>()
recovers it.
An absolute action is honoured verbatim, and action comes from fetched,
possibly attacker-influenced HTML — so a hostile page can direct the submission,
with the Referer, the Origin, and everything form_update put into the form, to
a host of its choosing. Resolution does not constrain the destination. If
submissions must stay within known hosts, pair the endpoint with capi_firewall’s
egress allow-list, and let the list cover the resolved action host, not only the
page host you fetched from.
Redirects and logins
The flow follows no redirect of its own. A 3xx on the page fetch fails the query,
and api.follow_redirects(n) is the runner that re-describes the flow endpoint at
the new location and lets the decoder run there. A 302 in answer to the
submission arrives whole at decode_submission — body and head, Location
included — for the endpoint author to interpret; nothing follows it. A
config-armed cookie jar still captures the Set-Cookie on that unfollowed hop,
because the submission runs a full traversal and the jar’s response half runs at
its phase whether or not anyone follows.
That is also the shape of a login form. A login flow that answers with a redirect
belongs inside the client library, as an authenticate(..) method that drives
api.follow_redirects(n).query(login_flow) and lets the jar keep the session — the
cookie-login scheme in Auth Strategies is the version with
no redirect to follow.
Backends
Form parsing is platform-specific, and the framework hides it: off browser wasm the
crate links the standard library and parses with scraper/html5ever; on the
browser-wasm target it uses DOMParser, through JS bindings that ride the crate’s
std feature as its js capability. Your FormFlow code is identical across both —
the compilation target picks the backend, as Features and
Targets describes.
Next: Clients, Lanes, and the Request Budget — the ApiClient
abstraction the form flow (and every endpoint) sends through, and the adapters that
implement it.