Forms
Most apps accept input in a request body: a JSON document from an API client or a script in the page, or the fields of an HTML form posted by the browser. Either way the work is the same three steps. Read the body, turn it into a typed value, and check that value before anything else touches it.
This page covers both kinds of body, then the validation they share. The running example is a signup with a name, an email address and an age:
pub type SignupInput {
SignupInput(name: String, email: String, age: Int)
}
JSON bodies
body.json reads the request body and decodes it with a decoder from
gleam/dynamic/decode. Like the functions in howdy/query, it is designed
for use: the rest of the handler only runs with a decoded value.
import gleam/dynamic/decode
import gleam/json
import howdy/body
import howdy/controller.{type Context}
pub fn signup_controller() {
controller.new("signups")
|> controller.post("/", create_signup)
}
fn signup_decoder() -> decode.Decoder(SignupInput) {
use name <- decode.field("name", decode.string)
use email <- decode.field("email", decode.string)
use age <- decode.field("age", decode.int)
decode.success(SignupInput(name:, email:, age:))
}
fn create_signup(ctx: Context) {
use input <- body.json(ctx, signup_decoder())
controller.json(ctx, json.object([#("name", json.string(input.name))]))
|> controller.with_status(201)
}
When the body cannot be decoded, the handler stops and the client gets a
400 with a JSON body that says why:
-
A body that is not JSON answers
{"error":"request body is not valid JSON"}. -
A value of the wrong type, such as
"age":"old", answers{"error":"invalid request body: age expected Int, found String"}. -
Missing fields are all reported together:
{"error":"invalid request body: email expected Field, found Nothing; age expected Field, found Nothing"}.
body.json reads at most body.default_limit bytes, which is one mebibyte.
A larger body answers 400 with {"error":"request body too large"}. Use
body.json_with_limit to pick a different limit for one route, such as a
bulk import:
use input <- body.json_with_limit(ctx, 10_000_000, import_decoder())
The content-type header is not checked, so a client that forgets to send
application/json is still understood.
HTML forms
A browser posting a <form> sends the fields as
application/x-www-form-urlencoded: the same name=Ada&age=36 format as a
query string, but in the body. howdy/form reads it. form.read is
designed for use like body.json, and hands the handler a Form:
import howdy/form
fn create_signup(ctx: Context) {
use fields <- form.read(ctx)
controller.text(ctx, "Hello, " <> form.value(fields, "name"))
}
A request body can only be read once, so read turns it into a value and
every field is then taken from that. Call the value something other than
form, such as fields: a local variable named form would shadow the
module.
The fields arrive already decoded, so
name=Ada+Lovelace&email=ada%40example.com gives "Ada Lovelace" and
"ada@example.com". When the body cannot be read, the handler stops and the
client gets an error:
-
A content type other than
application/x-www-form-urlencodedanswers415with{"error":"content type must be application/x-www-form-urlencoded"}. Unlikebody.json, the content type is checked. -
A form with a file input is sent as
multipart/form-data, which is not supported yet. It answers415with{"error":"multipart forms are not supported"}. -
A body with invalid encoding answers
400with{"error":"request body is not a valid urlencoded form"}. -
A body over
body.default_limitanswers400with{"error":"request body too large"}.form.read_with_limit(ctx, limit)picks a different limit for one route.
Typed fields
Every form value is a string, so a form needs one more step than JSON:
turning the strings into a typed input. howdy/form has a function for each
kind of field. Each takes the form, the field's name and a list of rules
from howdy/validate, which is covered below, and they
chain with use to build the value:
import howdy/form.{type Form}
import howdy/validate
fn signup_input_from_form(fields: Form) -> validate.Result(SignupInput) {
use name <- form.string(fields, "name", [])
use email <- form.string(fields, "email", [])
use age <- form.int(fields, "age", [])
validate.ok(SignupInput(name:, email:, age:))
}
-
stringandintrequire the field. One that is missing reportsis required, and anintthat is not a whole number reportsmust be an integer. -
optional_stringandoptional_intgive anOption, and only run their rules when there is a value. -
checkboxgives aBool. Browsers leave an unticked checkbox out of the submission entirely, so it isTruewhen the field is there at all. -
stringsgives every value of a field that may repeat, such as a multi-select or a group of checkboxes sharing a name, in the order sent.
Browsers submit an empty input as name=, not as a missing field, and the
helpers differ in what they make of it. string keeps the empty string, so
pair it with validate.not_empty(). int treats it as missing. The
optional_* helpers treat it as None, as optional_string does for a
value its rules leave empty, such as by trimming.
string, int and the optional_* helpers expect their field once. One
submitted twice reports must occur only once, so a request cannot slip a
second value past the one you checked.
For anything the helpers do not cover there is raw access. form.get gives
the first value of a field as a Result, form.all gives every value,
form.value gives the first value or "", and form.fields gives every
name and value pair in the order submitted.
Validation
Decoding proves the input has the right shape: age is an Int. It says
nothing about whether the values make sense: an age of -4 decodes
perfectly well. Checking that is the job of howdy/validate.
A validator is a function from the decoded input to a validate.Result.
Each field is passed through a list of rules, and the last line builds the
validated value:
import howdy/validate
pub type Signup {
Signup(name: String, email: String, age: Int)
}
fn validate_signup(input: SignupInput) -> validate.Result(Signup) {
use name <- validate.field("name", input.name, [
validate.trim(),
validate.not_empty(),
validate.max_length(50),
])
use email <- validate.field("email", input.email, [
validate.trim(),
validate.email(),
])
use age <- validate.field("age", input.age, [validate.min(13)])
validate.ok(Signup(name:, email:, age:))
}
Returning a second type is the point. A SignupInput is whatever the client
sent; a Signup can only be made by the validator. Write the rest of your
app to take a Signup and the compiler makes sure nothing unchecked gets
through.
Two things are worth knowing about how fields are checked:
-
Rules run in order and the first failure is that field's error, so a field
reports one problem at a time. Rules can also tidy a value:
trimnever fails, it hands the trimmed text to the rules after it, and the trimmed text is what you get back. Put it first. - Every field is checked even when an earlier one has failed, so the client hears about all the problems at once, not one per attempt.
The rules
-
trim()removes leading and trailing whitespace. It never fails. -
not_empty()rejects"". -
min_length(n)andmax_length(n)bound the length of a string, counted in characters. -
email()is a light check: something before a single@, and a dot in the part after it. Only sending a message proves an address is real. -
min(n)andmax(n)bound anInt. -
one_of(allowed, show)requires one of a list of values.showturns a value into text for the error message, so for strings pass a function that returns its argument. -
custom(check, message)builds a rule from your own test.
A custom rule is a function that returns True for a good value, plus the
message to use otherwise:
import gleam/string
fn no_spaces() -> validate.Rule(String) {
validate.custom(
fn(value) { !string.contains(value, " ") },
"must not contain spaces",
)
}
Messages read best when they finish the sentence "this field…", as the built-in ones do: "must not be empty", "must be at least 13".
Validating JSON
body.validated is body.json with a validator attached. It decodes, then
validates, and the handler only runs with the validated value:
fn create_signup(ctx: Context) {
use signup <- body.validated(ctx, signup_decoder(), validate_signup)
controller.json(ctx, json.object([#("name", json.string(signup.name))]))
|> controller.with_status(201)
}
Decode failures still answer 400. Validation failures answer 422 with
every field error:
{"error":"validation failed","fields":[{"field":"name","message":"must not be empty"},{"field":"email","message":"must be a valid email address"},{"field":"age","message":"must be at least 13"}]}
The two statuses mean different things to a client. A 400 says the request
was malformed, which is a bug in the client. A 422 says the request was
understood but the values were not acceptable, which is something to show
the person filling in the form. The fields list is made for that: match
each field to an input and show its message beside it.
If you have a value from somewhere other than a JSON body,
validate.check(ctx, result, next) does the same job for any
validate.Result.
Validating HTML forms
The field helpers take the same rules as validate.field, so a form does
not need a separate input type and validator. Give each field its rules and
build the validated value directly:
fn signup_from_form(fields: Form) -> validate.Result(Signup) {
use name <- form.string(fields, "name", [
validate.trim(),
validate.not_empty(),
validate.max_length(50),
])
use email <- form.string(fields, "email", [
validate.trim(),
validate.email(),
])
use age <- form.int(fields, "age", [validate.min(13)])
validate.ok(Signup(name:, email:, age:))
}
As with validate.field, every field is checked even when an earlier one
has failed, so all the errors come back together.
Field problems are returned as errors, not sent as responses, because a browser form wants a page back, not JSON. Call the validator yourself and decide what to render:
fn create_signup(ctx: Context) {
use fields <- form.read(ctx)
case signup_from_form(fields) {
Ok(signup) -> {
save(signup)
controller.status(ctx, 303)
|> response.set_header("location", "/welcome")
}
Error(errors) ->
controller.html(ctx, signup_page(fields, errors))
|> controller.with_status(422)
}
}
On success, redirect with 303. The browser follows it with a GET, so
refreshing the next page does not post the form a second time.
On failure, render the form again with status 422, passing in what the
visitor typed and the errors. Two functions do the lookups for one input:
form.value(fields, "email") is what was submitted, or "", and
form.error(errors, "email") is that field's message, if it has one.
import gleam/option.{None, Some}
import lustre/attribute
import lustre/element/html
fn email_input(fields: Form, errors: List(validate.FieldError)) {
html.label([], [
html.text("Email"),
html.input([
attribute.type_("email"),
attribute.name("email"),
attribute.value(form.value(fields, "email")),
]),
case form.error(errors, "email") {
Some(message) -> html.p([attribute.class("error")], [html.text(message)])
None -> html.text("")
},
])
}
Putting the typed values back into the form saves the visitor from starting
again. They are untrusted text, so build the page with something that
escapes them, such as Lustre's attribute.value, not by joining strings.
For a form endpoint that is not a page, such as one posted by a script,
form.validated(ctx, signup_from_form) is the form's body.validated: a
validation failure answers the same JSON 422, and the handler only runs
with the validated value.
If an API route and a browser form accept the same thing, keep the rules in
one place. Build a SignupInput from the form with no rules, then pass it
through the validator the JSON route uses:
signup_input_from_form(fields) |> result.try(validate_signup)
Cross-site requests
A form post is the one kind of write a page on another site can make a visitor's browser send to you, with their cookies attached and no CORS preflight in the way. If your forms act for a signed-in visitor, protect them with the CSRF middleware.
Testing forms
testing.post sends a JSON body, and testing.field_errors decodes a 422
response back into a list of field errors:
import gleam/json
import howdy/service.{FieldError}
import howdy/testing
pub fn signup_rejects_children_test() {
let res =
testing.post(
"/signups",
json.object([
#("name", json.string("Ada")),
#("email", json.string("ada@example.com")),
#("age", json.int(9)),
]),
)
|> testing.send(app())
assert res.status == 422
assert testing.field_errors(res)
== Ok([FieldError("age", "must be at least 13")])
}
For a 400, testing.error(res) gives you the message.
testing.post_form posts an HTML form as a browser would. It takes the
fields as name and value pairs, encodes them, and sets the content type:
pub fn signup_form_redirects_test() {
let res =
testing.post_form("/signups", [
#("name", "Ada Lovelace"),
#("email", "ada@example.com"),
#("age", "36"),
])
|> testing.send(app())
assert res.status == 303
}
Repeat a name to send a field more than once, and leave a checkbox out to
send it unticked. testing.form_body(pairs) sets the same body on a request
built another way, such as a PUT. If the app uses the
CSRF middleware, add an origin header as well, or
the post answers 403.
A validator is a plain function, so its rules can also be tested directly, with no request at all:
pub fn name_is_trimmed_test() {
let input = SignupInput(name: " Ada ", email: "ada@example.com", age: 36)
let assert Ok(signup) = validate_signup(input)
assert signup.name == "Ada"
}
The same goes for one that takes a Form. form.from_fields makes a form
from name and value pairs:
pub fn age_must_be_a_number_test() {
let fields =
form.from_fields([
#("name", "Ada"),
#("email", "ada@example.com"),
#("age", "old"),
])
assert signup_from_form(fields)
== Error([FieldError("age", "must be an integer")])
}
