HTTP API Client with JSON Decoding

The Problem

The Issue Type

i
pub type Issue {
  Issue(number: Int, title: String, state: String)
}

Writing a Decoder

i
pub fn issue_decoder() -> decode.Decoder(Issue) {
  use number <- decode.field("number", decode.int)
  use title <- decode.field("title", decode.string)
  use state <- decode.field("state", decode.string)
  decode.success(Issue(number, title, state))
}

Decoding a JSON String

i
pub fn decode_issues(json_str: String) -> Result(List(Issue), String) {
  case json.parse(json_str, decode.list(issue_decoder())) {
    Ok(issues) -> Ok(issues)
    Error(e) -> Error("decode failed: " <> string.inspect(e))
  }
}

Making HTTP Requests

i
fn fetch_issues(
  owner: String,
  repo: String,
) -> Result(List(Issue), String) {
  let url = "https://api.github.com/repos/" <> owner <> "/" <> repo <> "/issues"
  case httpc.get(url) {
    Error(e) -> Error("network error: " <> httpc.error_to_string(e))
    Ok(response) -> {
      case response.status {
        200 -> decode_issues(response.body)
        status ->
          Error("unexpected status: " <> int.to_string(status))
      }
    }
  }
}

Testing the Decoder

i
pub fn decode_one_issue_test() {
  let json_str = "{\"number\":1,\"title\":\"fix bug\",\"state\":\"open\"}"
  json.parse(json_str, issue_decoder())
  |> should.equal(Ok(Issue(1, "fix bug", "open")))
}

pub fn decode_list_test() {
  let json_str = "[{\"number\":1,\"title\":\"a\",\"state\":\"open\"}]"
  json.parse(json_str, decode.list(issue_decoder()))
  |> should.be_ok
  |> list.length
  |> should.equal(1)
}

pub fn missing_field_test() {
  let json_str = "{\"number\":1,\"title\":\"a\"}"
  json.parse(json_str, issue_decoder())
  |> should.be_error
}

pub fn wrong_type_test() {
  let json_str =
    "{\"number\":\"not an int\",\"title\":\"a\",\"state\":\"open\"}"
  json.parse(json_str, issue_decoder())
  |> should.be_error
}

Check Understanding

What is a Dynamic value?

Gleam is statically typed: every value has a known type at compile time. But JSON is dynamically typed: the structure of a JSON blob is not known until you parse it at runtime. decode.Decoder(t) is a description of how to extract a t from untyped data; it is not a function you call directly. json.parse(string, decoder) feeds the parsed JSON into the decoder and returns Ok(value) or Error(dynamic.DecodeErrors). Once the decoder succeeds, the value is fully typed and the compiler enforces it. This is similar to json.loads in Python, but with the parsing and type-checking fused into one step.

Why test the decoder with a hard-coded JSON string instead of making a real HTTP request?

Testing against a live network introduces failures that have nothing to do with your code: the server might be down, rate-limit you, or return different data each time. A hard-coded string makes the test deterministic and fast. The decode step and the fetch step are deliberately separated so each can be tested alone. Once you are confident the decoder is correct, integration tests can cover the network layer; but unit tests should never depend on external services.

Exercises

Labels field (15 minutes)

Add a labels: List(String) field to Issue. The GitHub API returns it as an array of objects with a "name" field. Write a decoder for the label object, then compose it with decode.list and add it to issue_decoder. Update the tests.

Filter open issues (10 minutes)

Write open_issues(issues: List(Issue)) -> List(Issue) that only returns issues where state == "open". Add tests with a mix of open and closed issues.

Retry on 5xx (15 minutes)

Modify fetch_issues to retry once if the response status is >= 500. The function signature does not change. Write a test using a hard-coded response that simulates a 500 followed by a 200.

Repository decoder (20 minutes)

Write type Repo { Repo(name: String, stars: Int, language: String) } and a decoder for GET /repos/:owner/:repo. The GitHub API uses "stargazers_count" for stars and "language" for the primary language. Use decode.field for each field, following the same use pattern as issue_decoder.