| Crates.io | http_api |
| lib.rs | http_api |
| version | 0.1.0 |
| created_at | 2025-11-04 07:50:55.67412+00 |
| updated_at | 2025-11-04 07:50:55.67412+00 |
| description | Declare modular HTTP client/server APIS using XHR, WebSocket and SSE |
| homepage | |
| repository | |
| max_upload_size | |
| id | 1915875 |
| size | 135,940 |
A Rust crate for declaring typed HTTP APIs using procedural macros.
http_api provides declarative macros for defining type-safe HTTP communication patterns:
xhr_api! - Request/response endpoints with guaranteed acknowledgmentsse_api! - Server-Sent Events for server-to-client streamingwebsocket_api! - Bidirectional WebSocket messaging with fire-and-forget semanticsThe crate follows an adapter pattern with separate implementations for different HTTP frameworks:
serde_jsonAdd this to your Cargo.toml:
[dependencies]
http_api = { path = "../http_api" }
# For server support (Axum)
http_api = { path = "../http_api", features = ["axum-adapter"] }
# For client support (Reqwest)
http_api = { path = "../http_api", features = ["reqwest-adapter"] }
# For both
http_api = { path = "../http_api", features = ["axum-adapter", "reqwest-adapter"] }
use http_api::xhr_api;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
struct Todo {
id: u64,
title: String,
description: String,
completed: bool,
}
xhr_api! {
pub mod todo_api {
#[derive(Debug)]
get_todos -> #[derive(Debug)] {
todos: Vec<Todo>
};
#[derive(Debug)]
add_todo {
title: String,
description: String
} -> #[derive(Debug)] {
todo: Todo
};
#[derive(Debug)]
complete_todo {
id: u64
};
}
}
use http_api::{HttpServer, HttpHandler};
use http_api::adapters::axum::handle_axum;
use axum::{Router, routing::post};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
#[derive(Clone)]
struct TodoServer {
todos: Arc<RwLock<HashMap<u64, Todo>>>,
next_id: Arc<RwLock<u64>>,
}
impl HttpServer for TodoServer {}
pub struct AxumRequestHandler;
impl HttpHandler<TodoServer, todo_api::Route> for AxumRequestHandler {
async fn handle(
server: &TodoServer,
route: &todo_api::Route,
payload: &[u8],
) -> anyhow::Result<String> {
todo_api::ServerHandler::handle(server, route, payload).await
}
}
impl todo_api::Server for TodoServer {
async fn get_todos(&self) -> anyhow::Result<todo_api::get_todos::Response> {
let todos = self.todos.read().unwrap();
let todos_vec: Vec<Todo> = todos.values().cloned().collect();
Ok(todo_api::get_todos::Response { todos: todos_vec })
}
async fn add_todo(&self, title: String, description: String) -> anyhow::Result<todo_api::add_todo::Response> {
let mut next_id = self.next_id.write().unwrap();
let id = *next_id;
*next_id += 1;
drop(next_id);
let todo = Todo {
id,
title,
description,
completed: false,
};
let mut todos = self.todos.write().unwrap();
todos.insert(id, todo.clone());
drop(todos);
Ok(todo_api::add_todo::Response { todo })
}
async fn complete_todo(&self, id: u64) -> anyhow::Result<()> {
let mut todos = self.todos.write().unwrap();
if let Some(todo) = todos.get_mut(&id) {
todo.completed = true;
Ok(())
} else {
Err(anyhow::anyhow!("Todo with id {} not found", id))
}
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let server = TodoServer::new();
let router = Router::new()
.route("/api/v1/:r", post(handle_axum::<todo_api::Route, TodoServer, AxumRequestHandler>))
.with_state(server);
http_api::adapters::axum::serve_axum("127.0.0.1:3000", router).await
}
use http_api::adapters::reqwest::ReqwestClient;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let http_client = ReqwestClient::new();
let client = todo_api::Client::new(http_client, "http://localhost:3000/api/v1");
let response = client.get_todos().await?;
println!("Todos: {:?}", response.todos);
let add_response = client.add_todo(
"Learn Rust".to_string(),
"Study the Rust programming language".to_string(),
).await?;
println!("Added todo: {:?}", add_response.todo);
client.complete_todo(add_response.todo.id).await?;
println!("Todo completed!");
Ok(())
}
The xhr_api! macro generates a module containing all API types and implementations:
pub mod todo_api {
pub mod get_todos {
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct Request;
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct Response {
pub todos: Vec<Todo>,
}
}
pub mod add_todo {
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct Request {
pub title: String,
pub description: String,
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct Response {
pub todo: Todo,
}
}
pub mod complete_todo {
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct Request {
pub id: u64,
}
}
}
#[derive(Clone, Debug)]
pub enum Route {
GetTodos,
AddTodo,
CompleteTodo,
}
impl std::str::FromStr for Route {
type Err = anyhow::Error;
fn from_str(route: &str) -> anyhow::Result<Self> {
match route {
"get_todos" => Ok(Self::GetTodos),
"add_todo" => Ok(Self::AddTodo),
"complete_todo" => Ok(Self::CompleteTodo),
_ => Err(anyhow::anyhow!("no such route {route}"))
}
}
}
pub trait Server: Send + Sync + 'static {
fn get_todos(&self) -> impl std::future::Future<Output = anyhow::Result<get_todos::Response>> + Send + Sync;
fn add_todo(&self, title: String, description: String) -> impl std::future::Future<Output = anyhow::Result<add_todo::Response>> + Send + Sync;
fn complete_todo(&self, id: u64) -> impl std::future::Future<Output = anyhow::Result<()>> + Send + Sync;
}
#[derive(Clone, Debug)]
pub struct Client<C: HttpClient> {
client: C,
base_url: String,
}
impl<C: HttpClient> Client<C> {
pub fn new(client: C, base_url: impl AsRef<str>) -> Self {
let base_url = base_url.as_ref().to_string();
Self { client, base_url }
}
pub async fn get_todos(&self) -> anyhow::Result<get_todos::Response> {
let request = get_todos::Request;
let payload = serde_json::to_vec(&request)?;
let response = self.client.post(&format!("{}/get_todos", self.base_url), &payload).await?;
Ok(serde_json::from_slice(&response)?)
}
pub async fn add_todo(&self, title: String, description: String) -> anyhow::Result<add_todo::Response> {
let request = add_todo::Request { title, description };
let payload = serde_json::to_vec(&request)?;
let response = self.client.post(&format!("{}/add_todo", self.base_url), &payload).await?;
Ok(serde_json::from_slice(&response)?)
}
}
The crate uses an adapter pattern to support different HTTP frameworks:
Server adapters implement the HttpHandler trait to bridge between the HTTP framework and your server implementation:
pub trait HttpHandler<S: HttpServer, R>: Send + Sync + 'static {
fn handle(server: &S, route: &R, payload: &[u8])
-> impl Future<Output = anyhow::Result<String>> + Send;
}
Currently supported:
handle_axum() function with Axum's routing systemClient adapters implement the HttpClient trait:
pub trait HttpClient: Send + Sync + 'static {
fn post(&self, url: &str, payload: &[u8])
-> impl Future<Output = anyhow::Result<Vec<u8>>> + Send;
}
Currently supported:
ReqwestClient for HTTP client requestsTo add support for a new HTTP framework:
HttpHandler for servers, HttpClient for clients)src/adapters/Cargo.toml if neededSee the examples/ directory for complete working examples:
todo_api.rs: Full-featured Todo API with server and clientRun examples with:
cargo run --example todo_api --all-features
The xhr_api! macro supports the following command patterns:
xhr_api! {
pub mod my_api {
ping;
}
}
xhr_api! {
pub mod my_api {
get_status -> {
status: String
};
}
}
xhr_api! {
pub mod my_api {
update_config {
key: String,
value: String
};
}
}
xhr_api! {
pub mod my_api {
create_user {
name: String,
email: String
} -> {
user_id: u64
};
}
}
xhr_api! {
pub mod my_api {
#[derive(Debug, Clone)]
get_data -> #[derive(Debug, Clone)] {
data: Vec<u8>
};
}
}
The macro follows Rust naming conventions:
pub mod todo_api)get_todos, add_todo)Request, Response)Route::GetTodos)fn get_todos(&self))client.get_todos())/get_todos, /add_todo)This design matches tokio_ipc::protocol! macro structure for consistency across IPC and HTTP communication patterns.
The sse_api! macro provides typed Server-Sent Events for real-time server-to-client streaming.
use http_api::sse_api;
sse_api! {
pub mod notifications {
#[derive(Debug, Clone)]
notification_event {
message: String,
priority: u8,
timestamp: u64,
};
#[derive(Debug, Clone)]
status_update {
status: String,
progress: f32,
};
#[derive(Debug, Clone)]
heartbeat;
}
}
use http_api::adapters::sse_axum::{create_sse_stream, SseEventConvert};
use axum::{Router, routing::get};
use tokio::sync::mpsc;
impl SseEventConvert for notifications::Event {
fn to_sse_string(&self) -> anyhow::Result<String> {
self.to_sse_string()
}
}
async fn sse_handler() -> impl axum::response::IntoResponse {
use notifications::EventSender;
let (tx, rx) = mpsc::unbounded_channel();
let sender = notifications::Sender::new(tx.clone());
tokio::spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
let _ = sender.send_heartbeat().await;
}
});
create_sse_stream(rx)
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/sse", get(sse_handler));
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
const eventSource = new EventSource('/sse');
eventSource.addEventListener('heartbeat', (event) => {
console.log('Heartbeat received');
});
eventSource.addEventListener('notification_event', (event) => {
const data = JSON.parse(event.data);
console.log('Notification:', data.message, 'Priority:', data.priority);
});
eventSource.addEventListener('status_update', (event) => {
const data = JSON.parse(event.data);
console.log('Status:', data.status, 'Progress:', data.progress);
});
The sse_api! macro generates:
pub mod notifications {
pub mod notification_event {
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Data {
pub message: String,
pub priority: u8,
pub timestamp: u64,
}
}
pub mod heartbeat {
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Data;
}
}
#[derive(Clone, Debug)]
pub enum Event {
NotificationEvent(notification_event::Data),
StatusUpdate(status_update::Data),
Heartbeat(heartbeat::Data),
}
impl Event {
pub fn event_type(&self) -> &'static str {
match self {
Event::NotificationEvent(_) => "notification_event",
Event::StatusUpdate(_) => "status_update",
Event::Heartbeat(_) => "heartbeat",
}
}
pub fn to_sse_string(&self) -> anyhow::Result<String> {
let event_type = self.event_type();
let data = match self {
Event::NotificationEvent(d) => serde_json::to_string(d)?,
Event::StatusUpdate(d) => serde_json::to_string(d)?,
Event::Heartbeat(d) => serde_json::to_string(d)?,
};
Ok(format!("event: {}\ndata: {}\n\n", event_type, data))
}
}
pub trait EventSender: Send + Sync + 'static {
async fn send_notification_event(&self, message: String, priority: u8, timestamp: u64) -> anyhow::Result<()>;
async fn send_status_update(&self, status: String, progress: f32) -> anyhow::Result<()>;
async fn send_heartbeat(&self) -> anyhow::Result<()>;
}
#[derive(Clone)]
pub struct Sender {
tx: tokio::sync::mpsc::UnboundedSender<Event>,
}
impl Sender {
pub fn new(tx: tokio::sync::mpsc::UnboundedSender<Event>) -> Self {
Self { tx }
}
}
impl EventSender for Sender {
async fn send_notification_event(&self, message: String, priority: u8, timestamp: u64) -> anyhow::Result<()> {
let event = Event::NotificationEvent(notification_event::Data { message, priority, timestamp });
self.tx.send(event)?;
Ok(())
}
// ... other methods
}
The SSE implementation uses per-connection MPSC channels:
mpsc::unbounded_channel()Sender wraps mpsc::UnboundedSender<Event> and can be clonedasync fn sse_handler() -> impl IntoResponse {
use notifications::EventSender;
let (tx, rx) = mpsc::unbounded_channel();
let sender1 = notifications::Sender::new(tx.clone());
let sender2 = notifications::Sender::new(tx.clone());
tokio::spawn(async move {
let _ = sender1.send_heartbeat().await;
});
tokio::spawn(async move {
let _ = sender2.send_notification_event("Hello".to_string(), 1, 12345).await;
});
create_sse_stream(rx)
}
SSE events follow this format:
event: notification_event
data: {"message":"Hello","priority":1,"timestamp":12345}
event: heartbeat
data: null
Each event consists of:
event: field with event type name (snake_case)data: field with JSON-serialized event data\n\n to terminate eventSee the examples/ directory:
notification_sse.rs: Real-time notification system with browser clientRun examples with:
cargo run --example notification_sse --all-features
Then open http://127.0.0.1:3000 in your browser to see SSE events in action.
The websocket_api! macro provides typed bidirectional WebSocket messaging for real-time communication.
use http_api::websocket_api;
websocket_api! {
pub mod chat_ws {
client_to_server {
#[derive(Debug, Clone)]
send_message {
content: String,
room_id: u64,
};
#[derive(Debug, Clone)]
join_room {
room_id: u64,
};
#[derive(Debug, Clone)]
ping;
}
server_to_client {
#[derive(Debug, Clone)]
message_received {
from_user: String,
content: String,
timestamp: u64,
};
#[derive(Debug, Clone)]
user_joined {
username: String,
room_id: u64,
};
#[derive(Debug, Clone)]
pong;
}
}
}
use chat_ws::client_to_server::ServerMessageHandler;
struct ChatServer;
impl ServerMessageHandler for ChatServer {
async fn handle_send_message(&self, content: String, room_id: u64) -> anyhow::Result<()> {
println!("Message in room {room_id}: {content}");
Ok(())
}
async fn handle_join_room(&self, room_id: u64) -> anyhow::Result<()> {
println!("User joined room {room_id}");
Ok(())
}
async fn handle_ping(&self) -> anyhow::Result<()> {
println!("Ping received");
Ok(())
}
}
const ws = new WebSocket('ws://localhost:3000/ws');
ws.onopen = () => {
ws.send(JSON.stringify({
type: 'send_message',
data: { content: 'Hello!', room_id: 1 }
}));
};
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
console.log('Received:', message.type, message.data);
};
The websocket_api! macro generates separate modules for each direction:
pub mod chat_ws {
pub mod client_to_server {
pub mod send_message {
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Data {
pub content: String,
pub room_id: u64,
}
}
pub mod ping {
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Data;
}
}
pub mod server_to_client {
pub mod message_received {
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Data {
pub from_user: String,
pub content: String,
pub timestamp: u64,
}
}
pub mod pong {
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Data;
}
}
}
#[derive(Clone, Debug)]
pub enum Message {
SendMessage(send_message::Data),
JoinRoom(join_room::Data),
Ping(ping::Data),
}
impl Message {
pub fn message_type(&self) -> &'static str { /* ... */ }
pub fn to_json_string(&self) -> anyhow::Result<String> { /* ... */ }
pub fn from_json_string(s: &str) -> anyhow::Result<Self> { /* ... */ }
}
pub trait ClientMessageSender: Send + Sync + 'static {
async fn send_send_message(&self, content: String, room_id: u64) -> anyhow::Result<()>;
async fn send_join_room(&self, room_id: u64) -> anyhow::Result<()>;
async fn send_ping(&self) -> anyhow::Result<()>;
}
#[derive(Clone)]
pub struct ClientSender {
tx: tokio::sync::mpsc::UnboundedSender<Message>,
}
impl ClientSender {
pub fn new(tx: tokio::sync::mpsc::UnboundedSender<Message>) -> Self { /* ... */ }
}
pub trait ServerMessageHandler: Send + Sync + 'static {
async fn handle_send_message(&self, content: String, room_id: u64) -> anyhow::Result<()>;
async fn handle_join_room(&self, room_id: u64) -> anyhow::Result<()>;
async fn handle_ping(&self) -> anyhow::Result<()>;
}
pub async fn dispatch_client_message<H: ServerMessageHandler>(
handler: &H,
message: Message,
) -> anyhow::Result<()> { /* ... */ }
WebSocket messages use JSON envelope for type discrimination:
{
"type": "send_message",
"data": {
"content": "Hello, world!",
"room_id": 42
}
}
Key points:
type field contains snake_case message namedata field contains JSON-serialized message payloadThe WebSocket implementation uses bidirectional MPSC channels:
let (c2s_tx, c2s_rx) = mpsc::unbounded_channel();
let (s2c_tx, s2c_rx) = mpsc::unbounded_channel();
let client_sender = chat_ws::client_to_server::ClientSender::new(c2s_tx);
let server_sender = chat_ws::server_to_client::ServerSender::new(s2c_tx);
tokio::spawn(async move {
while let Some(msg) = c2s_rx.recv().await {
chat_ws::client_to_server::dispatch_client_message(&handler, msg).await.unwrap();
}
});
See the examples/ directory:
chat_websocket.rs: Simple WebSocket message demonstrationRun examples with:
cargo run --example chat_websocket
This project is licensed under the MIT License.