
Building a Robust JSON API Client with `reqwest` and `serde`
Why build a dedicated API client?
It is tempting to scatter reqwest::get(...) calls throughout an application. That works for a prototype, but it becomes difficult to maintain once you need:
- consistent headers and authentication
- typed request and response models
- retry-friendly error handling
- testable business logic
- support for multiple endpoints
A dedicated client centralizes these concerns. Instead of repeating HTTP setup and JSON parsing everywhere, you expose a small Rust API that returns domain types.
What we are building
We will implement a client for a fictional issue-tracking service with endpoints such as:
GET /issues/{id}to fetch one issueGET /issues?status=opento list issuesPOST /issuesto create a new issue
The same pattern works for many real APIs, including internal REST services.
Dependencies
Add these crates to Cargo.toml:
[dependencies]
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
thiserror = "2"reqwest provides the HTTP client, serde handles JSON encoding and decoding, tokio powers async execution, and thiserror helps define a clean error type.
Define your data models first
Typed models are the foundation of a reliable API client. Start by describing the JSON structures you expect to send and receive.
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
pub struct Issue {
pub id: u64,
pub title: String,
pub status: String,
pub assignee: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct NewIssue<'a> {
pub title: &'a str,
pub description: &'a str,
}A few best practices are worth noting:
- Use
Option<T>for fields that may be absent. - Prefer owned
Stringwhen the data must outlive the request scope. - Use borrowed
&strin request payloads when you only need temporary serialization. - Keep model names close to the API domain, not the transport layer.
If the API uses different field names, serde can map them with #[serde(rename = "...")].
Design a client wrapper
A wrapper type gives you a single place to store configuration such as the base URL, authentication token, and shared HTTP client.
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue};
use reqwest::Client;
pub struct IssueTrackerClient {
base_url: String,
client: Client,
}
impl IssueTrackerClient {
pub fn new(base_url: impl Into<String>, token: impl AsRef<str>) -> Result<Self, reqwest::header::InvalidHeaderValue> {
let mut headers = HeaderMap::new();
let auth_value = format!("Bearer {}", token.as_ref());
headers.insert(AUTHORIZATION, HeaderValue::from_str(&auth_value)?);
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
let client = Client::builder()
.default_headers(headers)
.build()
.expect("failed to build HTTP client");
Ok(Self {
base_url: base_url.into(),
client,
})
}
}This structure keeps the HTTP client reusable. Reusing a single reqwest::Client is important because it pools connections and avoids unnecessary setup overhead.
Why this shape works well
| Concern | Where it lives |
|---|---|
| Base endpoint | base_url |
| Authentication | default headers |
| Connection pooling | shared reqwest::Client |
| JSON encoding/decoding | serde models |
| Endpoint logic | methods on IssueTrackerClient |
This separation makes the client easy to test and extend.
Implement a typed GET request
Let’s add a method for fetching a single issue by ID.
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ApiError {
#[error("request failed: {0}")]
Request(#[from] reqwest::Error),
#[error("unexpected status code: {0}")]
Status(reqwest::StatusCode),
}
impl IssueTrackerClient {
pub async fn get_issue(&self, id: u64) -> Result<Issue, ApiError> {
let url = format!("{}/issues/{}", self.base_url, id);
let response = self.client.get(url).send().await?;
if !response.status().is_success() {
return Err(ApiError::Status(response.status()));
}
let issue = response.json::<Issue>().await?;
Ok(issue)
}
}This method does three things:
- builds the endpoint URL
- sends the request
- validates the status code before deserializing JSON
That order matters. If the server returns an error page or a structured error object, trying to deserialize it into Issue would produce a confusing failure. Checking the status first gives you a clearer control flow.
Handle query parameters cleanly
Listing issues usually requires filters such as status, assignee, or pagination. reqwest supports query serialization directly.
use serde::Serialize;
#[derive(Debug, Serialize)]
pub struct IssueQuery<'a> {
pub status: Option<&'a str>,
pub assignee: Option<&'a str>,
pub page: Option<u32>,
}
impl IssueTrackerClient {
pub async fn list_issues(&self, query: IssueQuery<'_>) -> Result<Vec<Issue>, ApiError> {
let url = format!("{}/issues", self.base_url);
let response = self
.client
.get(url)
.query(&query)
.send()
.await?;
if !response.status().is_success() {
return Err(ApiError::Status(response.status()));
}
Ok(response.json::<Vec<Issue>>().await?)
}
}Using a dedicated query struct is better than manually concatenating strings. It reduces encoding bugs and makes the supported filters explicit.
Create resources with POST
For write operations, serialize a request body into JSON and send it with POST.
impl IssueTrackerClient {
pub async fn create_issue(&self, issue: &NewIssue<'_>) -> Result<Issue, ApiError> {
let url = format!("{}/issues", self.base_url);
let response = self
.client
.post(url)
.json(issue)
.send()
.await?;
if !response.status().is_success() {
return Err(ApiError::Status(response.status()));
}
Ok(response.json::<Issue>().await?)
}
}This pattern is simple and idiomatic:
.json(issue)serializes the payload- the
Content-Type: application/jsonheader is already set - the response is deserialized into a typed model
Add a practical error strategy
A production client should distinguish between transport errors, server errors, and data problems. The earlier ApiError type is a good start, but you can improve it by capturing response bodies for debugging.
#[derive(Debug, Error)]
pub enum ApiError {
#[error("request failed: {0}")]
Request(#[from] reqwest::Error),
#[error("unexpected status code: {status}, body: {body}")]
Http {
status: reqwest::StatusCode,
body: String,
},
}Then read the body when the status is not successful:
impl IssueTrackerClient {
async fn send_json<T: serde::de::DeserializeOwned>(
&self,
request: reqwest::RequestBuilder,
) -> Result<T, ApiError> {
let response = request.send().await?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(ApiError::Http { status, body });
}
Ok(response.json::<T>().await?)
}
}This helper reduces duplication and gives you a single place to enforce response handling rules.
Refactor toward reuse
Once you have multiple endpoints, a small helper like send_json becomes valuable. Here is how the client methods look after refactoring:
impl IssueTrackerClient {
pub async fn get_issue(&self, id: u64) -> Result<Issue, ApiError> {
let url = format!("{}/issues/{}", self.base_url, id);
self.send_json(self.client.get(url)).await
}
pub async fn list_issues(&self, query: IssueQuery<'_>) -> Result<Vec<Issue>, ApiError> {
let url = format!("{}/issues", self.base_url);
self.send_json(self.client.get(url).query(&query)).await
}
pub async fn create_issue(&self, issue: &NewIssue<'_>) -> Result<Issue, ApiError> {
let url = format!("{}/issues", self.base_url);
self.send_json(self.client.post(url).json(issue)).await
}
}This version is easier to maintain because endpoint methods now focus on intent, not HTTP boilerplate.
Use the client in an application
Here is a complete example of calling the client from main.
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = IssueTrackerClient::new("https://api.example.com", "secret-token")?;
let new_issue = NewIssue {
title: "Fix login timeout",
description: "Users are logged out after 30 seconds on slow networks.",
};
let created = client.create_issue(&new_issue).await?;
println!("Created issue: {:?}", created);
let fetched = client.get_issue(created.id).await?;
println!("Fetched issue: {:?}", fetched);
Ok(())
}This is the main benefit of typed clients: the application code reads like domain logic rather than HTTP plumbing.
Testing the client
For client code, tests should verify request construction and response handling. A common approach is to use a mock HTTP server such as wiremock or httpmock. That lets you simulate real API behavior without depending on an external service.
You should test at least these cases:
- successful JSON response
- non-2xx status code
- malformed JSON
- missing optional fields
- query parameter serialization
A good test suite catches changes in API contracts early, especially when the remote service evolves independently.
Best practices for production use
A few habits make API clients much more robust:
- Reuse the HTTP client: create one
reqwest::Clientper application or per API. - Keep models explicit: avoid untyped
serde_json::Valueunless the schema is truly dynamic. - Validate status codes before parsing: error bodies are often not the same shape as success bodies.
- Separate transport and domain errors: callers should know whether a failure was network-related or API-related.
- Support timeouts: configure reasonable request timeouts for real services.
- Log carefully: include status codes and request IDs, but avoid leaking secrets.
Common extension points
| Feature | Typical approach |
|---|---|
| Pagination | expose page and limit in query structs |
| Retries | wrap requests with retry logic for transient failures |
| Timeouts | configure via Client::builder() |
| Authentication refresh | inject a token provider instead of a static token |
| Rate-limit handling | inspect 429 responses and Retry-After headers |
When this pattern is especially useful
A typed API client is ideal when:
- your application depends on a stable REST API
- multiple modules need the same remote service
- you want compile-time checks for request and response shapes
- you need a clean boundary between business logic and transport
It is less useful for one-off scripts or highly dynamic endpoints where the schema changes frequently and typed models would add friction.
Conclusion
A Rust API client built with reqwest and serde gives you a strong foundation for reliable service integration. By wrapping the HTTP layer in a dedicated type, modeling JSON explicitly, and centralizing error handling, you get code that is easier to read, test, and evolve.
The same approach scales from a small internal tool to a larger application with many endpoints. Start with a minimal client, then add helpers for shared request logic, pagination, retries, and authentication as your needs grow.
