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>
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
We would want to see a list of validation errors, like this:
name: required
email: required
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;
};;
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")]
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}
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")]
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>
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";;
Now we can use it:
let+ height_m = required decimal "height-m"
...
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
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;
}
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
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
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 }
You could have a form submission that looked like this:
type: logged-out
Or:
type: logged-in
admin: true
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
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 })
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 }
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... *)
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)