A clean, idiomatic Rust HTTP client library with one core function that handles all HTTP communication. Built on top of reqwest with automatic JSON serialization via serde.
- Single core function —
make_requesthandles everything;get()andpost()are thin wrappers - Typed responses — Deserialize JSON directly into your Rust structs via generics
- Builder pattern — Configure the client once with
ApiClient::builder(), reuse everywhere - Base URL support — Set it once, then use relative paths like
"/users" - Session management — Automatic XSRF token and cookie handling across requests
- Per-request overrides — Custom timeout, headers, and bearer token per call via
RequestConfig - Strongly-typed errors —
ApiErrorenum with clear variants instead of magic error codes - Structured logging — Built-in tracing integration
- Async/await — Powered by tokio
Add to your Cargo.toml:
[dependencies]
rust_api_calling = "0.1.0"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }use rust_api_calling::{ApiClient, ApiResponse};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct Post {
id: u32,
title: String,
body: String,
}
#[tokio::main]
async fn main() {
let client = ApiClient::builder()
.base_url("https://jsonplaceholder.typicode.com")
.build()
.unwrap();
// GET request — response is automatically deserialized into Post
let response: ApiResponse<Post> = client
.get("/posts/1", None, None)
.await
.unwrap();
println!("Post #{}: {}", response.body.id, response.body.title);
}Create one ApiClient and reuse it — it uses connection pooling internally.
use rust_api_calling::ApiClient;
use std::time::Duration;
let client = ApiClient::builder()
.base_url("https://api.example.com") // Prepended to all relative paths
.default_timeout(Duration::from_secs(30)) // Default timeout for all requests
.default_header("Accept", "application/json") // Sent with every request
.session_enabled(true) // Auto-manage cookies & XSRF tokens
.build()
.unwrap();use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct User {
name: String,
email: String,
}
// Simple GET
let response = client.get::<Vec<User>>("/users", None, None).await?;
println!("Got {} users", response.body.len());
// GET with query parameters
let response = client
.get::<Vec<User>>("/users", Some(&[("role", "admin"), ("limit", "10")]), None)
.await?;use serde::{Serialize, Deserialize};
#[derive(Serialize)]
struct CreateUser {
name: String,
email: String,
password: String,
}
#[derive(Deserialize)]
struct UserResponse {
id: String,
name: String,
}
let new_user = CreateUser {
name: "Keval".to_string(),
email: "keval@example.com".to_string(),
password: "secure123".to_string(),
};
let response = client
.post::<UserResponse, _>("/users", Some(&new_user), None)
.await?;
println!("Created user with ID: {}", response.body.id);Override client defaults for individual requests:
use rust_api_calling::RequestConfig;
use std::time::Duration;
let config = RequestConfig::new()
.timeout(Duration::from_secs(5)) // Custom timeout
.bearer_token("eyJhbGciOiJIUzI1NiJ9...") // Authorization header
.header("X-Request-ID", "req-12345"); // Custom header
let response = client
.get::<serde_json::Value>("/protected/resource", None, Some(config))
.await?;All errors are strongly typed via the ApiError enum:
use rust_api_calling::ApiError;
match client.get::<serde_json::Value>("/users/999", None, None).await {
Ok(response) => {
println!("Status: {}", response.status);
println!("Body: {:?}", response.body);
}
Err(ApiError::HttpError { status, body }) => {
// Server returned 4xx or 5xx
eprintln!("HTTP {}: {}", status, body);
}
Err(ApiError::Timeout) => {
eprintln!("Request timed out!");
}
Err(ApiError::NetworkError(e)) => {
eprintln!("Network error: {}", e);
}
Err(ApiError::InvalidUrl(url)) => {
eprintln!("Bad URL: {}", url);
}
Err(e) => {
eprintln!("Other error: {}", e);
}
}If you don't want to define a struct, use serde_json::Value:
let response = client
.get::<serde_json::Value>("/users", None, None)
.await?;
// Access fields dynamically
if let Some(users) = response.body.as_array() {
for user in users {
println!("Name: {}", user["name"]);
}
}
// Raw body string is also available
println!("Raw JSON: {}", response.raw_body);You can always pass a full URL to bypass the configured base_url:
// This ignores the base_url and calls the full URL directly
let response = client
.get::<serde_json::Value>("https://other-api.com/data", None, None)
.await?;When session_enabled(true) is set, the client automatically:
- Reads XSRF tokens from response headers (
x-xsrf-token,xsrf-token,x-csrf-token) - Reads cookies from
set-cookieheaders - Attaches them to all subsequent requests
You can also manage sessions manually:
// Set session data manually
client.session.set_xsrf_token("my-token");
client.session.set_cookie("session=abc123");
// Read current session data
if let Some(token) = client.session.xsrf_token() {
println!("Current XSRF token: {}", token);
}
// Clear all session data
client.session.clear();All methods delegate to make_request. You can call it directly for full control:
use rust_api_calling::HttpMethod;
let response = client
.make_request::<serde_json::Value, _>(
HttpMethod::Post,
"/endpoint",
Some(&my_body), // Request body (serialized to JSON)
Some(&[("key", "val")]), // Query parameters
Some(config), // Per-request config
)
.await?;Every response includes:
let response = client.get::<MyType>("/endpoint", None, None).await?;
response.status; // HTTP status code (u16)
response.headers; // HashMap<String, String>
response.body; // Deserialized body (MyType)
response.raw_body; // Raw response string
response.is_success(); // true if 2xx
response.header("content-type"); // Case-insensitive header lookup| Method | Description |
|---|---|
ApiClient::builder() |
Start building a new client |
.get(url, query, config) |
Perform a GET request |
.post(url, body, config) |
Perform a POST request |
.make_request(method, url, body, query, config) |
The core function — full control |
| Method | Description |
|---|---|
.base_url(url) |
Set the base URL for all relative paths |
.default_timeout(duration) |
Set default timeout (default: 60s) |
.default_header(key, value) |
Add a header sent with every request |
.session_enabled(bool) |
Enable automatic session/cookie management |
.build() |
Build the ApiClient |
| Method | Description |
|---|---|
RequestConfig::new() |
Create empty per-request config |
.timeout(duration) |
Override timeout for this request |
.bearer_token(token) |
Set Authorization bearer token |
.header(key, value) |
Add a custom header |
| Variant | When |
|---|---|
InvalidUrl(String) |
URL could not be parsed |
NetworkError(reqwest::Error) |
Connection, DNS, or TLS failure |
SerializationError(serde_json::Error) |
JSON serialize/deserialize failure |
HttpError { status, body } |
Server returned non-2xx status |
EmptyResponse |
Server returned empty body |
Timeout |
Request timed out |
This library uses the tracing crate for structured logging. To see log output, add a subscriber in your application:
// Add to dev-dependencies: tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing_subscriber::fmt()
.with_env_filter("rust_api_calling=debug")
.init();Log levels used:
INFO— Request method and URLDEBUG— Query params, request body, response bodyWARN— Empty responsesERROR— Network errors, timeouts, HTTP errors, deserialization failures
A full working example is included in the examples/ directory:
cargo run --example usageLicensed under the MIT License.