DEV Community

Yawar Amin
Yawar Amin

Posted on

Powerful form validation with OCaml's Dream framework

NOTE: Dream_html.form and query helper functions are unreleased. They will be published to opam in the next release. Everything else is available now!

I'VE been a long-time proponent of the power of HTML forms and how natural they make it to build web pages that allow users to input data. Recently, I got a chance to refurbish an old internal application we use at work using htmx and Scala's Play Framework. In this app we often use HTML forms to submit information. For example:

<form id=new-user-form method=post action=/users hx-post=/users>
  <input name=name required>
  <input type=email name=email required>
  <input type=checkbox name=accept-terms value=true>
  <button type=submit>Add User</button>
</form>
Enter fullscreen mode Exit fullscreen mode

Play has great support for decoding submitted HTML form data into values of custom types and reporting all validation errors that may have occurred, which allowed me to render the errors directly alongside the forms themselves using the Constraint Validation API. I wrote about that in a previous post.

But in the OCaml world, the situation was not that advanced. We had some basic tools for parsing form data into a key-value pair list:

But if we wanted to decode this list into a custom type, we'd need something more sophisticated, like maybe conformist. However, conformist has the issue that it reports only one error at a time. Actually, conformist has a separate validate function that can report all errors together!

If we have to decode a form submission like:

accept-terms: true
Enter fullscreen mode Exit fullscreen mode

We would want to see a list of validation errors, like this:

name: required
email: required
Enter fullscreen mode Exit fullscreen mode

dream-html form validation

Long story short, I decided we needed a more ergonomic form validation experience in OCaml. And since I already maintain a package which adds type-safe helpers on top of the Dream web framework, I thought it would make a good addition. Let's take a it for a spin in the REPL:

$ utop -require dream-html
# type add_user = {
    name : string;
    email : string;
    accept_terms : bool;
  };;

# let add_user =
    let open Dream_html.Form in
    let+ name = required string "name"
    and+ email = required string "email"
    and+ accept_terms = optional bool "accept-terms" in
    {
      name;
      email;
      accept_terms = Option.value accept_terms ~default:false;
    };;
Enter fullscreen mode Exit fullscreen mode

Now we have a value add_user : add_user Dream_html.Form.t, which is a decoder to our custom type. Let's try it:

# Dream_html.Form.validate add_user [];;
- : (add_user, (string * string) list) result =
Error [("email", "error.required"); ("name", "error.required")]
Enter fullscreen mode Exit fullscreen mode

We get back a list of form validation errors, with field names and error message keys (this allows localizing the app).

Let's try a successful decode:

# Dream_html.Form.validate add_user ["name", "Bob"; "email", "bob@example.com"];;
- : (add_user, (string * string) list) result =
Ok {name = "Bob"; email = "bob@example.com"; accept_terms = false}

# Dream_html.Form.validate add_user ["name", "Bob"; "email", "bob@example.com"; "accept-terms", "true"];;
- : (add_user, (string * string) list) result =
Ok {name = "Bob"; email = "bob@example.com"; accept_terms = true}
Enter fullscreen mode Exit fullscreen mode

Let's check for a type error:

# Dream_html.Form.validate add_user ["name", "Bob"; "email", "bob@example.com"; "accept-terms", "1"];;
- : (add_user, (string * string) list) result =
Error [("accept-terms", "error.expected.bool")]
Enter fullscreen mode Exit fullscreen mode

It wants a bool, ie only true or false values. You can make sure your checkboxes always send true on submission by setting value=true.

Custom value decoders

You can decode custom data too. Eg suppose your form has inputs that are supposed to be decimal numbers:

<input name=height-m required>
Enter fullscreen mode Exit fullscreen mode

You can write a custom data decoder that can parse a decimal number:

# #require "decimal";;
# let decimal s =
    try Ok (Decimal.of_string s)
    with Invalid_argument _ -> Error "error.expected.decimal";;
Enter fullscreen mode Exit fullscreen mode

Now we can use it:

let+ height_m = required decimal "height-m"
...
Enter fullscreen mode Exit fullscreen mode

Adding constraints to the values

You can add further constraints to values that you decode. Eg, in most form submissions it doesn't make sense for any strings to be empty. So let's define a helper that constrains strings to be non-empty:

let nonempty =
  ensure "expected.nonempty" (( <> ) "") required string
Enter fullscreen mode Exit fullscreen mode

Now we can write the earlier form definition with stronger constraints for the strings:

