# 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`:
```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"] }
```
## Quick Start
### 1. Define Your API
```rust
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
```rust
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
```rust
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
```rust
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
```rust
#[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
```rust
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
```rust
#[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:
```rust
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:
```rust
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:
1. Implement the appropriate trait (`HttpHandler` for servers, `HttpClient` for clients)
2. Create a new module in `src/adapters/`
3. 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:
```bash
cargo run --example todo_api --all-features
```
## API Syntax
The `xhr_api!` macro supports the following command patterns:
### Unit Request, No Response
```rust
xhr_api! {
pub mod my_api {
ping;
}
}
```
### Unit Request, Structured Response
```rust
xhr_api! {
pub mod my_api {
get_status -> {
status: String
};
}
}
```
### Structured Request, No Response
```rust
xhr_api! {
pub mod my_api {
update_config {
key: String,
value: String
};
}
}
```
### Structured Request, Structured Response
```rust
xhr_api! {
pub mod my_api {
create_user {
name: String,
email: String
} -> {
user_id: u64
};
}
}
```
### With Attributes
```rust
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
```rust
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
```rust
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
```javascript
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
```rust
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
```rust
#[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
```rust
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
```rust
#[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
}
```
### SSE Architecture
The SSE implementation uses per-connection MPSC channels:
1. **Per-Connection Channels**: Each SSE connection gets its own `mpsc::unbounded_channel()`
2. **Cloneable Senders**: The `Sender` wraps `mpsc::UnboundedSender<Event>` and can be cloned
3. **Multiple Send Points**: Different parts of your application can send events via cloned senders
4. **Independent Connections**: Each SSE connection is independent with its own receiver
```rust
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:
```bash
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
```rust
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
```rust
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
```javascript
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
```rust
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
```rust
#[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
```rust
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
```rust
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:
```json
{
"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:
1. **Separate Channels**: Independent channels for client→server and server→client messages
2. **Fire-and-Forget**: Messages are one-way only, no request/response pairing
3. **Handler Pattern**: Receivers implement handler traits for incoming messages
4. **Dispatcher Functions**: Route messages to appropriate handler methods
```rust
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:
```bash
cargo run --example chat_websocket
```
## License
This project is licensed under the MIT License.