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.
The dependencies
Section titled “The dependencies”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.
[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].
The test server
Section titled “The test server”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.
The struct, and the call that fills it
Section titled “The struct, and the call that fills it”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>:
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 47The 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.
The field that is sometimes null
Section titled “The field that is sometimes 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:
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.
- How to make a GET request — the request that brings the body back, and
.text()when the body is not JSON. - How to POST JSON to an API — the same serde traits in the other direction.
- How to handle a missing field in JSON —
Option,default, and the difference between an absent key and anull.
The next article in this chapter writes the body to a file instead of parsing it.