howdy/form

HTML forms, submitted as application/x-www-form-urlencoded or as multipart/form-data, the encoding a form with a file input needs.

The body can be read only once, so read turns it into a Form and every field is then taken from that value. Field problems are returned as validate errors rather than responses, so a page can be rendered again with its errors and the values the user typed.

A local variable named form would shadow this module, so call the value something else, such as fields.

fn signup(fields: Form) -> validate.Result(Signup) {
  use email <- form.string(fields, "email", [validate.email()])
  use age <- form.int(fields, "age", [validate.min(13)])
  use newsletter <- form.checkbox(fields, "newsletter")
  validate.ok(Signup(email:, age:, newsletter:))
}

fn create(ctx: Context) {
  use fields <- form.read(ctx)
  case signup(fields) {
    Ok(signup) -> ...
    Error(errors) ->
      controller.html(ctx, signup_page(fields, errors))
      |> controller.with_status(422)
  }
}

Browsers submit an empty input as name=. string keeps the empty string, so pair it with validate.not_empty(); int treats it as missing; the optional_* helpers treat it as None. A singular field submitted more than once is an error. Any other content type gets a 415.

Files

A form takes no files unless its limits accept them, so an endpoint that does not expect uploads cannot be made to write them to disk. Each file is written to a temporary file as it arrives rather than held in memory, and deleted once the continuation returns, so store it or move it before then.

fn change_avatar(ctx: Context) {
  let limits =
    form.limits() |> form.accept_files(max_files: 1, max_file_size: 5_000_000)
  use fields <- form.read_with(ctx, limits)
  let avatar = {
    use avatar <- form.file(fields, "avatar", [
      form.file_type(["image/png", "image/jpeg"]),
    ])
    validate.ok(avatar)
  }
  case avatar {
    Ok(avatar) -> ... // copy avatar.path somewhere lasting
    Error(errors) -> ...
  }
}

The file name and content type are what the browser claimed. Check the content itself before relying on either, and never use the name as a path. A file input left empty is not submitted as a file.

Form posts are not subject to CORS preflight, so cookie-authenticated forms need CSRF protection. Use howdy/csrf, or the Origin check howdy_auth already applies to routes behind its guard.

Types

The fields of a submitted form, in the order they were sent.

pub opaque type Form

How much a form may carry. Build them from limits.

pub opaque type Limits

A file submitted with a form, written to a temporary file.

pub type UploadedFile {
  UploadedFile(
    file_name: String,
    content_type: String,
    size: Int,
    path: String,
  )
}

Constructors

  • UploadedFile(
      file_name: String,
      content_type: String,
      size: Int,
      path: String,
    )

    Arguments

    file_name

    The name the browser gave, without any directories. Do not use it as a path.

    content_type

    The content type the browser claimed, or application/octet-stream.

    size

    The size in bytes.

    path

    Where the file is. It is deleted once the continuation given to read_with returns.

Values

pub fn accept_files(
  limits: Limits,
  max_files max_files: Int,
  max_file_size max_file_size: Int,
) -> Limits

Accept up to max_files files of up to max_file_size bytes each. A larger file, or one too many, fails the whole form with a 400. Check smaller limits with validation rules such as max_size instead, to show the user an error beside the field.

pub fn all(form: Form, name: String) -> List(String)

Every value submitted for name, in order. An absent field gives [].

pub fn all_files(form: Form, name: String) -> List(UploadedFile)

Every file submitted for name, in order. An absent field gives [].

pub fn checkbox(
  form: Form,
  name: String,
  next: fn(Bool) -> Result(b, List(service.FieldError)),
) -> Result(b, List(service.FieldError))

Whether a checkbox was ticked. Browsers leave unticked checkboxes out of the submission, so this is True when the field is present at all.

pub fn error(
  errors: List(service.FieldError),
  name: String,
) -> option.Option(problem.Problem)

The problem with name among validation errors, if it has one. Put it into words with problem.message.

