Call a JSON API and parse the response

Make an HTTP request, parse the JSON body, and pull typed fields out of it — turning an untyped response into a value your program can trust. Uses HttpClient, Http, and Json. (The HTTP recipe covers building requests and servers; this is the missing half: decoding a response.)


The shape of a JSON value

Json.parse returns Result(JsonValue, String), where JsonValue is an ADT:

type JsonValue =
    Null
  | Bool(Bool)
  | Number(Float)
  | Str(String)
  | Array(List(JsonValue))
  | Object(List((String, JsonValue)))

You decode by pattern-matching this ADT — there’s no reflection, so a malformed or wrong-typed field is a branch you handle, not a crash.


Fetch and decode

HttpClient.get(client, url) returns Result(Response, _); Http.response_body extracts the body string; Json.get(jv, key) returns Option(JsonValue) for a field. Chaining the matches turns two failure sources (the request and the parse) into one Result:

mod Weather do

  type Report = Report(String, Float)   -- city name, temperature

  fn fetch(city : String) : Result(Report, String) do
    let client = HttpClient.new_client()
    match HttpClient.get(client, "https://example.com/weather?city=" ++ city) do
      Err(_)   -> Err("request failed")
      Ok(resp) ->
        match Json.parse(Http.response_body(resp)) do
          Err(e) -> Err("bad JSON: " ++ e)
          Ok(jv) ->
            -- pull two fields and check their shapes in one match
            match (Json.get(jv, "name"), Json.get(jv, "temp")) do
              (Some(JsonValue.Str(name)), Some(JsonValue.Number(t))) ->
                Ok(Report(name, t))
              _ ->
                Err("missing or wrong-typed fields")
            end
        end
    end
  end

end

Matching the tuple (Json.get(…), Json.get(…)) checks both fields at once: the success arm fires only when name is a string and temp is a number; anything else falls through to the error arm. Constructors like Str/Number exist in more than one type, so qualify them as JsonValue.Str / JsonValue.Number to disambiguate.


Nested fields

For values buried in nested objects, Json.get_in(jv, ["main", "temp"]) walks a key path; Json.get_at(jv, i) indexes into an array. Both return Option(JsonValue), so they compose with the same matching style:

match Json.get_in(jv, ["main", "temp"]) do
  Some(JsonValue.Number(t)) -> Ok(t)
  _                         -> Err("no main.temp")
end

Try it

Json.parse works on any string, so you can exercise the accessors without a network call:

match Json.parse("{\"name\": \"Paris\", \"temp\": 14.5}") do
  Ok(jv) -> Json.get(jv, "name")
  Err(_) -> None
end

This returns Some(JsonValue.Str("Paris")).


See also

march — interactive
Click run on any snippet to try it here.
march>