http_api
A Rust crate for declaring typed HTTP APIs using procedural macros.
Overview
http_api provides declarative macros for defining type-safe HTTP communication patterns:
xhr_api! - Request/response endpoints with guaranteed acknowledgment
sse_api! - Server-Sent Events for server-to-client streaming
websocket_api! - Bidirectional WebSocket messaging with fire-and-forget semantics
The crate follows an adapter pattern with separate implementations for different HTTP frameworks:
- Server adapters: Axum (with support for additional frameworks)
- Client adapters: Reqwest (with support for additional HTTP clients)
Features
- Type-safe HTTP APIs: Define your API once, get compile-time guarantees
- Automatic code generation: Request/response structs, route enums, server traits, and client implementations
- Adapter pattern: Pluggable server and client implementations
- JSON serialization: Built-in support for JSON payloads via
serde_json
- Async/await: Full async support using Tokio
Installation
Add this to your Cargo.toml:
[dependencies]
http_api = { path = "../http_api" }
http_api = { path = "../http_api", features = ["axum-adapter"] }
http_api = { path = "../http_api", features = ["reqwest-adapter"] }
http_api = { path = "../http_api", features = ["axum-adapter", "reqwest-adapter"] }
Quick Start
1. Define Your API
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
};
}
}
2. Implement the Server
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
}
3. Use the Client
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(())
}
Generated Code
The xhr_api! macro generates a module containing all API types and implementations:
Module Structure
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,
}
}
}
Route Enum
#[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}"))
}
}
}
Server Trait
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;
}
Client Implementation
#[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)?)
}
}
Adapter Pattern
The crate uses an adapter pattern to support different HTTP frameworks:
Server Adapters
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:
- Axum: Use
handle_axum() function with Axum's routing system
Client Adapters
Client 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:
- Reqwest: Use
ReqwestClient for HTTP client requests
Adding New Adapters
To add support for a new HTTP framework:
- Implement the appropriate trait (
HttpHandler for servers, HttpClient for clients)
- Create a new module in
src/adapters/
- Add feature flags in
Cargo.toml if needed
Examples
See the examples/ directory for complete working examples:
todo_api.rs: Full-featured Todo API with server and client
Run examples with:
cargo run --example todo_api --all-features
API Syntax
The xhr_api! macro supports the following command patterns:
Unit Request, No Response
xhr_api! {
pub mod my_api {
ping;
}
}
Unit Request, Structured Response
xhr_api! {
pub mod my_api {
get_status -> {
status: String
};
}
}
Structured Request, No Response
xhr_api! {
pub mod my_api {
update_config {
key: String,
value: String
};
}
}
Structured Request, Structured Response
xhr_api! {
pub mod my_api {
create_user {
name: String,
email: String
} -> {
user_id: u64
};
}
}
With Attributes
xhr_api! {
pub mod my_api {
#[derive(Debug, Clone)]
get_data -> #[derive(Debug, Clone)] {
data: Vec<u8>
};
}
}
Naming Conventions
The macro follows Rust naming conventions:
- Module names: snake_case (
pub mod todo_api)
- Command names: snake_case (
get_todos, add_todo)
- Struct names: PascalCase (
Request, Response)
- Route variants: PascalCase (
Route::GetTodos)
- Trait methods: snake_case (
fn get_todos(&self))
- Client methods: snake_case (
client.get_todos())
- URL paths: snake_case (
/get_todos, /add_todo)
This design matches tokio_ipc::protocol! macro structure for consistency across IPC and HTTP communication patterns.
SSE API
The sse_api! macro provides typed Server-Sent Events for real-time server-to-client streaming.
SSE Quick Start
1. Define Your Events
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;
}
}
2. Implement the Server
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();
}
3. Use the Browser Client
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);
});
Generated SSE Code
The sse_api! macro generates:
Event Modules
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;
}
}
Event Enum
#[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))
}
}
EventSender Trait
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<()>;
}
Sender Struct
#[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(())
}
}
SSE Architecture
The SSE implementation uses per-connection MPSC channels:
- Per-Connection Channels: Each SSE connection gets its own
mpsc::unbounded_channel()
- Cloneable Senders: The
Sender wraps mpsc::UnboundedSender<Event> and can be cloned
- Multiple Send Points: Different parts of your application can send events via cloned senders
- Independent Connections: Each SSE connection is independent with its own receiver
async 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 Protocol Format
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
- Double newline
\n\n to terminate event
SSE Examples
See the examples/ directory:
notification_sse.rs: Real-time notification system with browser client
Run 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.
WebSocket API
The websocket_api! macro provides typed bidirectional WebSocket messaging for real-time communication.
WebSocket Quick Start
1. Define Your Messages
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;
}
}
}
2. Implement Message Handlers
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(())
}
}
3. Use the Browser Client
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);
};
Generated WebSocket Code
The websocket_api! macro generates separate modules for each direction:
Message Modules
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;
}
}
}
Message Enums
#[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> { }
}
MessageSender Traits
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 { }
}
MessageHandler Traits
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<()> { }
JSON Envelope Format
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 name
data field contains JSON-serialized message payload
- Both directions use same envelope format
WebSocket Architecture
The WebSocket implementation uses bidirectional MPSC channels:
- Separate Channels: Independent channels for client→server and server→client messages
- Fire-and-Forget: Messages are one-way only, no request/response pairing
- Handler Pattern: Receivers implement handler traits for incoming messages
- Dispatcher Functions: Route messages to appropriate handler methods
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();
}
});
WebSocket Examples
See the examples/ directory:
chat_websocket.rs: Simple WebSocket message demonstration
Run examples with:
cargo run --example chat_websocket
License
This project is licensed under the MIT License.