Elm-inspired decoders for OCaml

Matt Bray 62b2b2981a Merge pull request #58 from mattjbray/dependabot/npm_and_yarn/json5-2.2.3 5 months ago
.github 45d81a9d2d use node 16x for github actions 10 months ago
__tests__ 1f80c21f69 fix(bs_xml): skip comment nodes 9 months ago
src f6eecb3131 feat: int and bool decoders 6 months ago
src-bencode 27a4b4f8b1 clean: make format 6 months ago
src-bs f6eecb3131 feat: int and bool decoders 6 months ago
src-cbor 27a4b4f8b1 clean: make format 6 months ago
src-ezjsonm 19429aa913 chore: rename modules 11 months ago
src-ezxmlm f6eecb3131 feat: int and bool decoders 6 months ago
src-jsonaf 27a4b4f8b1 clean: make format 6 months ago
src-jsonm 0050f4fb24 clean: move tests to top level 2 years ago
src-msgpck 27a4b4f8b1 clean: make format 6 months ago
src-ocyaml 19429aa913 chore: rename modules 11 months ago
src-sexplib 19429aa913 chore: rename modules 11 months ago
src-yojson 27a4b4f8b1 clean: make format 6 months ago
test-bencode cf2dc2d15d use ounit2 consistently 11 months ago
test-cbor cf2dc2d15d use ounit2 consistently 11 months ago
test-ezjsonm 4b3d601109 clean: unused type 10 months ago
test-ezxmlm f6eecb3131 feat: int and bool decoders 6 months ago
test-jsonaf 1d77000487 chore: ounit2 10 months ago
test-jsonm cf2dc2d15d use ounit2 consistently 11 months ago
test-msgpck cf2dc2d15d use ounit2 consistently 11 months ago
test-sexplib cf2dc2d15d use ounit2 consistently 11 months ago
test-yojson cf2dc2d15d use ounit2 consistently 11 months ago
.gitignore 32aa29da31 chore: gitignore .DS_Store 11 months ago
.ocamlformat 84005bf7e8 bump ocamlformat version 11 months ago
CHANGES.md 0e0afb7e98 release 10 months ago
LICENSE 39e4be8f16 chore: release 3 years ago
Makefile 11a705236b feat(make): watch tasks 11 months ago
README.md e4bc4cf950 doc: fix link 11 months ago
bsconfig.json 616f55d2e2 namespace under decoders 11 months ago
decoders-bencode.opam a71e98922b regen opam files after dune lang change 11 months ago
decoders-cbor.opam a71e98922b regen opam files after dune lang change 11 months ago
decoders-ezjsonm.opam a71e98922b regen opam files after dune lang change 11 months ago
decoders-ezxmlm.opam 042567f397 chore(opam): lower bound for ezxmlm 10 months ago
decoders-jsonaf.opam 1d77000487 chore: ounit2 10 months ago
decoders-jsonm.opam a71e98922b regen opam files after dune lang change 11 months ago
decoders-msgpck.opam a71e98922b regen opam files after dune lang change 11 months ago
decoders-msgpck.opam.template af7643b2c8 chore(opam): only run decoders-msgpck tests on ocaml >= 4.08 1 year ago
decoders-sexplib.opam a71e98922b regen opam files after dune lang change 11 months ago
decoders-yojson.opam a71e98922b regen opam files after dune lang change 11 months ago
decoders.opam a71e98922b regen opam files after dune lang change 11 months ago
dune 2d317bdf22 clean: ocamlformat 2 years ago
dune-project 042567f397 chore(opam): lower bound for ezxmlm 10 months ago
package-lock.json 5fc6c0f922 chore(deps): bump json5 from 2.2.1 to 2.2.3 5 months ago
package.json cb6707ee10 1.0.0 10 months ago

README.md

ocaml-decoders: Elm-inspired decoders for OCaml

A combinator library for "decoding" JSON-like values into your own OCaml types, inspired by Elm's Json.Decode and Json.Encode.

Eh?

