How do I implement custom middleware for HTTP requests in Rust?
Custom middleware in Rust allows you to intercept and modify HTTP requests and responses before they reach your application handlers. This is essential for implementing cross-cutting concerns like authentication, logging, rate limiting, and request validation in web applications and scraping tools.
Understanding Middleware in Rust
Middleware in Rust follows a layered architecture where each middleware component can process requests before passing them to the next layer and modify responses on their way back. Popular Rust web frameworks like Axum, Actix-web, and Warp each have their own middleware patterns.
Implementing Middleware with Axum
Axum provides a clean and type-safe approach to middleware implementation using tower's Service
and Layer
traits.
Basic Logging Middleware
Here's a simple logging middleware that tracks request duration:
use axum::{
extract::Request,
http::StatusCode,
middleware::Next,
response::Response,
Router,
};
use std::time::Instant;
use tower::ServiceBuilder;
use tower_http::trace::TraceLayer;
// Middleware function
async fn logging_middleware(
request: Request,
next: Next,
) -> Result<Response, StatusCode> {
let start = Instant::now();
let method = request.method().clone();
let uri = request.uri().clone();
// Call the next middleware/handler
let response = next.run(request).await;
let duration = start.elapsed();
println!(
"{} {} - {} - {:?}",
method,
uri,
response.status(),
duration
);
Ok(response)
}
// Apply middleware to router
let app = Router::new()
.route("/", get(handler))
.layer(axum::middleware::from_fn(logging_middleware));
Authentication Middleware
Here's a more complex middleware for JWT authentication:
use axum::{
extract::{Request, State},
http::{HeaderMap, StatusCode},
middleware::Next,
response::Response,
Json,
};
use jsonwebtoken::{decode, DecodingKey, Validation};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
sub: String,
exp: usize,
}
#[derive(Clone)]
struct AppState {
jwt_secret: String,
}
async fn auth_middleware(
State(state): State<AppState>,
headers: HeaderMap,
mut request: Request,
next: Next,
) -> Result<Response, StatusCode> {
// Extract Authorization header
let auth_header = headers
.get("Authorization")
.and_then(|header| header.to_str().ok())
.ok_or(StatusCode::UNAUTHORIZED)?;
// Parse Bearer token
let token = auth_header
.strip_prefix("Bearer ")
.ok_or(StatusCode::UNAUTHORIZED)?;
// Validate JWT token
let validation = Validation::default();
let key = DecodingKey::from_secret(state.jwt_secret.as_bytes());
let token_data = decode::<Claims>(token, &key, &validation)
.map_err(|_| StatusCode::UNAUTHORIZED)?;
// Add user info to request extensions
request.extensions_mut().insert(token_data.claims);
Ok(next.run(request).await)
}
// Usage
let app = Router::new()
.route("/protected", get(protected_handler))
.layer(axum::middleware::from_fn_with_state(
AppState { jwt_secret: "secret".to_string() },
auth_middleware
));
Implementing Middleware with Actix-web
Actix-web uses a different approach with the Transform
and Service
traits:
use actix_web::{
dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform},
Error, HttpMessage,
};
use futures_util::future::LocalBoxFuture;
use std::{
future::{ready, Ready},
rc::Rc,
time::Instant,
};
// Middleware factory
pub struct Logging;
impl<S, B> Transform<S, ServiceRequest> for Logging
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type InitError = ();
type Transform = LoggingMiddleware<S>;
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(LoggingMiddleware {
service: Rc::new(service),
}))
}
}
// Middleware service
pub struct LoggingMiddleware<S> {
service: Rc<S>,
}
impl<S, B> Service<ServiceRequest> for LoggingMiddleware<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error>,
S::Future: 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
forward_ready!(service);
fn call(&self, req: ServiceRequest) -> Self::Future {
let start = Instant::now();
let method = req.method().clone();
let path = req.path().to_string();
let service = self.service.clone();
Box::pin(async move {
let res = service.call(req).await?;
let duration = start.elapsed();
println!(
"{} {} - {} - {:?}",
method,
path,
res.status(),
duration
);
Ok(res)
})
}
}
// Usage in main.rs
use actix_web::{web, App, HttpServer, middleware::Logger};
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| {
App::new()
.wrap(Logging)
.route("/", web::get().to(handler))
})
.bind("127.0.0.1:8080")?
.run()
.await
}
Rate Limiting Middleware
Here's a rate limiting middleware using a token bucket algorithm:
use axum::{
extract::{ConnectInfo, Request},
http::StatusCode,
middleware::Next,
response::Response,
};
use std::{
collections::HashMap,
net::SocketAddr,
sync::Arc,
time::{Duration, Instant},
};
use tokio::sync::Mutex;
#[derive(Clone)]
struct TokenBucket {
tokens: f64,
last_refill: Instant,
capacity: f64,
refill_rate: f64, // tokens per second
}
impl TokenBucket {
fn new(capacity: f64, refill_rate: f64) -> Self {
Self {
tokens: capacity,
last_refill: Instant::now(),
capacity,
refill_rate,
}
}
fn try_consume(&mut self, tokens: f64) -> bool {
self.refill();
if self.tokens >= tokens {
self.tokens -= tokens;
true
} else {
false
}
}
fn refill(&mut self) {
let now = Instant::now();
let elapsed = now.duration_since(self.last_refill).as_secs_f64();
let tokens_to_add = elapsed * self.refill_rate;
self.tokens = (self.tokens + tokens_to_add).min(self.capacity);
self.last_refill = now;
}
}
type RateLimiter = Arc<Mutex<HashMap<String, TokenBucket>>>;
async fn rate_limit_middleware(
ConnectInfo(addr): ConnectInfo<SocketAddr>,
request: Request,
next: Next,
) -> Result<Response, StatusCode> {
let limiter: RateLimiter = Arc::new(Mutex::new(HashMap::new()));
let client_ip = addr.ip().to_string();
let mut buckets = limiter.lock().await;
let bucket = buckets
.entry(client_ip)
.or_insert_with(|| TokenBucket::new(10.0, 1.0)); // 10 requests, 1 per second
if bucket.try_consume(1.0) {
drop(buckets);
Ok(next.run(request).await)
} else {
Err(StatusCode::TOO_MANY_REQUESTS)
}
}
Request Modification Middleware
Middleware can also modify requests before they reach handlers:
use axum::{
body::Body,
extract::Request,
http::{HeaderMap, HeaderName, HeaderValue},
middleware::Next,
response::Response,
};
async fn cors_middleware(
mut request: Request,
next: Next,
) -> Result<Response, StatusCode> {
// Add custom headers to request
request.headers_mut().insert(
HeaderName::from_static("x-processed-by"),
HeaderValue::from_static("rust-middleware"),
);
let mut response = next.run(request).await;
// Add CORS headers to response
let headers = response.headers_mut();
headers.insert(
HeaderName::from_static("access-control-allow-origin"),
HeaderValue::from_static("*"),
);
headers.insert(
HeaderName::from_static("access-control-allow-methods"),
HeaderValue::from_static("GET, POST, PUT, DELETE, OPTIONS"),
);
Ok(response)
}
Error Handling Middleware
Robust error handling is crucial for production applications:
use axum::{
extract::Request,
http::StatusCode,
middleware::Next,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
async fn error_handler_middleware(
request: Request,
next: Next,
) -> Result<Response, StatusCode> {
match next.run(request).await {
Ok(response) => Ok(response),
Err(_) => {
let error_response = Json(json!({
"error": "Internal server error",
"code": 500
}));
Ok((StatusCode::INTERNAL_SERVER_ERROR, error_response).into_response())
}
}
}
Chaining Multiple Middlewares
You can combine multiple middlewares using tower's ServiceBuilder
:
use tower::ServiceBuilder;
use tower_http::{
compression::CompressionLayer,
timeout::TimeoutLayer,
};
let app = Router::new()
.route("/", get(handler))
.layer(
ServiceBuilder::new()
.layer(TimeoutLayer::new(Duration::from_secs(30)))
.layer(CompressionLayer::new())
.layer(axum::middleware::from_fn(logging_middleware))
.layer(axum::middleware::from_fn(cors_middleware))
);
Testing Middleware
Testing middleware is important for ensuring reliability:
#[cfg(test)]
mod tests {
use super::*;
use axum::{
body::Body,
http::{Request, StatusCode},
};
use tower::ServiceExt;
#[tokio::test]
async fn test_logging_middleware() {
let app = Router::new()
.route("/test", get(|| async { "OK" }))
.layer(axum::middleware::from_fn(logging_middleware));
let response = app
.oneshot(
Request::builder()
.uri("/test")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
}
Performance Considerations
When implementing middleware, consider these performance aspects:
- Minimize allocations: Reuse buffers and avoid unnecessary string allocations
- Use async/await efficiently: Don't block the async runtime
- Cache expensive operations: Store computed values when possible
- Profile your middleware: Use tools like
cargo flamegraph
to identify bottlenecks
Common Use Cases
Custom middleware is particularly useful for:
- Web scraping tools: Adding retry logic and request throttling
- API gateways: Implementing authentication and rate limiting
- Monitoring systems: Collecting metrics and distributed tracing
- Security applications: Validating requests and sanitizing responses
When building web scraping applications, middleware can help you handle authentication in browser automation tools by managing session tokens and cookies across requests.
For complex applications that need to monitor network requests, middleware provides a centralized location to implement logging and analytics without modifying individual handlers.
Conclusion
Custom middleware in Rust provides a powerful way to implement cross-cutting concerns in your HTTP applications. Whether you're using Axum, Actix-web, or another framework, the patterns shown here will help you build robust, maintainable middleware that enhances your application's functionality while keeping your code organized and testable.
The key to effective middleware design is keeping each middleware focused on a single responsibility and ensuring they compose well together. With Rust's type system and async capabilities, you can build high-performance middleware that scales to handle production workloads.