let add_user =
  let open Dream_html.Form in
  let+ name = nonempty "name"
  and+ email = nonempty "email"
  and+ accept_terms = optional bool "accept-terms" in
  {
    name;
    email;
    accept_terms = Option.value accept_terms ~default:false;
  }
Enter fullscreen mode Exit fullscreen mode

Validating forms in Dream handlers

In a Dream application, the built-in form handling would look something like this:

(* POST /users *)
let post_users request =
  match%lwt Dream.form request with
  | `Ok ["accept-terms", accept_terms; "email", email; "name", name] ->
    (* ...success... *)
  | _ -> Dream.empty `Bad_Request
Enter fullscreen mode Exit fullscreen mode

But with our form validation abilities, we can do something more:

(* POST /users *)
let post_users request =
  match%lwt Dream_html.form add_user request with
  | `Ok { name; email; accept_terms } ->
    (* ...success... *)
  | `Invalid errors ->
    Dream.json ~code:422 ( (* ...turn the error list into a JSON object... *) )
  | _ -> Dream.empty `Bad_Request
Enter fullscreen mode Exit fullscreen mode

Decoding variant type values

Of course, variant types are a big part of programming in OCaml, so you might want to decode a form submission into a value of a variant type. Eg,

type user =
| Logged_out
| Logged_in of { admin : bool }
Enter fullscreen mode Exit fullscreen mode

You could have a form submission that looked like this:

type: logged-out
Enter fullscreen mode Exit fullscreen mode

Or:

type: logged-in
admin: true
Enter fullscreen mode Exit fullscreen mode

Etc.

To decode this kind of submission, you can break it down into decoders for each case, then join them together with Dream_html.Form.( or ), eg:

let logged_out =
  let+ _ = ensure "expected.type" (( = ) "logged-out") required string "type" in
  Logged_out

let logged_in =
  let+ _ = ensure "expected.type" (( = ) "logged-in") required string "type"
  and+ admin = required bool "admin" in
  Logged_in { admin }

let user = logged_out or logged_in
Enter fullscreen mode Exit fullscreen mode

Let's try it:

# validate user [];;
- : (user, (string * string) list) result =
Error [("admin", "error.required"); ("type", "error.required")]

# validate user ["type", "logged-out"];;
- : (user, (string * string) list) result = Ok Logged_out

# validate user ["type", "logged-in"];;
- : (user, (string * string) list) result = Error [("admin", "error.required")]

# validate user ["type", "logged-in"; "admin", ""];;
- : (user, (string * string) list) result =
Error [("admin", "error.expected.bool")]

# validate user ["type", "logged-in"; "admin", "true"];;
- : (user, (string * string) list) result = Ok (Logged_in { admin = true })
Enter fullscreen mode Exit fullscreen mode

As you can see, the decoder can handle either case and all the requirements therein.

Decoding queries into custom types

The decoding functionality works not just with 'forms' but also with queries eg /foo?a=1&b=2. Of course, here we are using 'forms' as a shorthand for the application/x-www-form-urlencoded data that is submitted with a POST request, but actually an HTML form that has action=get submits its input data as a query, part of the URL, not as form data. A little confusing, but the key thing to remember is that Dream can work with both, and so can dream-html.

In Dream, you can get query data using functions like let a = Dream.query request "a". But if you are submitting more sophisticated data via the query, you can decode them into a custom type using the above form decoding functionality. Eg suppose you want to decode UTM parameters into a custom type:

type utm = {
  source : string option;
  medium : string option;
  campaign : string option;
  term : string option;
  content : string option;
}

let utm =
  let+ source = optional string "utm_source"
  and+ medium = optional string "utm_medium"
  and+ campaign = optional string "utm_campaign"
  and+ term = optional string "utm_term"
  and+ content = optional string "utm_content" in
  { source; medium; campaign; term; content }
Enter fullscreen mode Exit fullscreen mode

Now, you can use this very similarly to a POST form submission:

let some_page request =
  match Dream_html.query utm request with
  | `Ok { source; medium; campaign; term; content } ->
    (* ...success... *)
  | `Invalid errors -> (* ...handle errors... *)
Enter fullscreen mode Exit fullscreen mode

And the cool thing is, since they are literally the same form definition, you can switch back and forth between making your request handle POST form data or GET query parameters, with very few changes.

So...

Whew. That was a lot. And I didn't really dig into the even more advanced use cases. But hopefully at this point you might be convinced that forms and queries are now easy to handle in Dream. Of course, you might not really need all this power. For simple use cases, you can probably get away with Dream's built-in capabilities. But for larger apps that maybe need to handle a lot of forms, I think it can be useful.

Top comments (0)