How do I build an HTTP server in Rust?

Walkthrough

Hyper is a fast, correct HTTP library for Rust that powers many web frameworks including Axum and Warp. It provides low-level HTTP/1 and HTTP/2 support with both client and server implementations. Understanding Hyper gives you fine-grained control over HTTP operations and is essential knowledge for Rust web development.

Core concepts:

  1. Service trait — defines how to handle incoming requests
  2. Body types — streams of bytes that can be collected into chunks
  3. Request/Response — strongly typed HTTP message structures
  4. Tokio integration — Hyper is async and built on Tokio

Hyper's API is lower-level than frameworks like Axum, giving you direct control over every aspect of request handling.

Code Example

# Cargo.toml
[dependencies]
hyper = { version = "1", features = ["full"] }
hyper-util = { version = "0.1", features = ["full"] }
tokio = { version = "1", features = ["full"] }
http-body-util = "0.1"
use http_body_util::BodyExt;
use hyper::body::Bytes;
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Method, Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
use std::net::SocketAddr;
use tokio::net::TcpListener;
 
// Type alias for our response body type
 type GenericError = Box<dyn std::error::Error + Send + Sync>;
 type Result<T> = std::result::Result<T, GenericError>;
 
async fn handle_request(req: Request<hyper::body::Incoming>) -> Result<Response<String>> {
    match (req.method(), req.uri().path()) {
        // Simple GET endpoint
        (&Method::GET, "/") => Ok(Response::new("Welcome to Hyper!".to_string())),
        
        // Health check endpoint
        (&Method::GET, "/health") => {
            Ok(Response::builder()
                .status(StatusCode::OK)
                .header("Content-Type", "application/json")
                .body(r#"{ "status": "healthy" }"#.to_string())?)
        }
        
        // Echo endpoint - returns the request body
        (&Method::POST, "/echo") => {
            let body = req.collect().await?.to_bytes();
            let body_str = String::from_utf8_lossy(&body);
            Ok(Response::new(format!("Echo: {}", body_str)))
        }
        
        // Path parameters (manual parsing)
        (&Method::GET, path) if path.starts_with("/users/") => {
            let user_id = path.trim_start_matches("/users/");
            Ok(Response::new(format!("User ID: {}", user_id)))
        }
        
        // 404 for unknown routes
        _ => Ok(Response::builder()
            .status(StatusCode::NOT_FOUND)
            .body("Not Found".to_string())?),
    }
}
 
#[tokio::main]
async fn main() -> Result<()> {
    let addr: SocketAddr = "127.0.0.1:3000".parse()?;
    let listener = TcpListener::bind(addr).await?;
    
    println!("Server listening on http://{}", addr);
    
    loop {
        let (stream, remote_addr) = listener.accept().await?;
        println!("New connection from {}", remote_addr);
        
        let io = TokioIo::new(stream);
        
        tokio::spawn(async move {
            let service = service_fn(handle_request);
            
            if let Err(e) = http1::Builder::new().serve_connection(io, service).await {
                eprintln!("Error serving connection: {}", e);
            }
        });
    }
}

JSON API with Manual Routing

use http_body_util::BodyExt;
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Method, Request, Response, StatusCode};
use hyper_util::rt::TokioIo;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::TcpListener;
use tokio::sync::RwLock;
 
type Db = Arc<RwLock<HashMap<u64, User>>>;
 
#[derive(Debug, Serialize, Deserialize, Clone)]
struct User {
    id: u64,
    name: String,
    email: String,
}
 
async fn handle_api(
    req: Request<hyper::body::Incoming>,
    db: Db,
) -> Result<Response<String>, Box<dyn std::error::Error + Send + Sync>> {
    match (req.method(), req.uri().path()) {
        // List all users
        (&Method::GET, "/users") => {
            let users = db.read().await;
            let json = serde_json::to_string(&users.values().collect::<Vec<_>>())?;
            Ok(Response::builder()
                .header("Content-Type", "application/json")
                .body(json)?)
        }
        
        // Create user
        (&Method::POST, "/users") => {
            let body = req.collect().await?.to_bytes();
            let mut user: User = serde_json::from_slice(&body)?;
            
            let mut db = db.write().await;
            let id = db.keys().max().unwrap_or(&0) + 1;
            user.id = id;
            db.insert(id, user.clone());
            
            let json = serde_json::to_string(&user)?;
            Ok(Response::builder()
                .status(StatusCode::CREATED)
                .header("Content-Type", "application/json")
                .body(json)?)
        }
        
        // Get user by ID
        (&Method::GET, path) if path.starts_with("/users/") => {
            let id: u64 = path.trim_start_matches("/users/").parse()?;
            let db = db.read().await;
            
            match db.get(&id) {
                Some(user) => {
                    let json = serde_json::to_string(user)?;
                    Ok(Response::builder()
                        .header("Content-Type", "application/json")
                        .body(json)?)
                }
                None => Ok(Response::builder()
                    .status(StatusCode::NOT_FOUND)
                    .body("User not found".to_string())?),
            }
        }
        
        _ => Ok(Response::builder()
            .status(StatusCode::NOT_FOUND)
            .body("Not Found".to_string())?),
    }
}
 
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    let db: Db = Arc::new(RwLock::new(HashMap::new()));
    
    // Seed some data
    db.write().await.insert(1, User {
        id: 1,
        name: "Alice".to_string(),
        email: "alice@example.com".to_string(),
    });
    
    let addr: SocketAddr = "127.0.0.1:3000".parse()?;
    let listener = TcpListener::bind(addr).await?;
    println!("API server listening on http://{}", addr);
    
    loop {
        let (stream, _) = listener.accept().await?;
        let io = TokioIo::new(stream);
        let db = db.clone();
        
        tokio::spawn(async move {
            let service = service_fn(move |req| {
                let db = db.clone();
                async move { handle_api(req, db).await }
            });
            
            if let Err(e) = http1::Builder::new().serve_connection(io, service).await {
                eprintln!("Error: {}", e);
            }
        });
    }
}

Middleware Pattern

use hyper::service::service_fn;
use hyper::{Request, Response};
use std::time::Instant;
 
// Logging middleware
async fn log_request(
    req: Request<hyper::body::Incoming>,
) -> Result<Response<String>, Box<dyn std::error::Error + Send + Sync>> {
    let method = req.method().clone();
    let path = req.uri().path().to_string();
    let start = Instant::now();
    
    println!("--> {} {}", method, path);
    
    let response = handle_request(req).await;
    
    let elapsed = start.elapsed();
    println!("<-- {} {} ( {:?} )", method, path, elapsed);
    
    response
}

Summary

  • Hyper uses service_fn to wrap an async function as a request handler
  • Match on (method, path) to implement routing logic
  • Use Response::builder() for setting status codes and headers
  • Read request bodies with .collect().await?.to_bytes()
  • Spawn a task per connection for concurrent request handling
  • Share state across handlers using Arc<RwLock<T>> or Arc<Mutex<T>>
  • Hyper 1.x separates connection handling (http1::Builder) from service logic
  • For production apps, consider Axum (built on Hyper) for routing, middleware, and ergonomics
  • Always handle errors gracefully and return appropriate status codes