Skip to content
Select theme

How to parse JSON from an API

Short answer: Give the body a struct that derives Deserialize, then call response.json::<User>().await? — reqwest’s json feature reads the body and parses it in one call. A field the API sometimes sends as null has to be Option<T>, because serde refuses to put null into a String.

Three lines. reqwest’s json feature is not in its default set, so name it: it brings serde_json along and adds the json() method to Response. serde’s derive feature is what puts #[derive(Deserialize)] on the table.

Cargo.toml
[dependencies]
reqwest = { version = "0.13.5", features = ["json"] }
tokio = { version = "1.53.3", features = ["macros", "rt-multi-thread"] }
serde = { version = "1.0.229", features = ["derive"] }

This picks up the fetch project from How to make a GET request — the same dependencies, with the feature named and serde added. main stays an async fn under #[tokio::main].

Each block below runs against the same small server, written with nothing but std. /1 answers with an email string; /2 sends the same object with "email": null.

A field-per-key struct and one call. json::<User>() takes the type as a turbofish argument, consumes the response, and hands back a Result<User, reqwest::Error>:

src/main.rs
use serde::Deserialize;
use std::error::Error;
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
use std::thread;
/// A one-file test server. `/1` answers with an email, `/2` with `"email": null`.
fn spawn_server() -> String {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
thread::spawn(move || {
for stream in listener.incoming() {
let mut stream = stream.unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut request_line = String::new();
reader.read_line(&mut request_line).unwrap();
let path = request_line.split_whitespace().nth(1).unwrap_or("/");
let body = match path {
"/1" => r#"{"id": 1, "name": "Ada Lovelace", "email": "ada@example.com"}"#,
_ => r#"{"id": 2, "name": "Grace Hopper", "email": null}"#,
};
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
.unwrap();
stream.flush().unwrap();
}
});
base
}
#[derive(Debug, Deserialize)]
struct User {
id: u32,
name: String,
email: String,
}
#[tokio::main]
async fn main() {
let base = spawn_server();
let client = reqwest::Client::new();
for id in [1, 2] {
match client
.get(format!("{base}/{id}"))
.send()
.await
.unwrap()
.json::<User>()
.await
{
Ok(user) => println!("{user:?}"),
Err(error) => {
// reqwest's own message stops at the URL; the serde message is one level down.
let source = error.source().expect("a decode error wraps a serde error");
println!("{source}");
}
}
}
}

Run it with cargo run (cargo’s own Compiling and Finished lines are not shown here):

User { id: 1, name: "Ada Lovelace", email: "ada@example.com" }
invalid type: null, expected a string at line 1 column 47

The first user parsed; the second did not. reqwest’s own message for a decode failure — error decoding response body for url (…) — stops at the URL, so the loop above walks one level down the source chain with std::error::Error::source to reach the message that names the problem: invalid type: null, expected a string. The request itself succeeded: the status was 200, the body arrived, and the failure is in reading it. Nothing in the compiler checks the struct against the API’s payload, so this is a failure you meet at run time, on the first response that happens to carry a null.

Option<String> is the fix, and it is the honest type for this field: the API’s contract puts the key there and allows the value to be null, so the Rust type says exactly that. Same loop, one changed line:

src/main.rs
use serde::Deserialize;
use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
use std::thread;
/// A one-file test server. `/1` answers with an email, `/2` with `"email": null`.
fn spawn_server() -> String {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
thread::spawn(move || {
for stream in listener.incoming() {
let mut stream = stream.unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut request_line = String::new();
reader.read_line(&mut request_line).unwrap();
let path = request_line.split_whitespace().nth(1).unwrap_or("/");
let body = match path {
"/1" => r#"{"id": 1, "name": "Ada Lovelace", "email": "ada@example.com"}"#,
_ => r#"{"id": 2, "name": "Grace Hopper", "email": null}"#,
};
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
.unwrap();
stream.flush().unwrap();
}
});
base
}
#[derive(Debug, Deserialize)]
struct User {
id: u32,
name: String,
email: Option<String>,
}
#[tokio::main]
async fn main() {
let base = spawn_server();
let client = reqwest::Client::new();
for id in [1, 2] {
match client
.get(format!("{base}/{id}"))
.send()
.await
.unwrap()
.json::<User>()
.await
{
Ok(user) => println!("{user:?}"),
Err(error) => println!("{error}"),
}
}
}
User { id: 1, name: "Ada Lovelace", email: Some("ada@example.com") }
User { id: 2, name: "Grace Hopper", email: None }

A null becomes None, a string becomes Some("…"), and every reader of the field now has to decide what None means — print a dash, fall back to a lookup, skip the row. That is the trade the type makes: the compiler will not let a missing email pass for a real one.

A key that is absent altogether is a different problem, and serde treats it differently: an Option field is filled with None when the key is missing, exactly as it is for a null, while a plain field needs #[serde(default)] to survive its absence. That distinction is on the missing-field page.

The next article in this chapter writes the body to a file instead of parsing it.