An OCaml program having a JSON (or YAML) data source usually goes something like this:

  1. Get your data from somewhere. Now you have a string.
  2. Parse the string as JSON (or YAML). Now you have a Yojson.Basic.t, or maybe an Ezjsonm.value.
  3. Decode the JSON value to an OCaml type that's actually useful for your program's domain.

This library helps with step 3.

Getting started

Install one of the supported decoder backends:

For ocaml

opam install decoders-bencode      # For bencode
opam install decoders-cbor         # For CBOR
opam install decoders-ezjsonm      # For ezjsonm
opam install decoders-jsonm        # For jsonm
opam install decoders-msgpck       # For msgpck
opam install decoders-sexplib      # For sexplib
opam install decoders-yojson       # For yojson

For bucklescript

npm install --save-dev bs-decoders

Decoding

Now we can start decoding stuff!

First, a module alias to save some keystrokes. In this guide, we'll parse JSON using Yojson's Basic variant.

utop # module D = Decoders_yojson.Basic.Decode;;
module D = Decoders_yojson.Basic.Decode

Let's set our sights high and decode an integer.

utop # D.decode_value D.int (`Int 1);;
- : (int, error) result = Ok 1

Nice! We used decode_value, which takes a decoder and a value (in this case a Yojson.Basic.t) and... decodes the value.

utop # D.decode_value;;
- : 'a decoder -> value -> ('a, error) result = <fun>

For convenience we also have decode_string, which takes a string and calls Yojson's parser under the hood.

utop # D.decode_string D.int "1";;
- : (int, error) result = Ok 1

What about a list of ints? Here's where the "combinator" part comes in.

utop # D.decode_string D.(list int) "[1,2,3]";;
- : (int list, error) result = Ok [1; 2; 3]

Success!

Ok, so what if we get some unexpected JSON?

utop # #install_printer D.pp_error;;
utop # D.decode_string D.(list int) "[1,2,true]";;
- : (int list, error) result =
Error while decoding a list: element 2: Expected an int, but got true

Complicated JSON structure

To decode a JSON object with many fields, we can use the let-binding operators (let*, etc.) from the Infix module.

type my_user =
  { name : string
  ; age : int
  }

let my_user_decoder : my_user decoder =
  let open D in
  let* name = field "name" string in
  let* age = field "age" int in
  succeed { name; age }

Note for Bucklescript users: let-binding operators are not currently available in Bucklescript, so if you need your decoders to be compatible with Bucklescript you can use the monadic bind operator (>>=):

> let my_user_decoder : my_user decoder =
>   let open D in
>   field "name" string >>= fun name ->
>   field "age" int >>= fun age ->
>   succeed { name; age }
> ```

We can also use these operators to decode objects with inconsistent structure. Say, for
example, our JSON is a list of shapes. Squares have a side length, circles have
a radius, and triangles have a base and a height.

```json
[{ "shape": "square", "side": 11 },
 { "shape": "circle", "radius": 5 },
 { "shape": "triange", "base": 3, "height": 7 }]

We could represent these types in OCaml and decode them like this:

type square = { side : int }

type circle = { radius : int }

type triangle = { base : int; height : int }

type shape =
  | Square of square
  | Circle of circle
  | Triangle of triangle

let square_decoder : square decoder =
  D.(let+ s = field "side" int in { side = s })

let circle_decoder : circle decoder =
  D.(let+ r = field "radius" int in { radius = r })

let triangle_decoder : triangle decoder =
  D.(
    let* b = field "base" int in
    let+ h = field "height" int in
    { base = b; height = h })

let shape_decoder : shape decoder =
  let open D in
  let* shape = field "shape" string in
  match shape with
  | "square" -> let+ s = square_decoder in Square s
  | "circle" -> let+ c = circle_decoder in Circle c
  | "triangle" -> let+ t = triangle_decoder in Triangle t
  | _ -> fail "Expected a shape"


let decode_list (json_string : string) : (shape list, _) result =
  D.(decode_string (list shape_decoder) json_string)

