http_api

Crates.iohttp_api
lib.rshttp_api
version0.1.0
created_at2025-11-04 07:50:55.67412+00
updated_at2025-11-04 07:50:55.67412+00
descriptionDeclare modular HTTP client/server APIS using XHR, WebSocket and SSE
homepage
repository
max_upload_size
id1915875
size135,940
(nobane)

documentation

README

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" }

# 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

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:

  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:

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(())
    }
    // ... 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
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:

  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
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.

Commit count: 0

cargo fmt