HTTP API Client with JSON Decoding
gleam/dynamic/decodeprovides composable decoders that convert untyped external data into typed Gleam values.- A
decode.Decoder(t)is built withdecode.fieldand theusesyntax; decoders compose by nesting. - Separating the fetch step from the decode step keeps each part independently testable.
json.parse(string, decoder)parses a JSON string and runs the decoder in one call.- Network errors, non-200 responses, and decode failures are all
Resulterrors; the caller handles them uniformly.
The Problem
- External APIs return JSON: untyped text with no compile-time guarantees
- To work with the data in a type-safe way, you need to decode it into your own types
gleam/dynamic/decodeprovides a decoder domain-specific language (DSL)- Small functions that each handle one piece of a JSON structure
- Composing them produces a decoder for the whole record, and the compiler checks the composition
The Issue Type
pub type Issue {
Issue(number: Int, title: String, state: String)
}
- A GitHub issue has many fields; this decoder extracts only three
- The type definition says exactly what the application cares about
- Fields not listed are silently ignored by the decoder
Writing a Decoder
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))
}
issue_decoder()returns adecode.Decoder(Issue)decode.field("number", decode.int)reads the"number"key and decodes it as an integerdecode.intanddecode.stringare built-in decoders
- The
usebindings run in sequence- If any field is missing or has the wrong type the whole decoder returns an error
decode.success(Issue(...))assembles the value only when all fields decoded successfully
Decoding a JSON String
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))
}
}
decode.list(issue_decoder())wraps the single-item decoder to handle a JSON arrayjson.parseparses the string and runs the decoderstring.inspectconverts the decode error to a human-readable string for display or logging
Making HTTP Requests
- In a real application,
gleam_httpcfetches the data:
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))
}
}
}
}
- Network errors produce
Errorimmediately - Non-200 status codes produce
Errorwith the status - Only a 200 response is decoded; the logic is layered explicitly
Testing the Decoder
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
}
- The decoder can be tested with a hard-coded JSON string: no network needed
decode_one_issue_testconfirms the happy pathmissing_field_testconfirms that a missing"state"field returns an errorwrong_type_testconfirms that a non-integer"number"returns an 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.