pub fn fields(form: Form) -> List(#(String, String))

Every field but the files, in the order submitted.

pub fn file(
  form: Form,
  name: String,
  rules: List(
    fn(UploadedFile) -> Result(UploadedFile, problem.Problem),
  ),
  next: fn(UploadedFile) -> Result(b, List(service.FieldError)),
) -> Result(b, List(service.FieldError))

A required file, checked with rules.

pub fn file_type(
  allowed: List(String),
) -> fn(UploadedFile) -> Result(UploadedFile, problem.Problem)

A file whose claimed content type is one of allowed, such as "application/pdf", or matches a pattern such as "image/*". The type comes from the browser, so check the content too before relying on it.

pub fn files(
  form: Form,
  name: String,
  rules: List(
    fn(UploadedFile) -> Result(UploadedFile, problem.Problem),
  ),
  next: fn(List(UploadedFile)) -> Result(
    b,
    List(service.FieldError),
  ),
) -> Result(b, List(service.FieldError))

Every file of a field that may hold several, such as an input with multiple, each checked with rules.

pub fn from_fields(fields: List(#(String, String))) -> Form

A form made of fields, for calling a validator directly in a test.

pub fn get(form: Form, name: String) -> Result(String, Nil)

The first value submitted for name.

pub fn get_file(
  form: Form,
  name: String,
) -> Result(UploadedFile, Nil)

The first file submitted for name.

pub fn int(
  form: Form,
  name: String,
  rules: List(fn(Int) -> Result(Int, problem.Problem)),
  next: fn(Int) -> Result(b, List(service.FieldError)),
) -> Result(b, List(service.FieldError))

A required integer, checked with rules. An empty value is missing.

pub fn limits() -> Limits

The limits read uses: up to body.default_limit bytes of fields, and no files.

pub fn max_fields_size(limits: Limits, bytes: Int) -> Limits

The most bytes of everything but file contents: the whole body of a urlencoded form, or the fields, part headers and boundaries of a multipart one.

pub fn max_size(
  bytes: Int,
) -> fn(UploadedFile) -> Result(UploadedFile, problem.Problem)

A file of at most bytes bytes.

pub fn max_total_file_size(limits: Limits, bytes: Int) -> Limits

The most bytes all the files together may have. By default it is max_files times max_file_size.

pub fn optional_file(
  form: Form,
  name: String,
  rules: List(
    fn(UploadedFile) -> Result(UploadedFile, problem.Problem),
  ),
  next: fn(option.Option(UploadedFile)) -> Result(
    b,
    List(service.FieldError),
  ),
) -> Result(b, List(service.FieldError))

An optional file, checked with rules when given.

pub fn optional_int(
  form: Form,
  name: String,
  rules: List(fn(Int) -> Result(Int, problem.Problem)),
  next: fn(option.Option(Int)) -> Result(
    b,
    List(service.FieldError),
  ),
) -> Result(b, List(service.FieldError))

An optional integer, checked with rules when given. An absent or empty value is None.

pub fn optional_string(
  form: Form,
  name: String,
  rules: List(fn(String) -> Result(String, problem.Problem)),
  next: fn(option.Option(String)) -> Result(
    b,
    List(service.FieldError),
  ),
) -> Result(b, List(service.FieldError))

An optional string, checked with rules when given. An absent or empty value is None, as is one that rules leave empty, such as by trimming.

pub fn read(
  ctx: context.Context(guarded),
  next: fn(Form) -> response.Response(content.Content),
) -> response.Response(content.Content)

Read the request body as a form, with the default limits: no files. A wrong content type returns 415; a body that is too large or badly encoded returns 400. The continuation never runs in either case.

pub fn read_with(
  ctx: context.Context(guarded),
  limits: Limits,
  next: fn(Form) -> response.Response(content.Content),
) -> response.Response(content.Content)

Like read but with the limits given, such as to accept files. Any uploaded files are deleted once next returns, even if it crashes.

pub fn read_with_limit(
  ctx: context.Context(guarded),
  limit: Int,
  next: fn(Form) -> response.Response(content.Content),
) -> response.Response(content.Content)

Like read but with a custom size limit in bytes.

pub fn string(
  form: Form,
  name: String,
  rules: List(fn(String) -> Result(String, problem.Problem)),
  next: fn(String) -> Result(b, List(service.FieldError)),
) -> Result(b, List(service.FieldError))

A required string, checked with rules. An empty value is kept.

pub fn strings(
  form: Form,
  name: String,
  next: fn(List(String)) -> Result(b, List(service.FieldError)),
) -> Result(b, List(service.FieldError))

Every value of a field that may repeat, such as a multi-select.

pub fn temporary_directory(
  limits: Limits,
  directory: String,
) -> Limits

Where uploaded files are written while a request uses them. By default the directory named by TMPDIR, TMP or TEMP, else /tmp. It is created if missing.

pub fn uploaded_files(
  form: Form,
) -> List(#(String, UploadedFile))

Every file with the name of its field, in the order submitted.

pub fn validated(
  ctx: context.Context(guarded),
  validator: fn(Form) -> Result(a, List(service.FieldError)),
  next: fn(a) -> response.Response(content.Content),
) -> response.Response(content.Content)

Read a form and validate it, for endpoints that answer with JSON. A validation failure returns 422 with every field error, as body.validated does. Pages that render their errors should use read.

pub fn validated_with(
  ctx: context.Context(guarded),
  limits: Limits,
  validator: fn(Form) -> Result(a, List(service.FieldError)),
  next: fn(a) -> response.Response(content.Content),
) -> response.Response(content.Content)

Like validated but with the limits given, such as to accept files.

pub fn value(form: Form, name: String) -> String

The first value submitted for name, or "". Use it to fill an input back in when rendering a form again. Escape it like any other user input.

pub fn with_files(
  form: Form,
  files: List(#(String, UploadedFile)),
) -> Form

Add files to a form, for calling a validator directly in a test.

✨ Search Document