Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

  1. 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 into unexpected_status_with_headers, Location included. (A page that redirects is api.follow_redirects(n).query(flow)’s job; see below.)
  2. Extract the form by the endpoint’s form_selector()FormSelector::id("edit-profile"), ::name("login"), or ::css("form.login").
  3. Apply the updates the endpoint’s form_update() returns to the form’s fields; a field the form does not have is a FormError.
  4. Resolve the action the way a browser would: a relative action against page_url(), an empty or missing one to the page itself, an absolute one as-is.
  5. Submit — a follow-up exchange through the door, built as a SubmissionEndpoint over the flow endpoint.
  6. 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 HasUrlEncodedLiburl_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.