Now, say that we didn't have the benefit of the "shape" field describing the type of the shape in our JSON list. We can still decode the shapes by trying each decoder in turn using the one_of combinator.

one_of takes a list of string * 'a decoder pairs and tries each decoder in turn. The string element of each pair is just used to name the decoder in error messages.

let shape_decoder_2 : shape decoder =
  D.(
    one_of
      [ ("a square", let+ s = square_decoder in Square s)
      ; ("a circle", let+ c = circle_decoder in Circle c)
      ; ("a triangle", let+ t = triangle_decoder in Triangle t)
      ]
  )

Generic decoders

Suppose our program deals with users and roles. We want to decode our JSON input into these types.

type role = Admin | User

type user =
  { name : string
  ; roles : role list
  }

Let's define our decoders. We'll write a module functor so we can re-use the same decoders across different JSON libraries, with YAML input, or with Bucklescript.

module My_decoders(D : Decoders.Decode.S) = struct
  open D

  let role : role decoder =
    string >>= function
    | "ADMIN" -> succeed Admin
    | "USER" -> succeed User
    | _ -> fail "Expected a role"

  let user : user decoder =
    let* name = field "name" string in
    let* roles = field "roles" (list role) in
    succeed { name; roles }
end

module My_yojson_decoders = My_decoders(Decoders_yojson.Basic.Decode)

Great! Let's try them out.

utop # open My_yojson_decoders;;
utop # D.decode_string role {| "USER" |};;
- : (role, error) result = Ok User

utop # D.decode_string D.(field "users" (list user))
         {| {"users": [{"name": "Alice", "roles": ["ADMIN", "USER"]},
                       {"name": "Bob", "roles": ["USER"]}]}
          |};;
- : (user list, error) result =
Ok [{name = "Alice"; roles = [Admin; User]}; {name = "Bob"; roles = [User]}]

Let's introduce an error in the JSON:

utop # D.decode_string D.(field "users" (list user))
         {| {"users": [{"name": "Alice", "roles": ["ADMIN", "USER"]},
                       {"name": "Bob", "roles": ["SUPER_USER"]}]}
          |};;
- : (user list, error) result =
Error
 in field "users":
   while decoding a list:
     element 1:
       in field "roles":
         while decoding a list:
           element 0: Expected a role, but got "SUPER_USER"

We get a nice pointer that we forgot to handle the SUPER_USER role.

Encoding

ocaml-decoders also has support for defining backend-agnostic encoders, for turning your OCaml values into JSON values.

module My_encoders(E : Decoders.Encode.S) = struct
  open E

  let role : role encoder =
    function
    | Admin -> string "ADMIN"
    | User -> string "USER"

  let user : user encoder =
    fun u ->
      obj
        [ ("name", string u.name)
        ; ("roles", list role u.roles)
        ]
end

module My_yojson_encoders = My_encoders(Decoders_yojson.Basic.Encode)
utop # module E = Decoders_yojson.Basic.Encode;;
utop # open My_yojson_encoders;;
utop # let users =
  [ {name = "Alice"; roles = [Admin; User]}
  ; {name = "Bob"; roles = [User]}
  ];;
utop # E.encode_string E.obj [("users", E.list user users)];;
- : string =
"{\"users\":[{\"name\":\"Alice\",\"roles\":[\"ADMIN\",\"USER\"]},{\"name\":\"Bob\",\"roles\":[\"USER\"]}]}"

API Documentation

For more details, see the API documentation:

Decoding XML

A similar decoders interface exists for decoding XML. See the interface file src/xml.ml for documentation.

XML implementations

Platform Package Module Example usage
opam decoders-ezxmlm Decoders_ezxmlm.Decode src-ezxmlm/test/test_ezxmlm_decode.ml
npm bs-decoders Decoders.Bs_xml.Decode __tests__/decoders_bs_xml_test.ml

Release

After updating CHANGES.md:

npm version <newversion> # e.g. npm version 0.7.0
git push --tags
dune-release
npm publish