Homepage for the changeset
library
Data validation with first-class and first-order labels in OCaml.
Library documentation can be found here.
This library takes its inspiration from the Ecto.Changeset module in the Elixir ecosystem.
Getting started
Changesets and data validation. Changesets are data structures well suited to validate data while accumulating errors along the way. They are composed of:
- Changes: an heterogeneous map whose keys are of type
'a label
and values of type'a
. When using the deriving ppx plugin on a record type definition, the labels correspond to the fields of this one. - Errors: a list associating labels and strings, the last ones represent error messages.
Labels are first-class, that means you can give them as argument to functions, and first-order, so you can pattern match against them. The library promotes the pipeline design approach.
There is a ppx deriving
plugin so there is no need to write any boilerplate code.
Install using opam
$ opam pin add changeset_lib https://github.com/phink/changeset.git
$ opam pin add ppx_changeset https://github.com/phink/changeset.git
Update the libraries and preprocess section in your jbuild file:
(libraries (... changeset))
(preprocess (pps (... ppx_changeset)))
Example
Let us illustrate the usage of the library with a first example.
Type definition and derivation
We start by defining a type t
that would represent the incoming data.
type t = {
age: int;
phone: string;
password: string;
} [@@deriving changeset]
The [@@deriving changeset]
ppx annotation will generate a GADT definition whose constructors correspond to the fields of the original record type t
. The parameter type variable of label
aims to represent the corresponding type of the label in the record definition. In our example we have the following generated definition.
type 'a label =
| Age : int label
| Phone : string label
| Password : string label
It will also generate a module named Changeset
of type Changeset_lib.S
that provides, among others, validation functions for the type t
.
The workflow
We start by defining a function validate
that takes a changeset cset
, applies the desired validations, and returns the changeset potentially augmented with new errors returned by the validation functions.
let validate cset : Changeset.t =
cset
|> Changeset.validate_int Age [`greater_than_or_equal_to 0]
|> Changeset.validate_string_length Password [`min 12]
|> Changeset.validate_format Phone (Str.regexp "^\\+?[1-9][0-9]+$")
The function validate
takes a term of type t
, creates a changeset in which all the elements in t
are loaded as changes using the Changeset.from_record
function and calls validate
.
let create t : (t, Changeset.t) Base.Result.t =
t
|> Changeset.from_record
|> validate
|> Changeset.apply
If some of the validations were not respected, an error is registered and the changeset would now be considered as invalid.
The function Changeset.apply
returns a value of type (t, Changeset.t) Base.Result.t
. That is,
Ok x
if the changeset returned byvalidate
is valid.
Error cset
otherwise, wherecset
corresponds to the changeset returned byvalidate
if it was not valid.
Example of success and error reporting
let run data =
data
|> create
|> Changeset.apply
|> function
| Ok _t -> Stdio.print_endline "OK"
| Error cset -> Stdio.prerr_endline (Changeset.show_errors cset)
Example with valid data
let data : t = {
age = 10;
password = "somethinghardtohack";
phone = "+14155552671"
}
Outputs the following when executing run data
:
OK
Example with invalid data
let data : t = {
age = -1;
password = "camel<3";
phone = "+14155552671"
}
Outputs the following when executing run data
:
{
"errors":{
"password":"should be at least 12 character(s)";
"age":"must be greater than or equal to 0"
}
}
Define your own validation functions
One can easily create its own validation with validate_change
. For instance, we can create validate_one_uppercase
that verifies if the value bound to the label passed as argument contains at least one uppercase. Otherwise an error associating the corresponding label to the errom message "should contain at least one uppercase"
would be registered in changeset cset
.
let validate_one_uppercase label cset =
let message = "should contain at least one uppercase" in
let aux s =
if not Base.(String.exists s ~f:Char.is_uppercase)
then [message]
else []
in
Changeset.validate_change label aux cset
The original validate
definition should be extended as follows.
let validate cset =
cset
|> Changeset.validate_int Age [`greater_than_or_equal_to 0]
|> Changeset.validate_string_length Password [`min 12]
|> Changeset.validate_format Phone (Str.regexp "^\\+?[1-9][0-9]+$")
|> validate_one_uppercase Password
Dynamic side
Information coming from the outside world is not always complete and, as a consequence, we usually have to deal with partial data. Suppose we are implementing a strong password generator API and the only information provided are the phone number and the age. In json format it would be represented as:
{"age": 9; "phone": "+14155552671"}
The changes in a changeset correspond to an heterogeneous map with bindings for keys of type 'a label
to values of type 'a
. This structure allows us to incrementally build the changeset before calling Changeset.apply
.
Remember that the job of the API is to generate passwords. As a consequence, when creating a new changeset we also have to generate a value for the password. Our create
function is now defined as follows, assuming a function generate_strong_password
of type unit -> string
.
let create json =
let open Yojson.Basic.Util in
let age = json |> member "age" |> to_int in
let phone = json |> member "phone" |> to_string in
Changeset.[Age => age; Phone => phone]
|> Changeset.put_changes
|> Changeset.put_change Password (generate_strong_password ())
|> validate
|> Changeset.apply
Limitations
Parameterized source types are not yet supported.