ESC
Type to search...

Middleware

Rate limiting, compression, CORS, timeout, and custom middleware

Middleware intercepts requests before they reach your handler and responses before they are sent to the client. Each piece in the chain can inspect, modify, or short-circuit the request lifecycle.

Rate Limiting

Protects your API from abuse using the token bucket algorithm. Each client gets a quota of tokens that refills continuously at the configured rate.

Rapina::new()
    .with_rate_limit(RateLimitConfig::per_minute(60))
    .discover()
    .listen("127.0.0.1:3000")
    .await

RateLimitConfig is available from use rapina::prelude::*.

ConstructorDescription
RateLimitConfig::new(rps, burst)Requests per second with burst capacity
RateLimitConfig::per_minute(n)Shorthand for n requests per minute
RateLimitConfig::new(2.0, 10)     // 2 req/s, burst of 10
RateLimitConfig::per_minute(120)  // burst of 120, refills at 2 req/s

When the limit is exceeded, Rapina responds 429 Too Many Requests with a Retry-After header.

Key extraction

Limits are applied per client IP by default, read from X-Forwarded-For (leftmost entry) then X-Real-IP, falling back to "unknown". Use KeyExtractor::Custom to limit by any other key:

use std::sync::Arc;

let config = RateLimitConfig::per_minute(100)
    .with_key_extractor(KeyExtractor::Custom(Arc::new(|req| {
        req.headers()
            .get("x-user-id")
            .and_then(|v| v.to_str().ok())
            .unwrap_or("anonymous")
            .to_string()
    })));

New clients start with a full burst. Inactive buckets (idle for more than 10 minutes) are cleaned up every 1000 requests.


Response Compression

Compresses responses automatically, negotiating the algorithm via Accept-Encoding. Gzip takes priority over deflate.

use rapina::middleware::CompressionConfig;

Rapina::new()
    .with_compression(CompressionConfig::default())
    .discover()
    .listen("127.0.0.1:3000")
    .await
FieldDefaultDescription
min_size1024Minimum body size in bytes to compress
level6Compression level 0–9
CompressionConfig::default()
CompressionConfig::new(512, 9)  // min 512 bytes, maximum compression

Compression is skipped when the client does not send Accept-Encoding: gzip or deflate, the response already has a Content-Encoding header, the Content-Type is not compressible (e.g. image/png), or the body is smaller than min_size. Vary: Accept-Encoding is added automatically for correct proxy caching.


CORS

Controls which origins can access your API.

use rapina::middleware::CorsConfig;

Rapina::new()
    .with_cors(CorsConfig::permissive())
    .discover()
    .listen("127.0.0.1:3000")
    .await
ConstructorUse case
CorsConfig::permissive()Development — allows any origin, method, and header
CorsConfig::with_origins(vec![...])Production — restricts to specific origins
// Development
CorsConfig::permissive()

// Production
CorsConfig::with_origins(vec![
    "https://app.example.com".to_string(),
    "https://admin.example.com".to_string(),
])

with_origins defaults to methods GET POST PUT PATCH DELETE OPTIONS and headers Accept Authorization. OPTIONS preflight requests return 204 No Content and never reach your handler. Vary: Origin is added to every response.

Advanced configuration

use rapina::middleware::{CorsConfig, AllowedOrigins, AllowedMethods, AllowedHeaders};
use http::{Method, header};

let cors = CorsConfig {
    allowed_origins: AllowedOrigins::Exact(vec![
        "https://app.example.com".to_string(),
    ]),
    allowed_methods: AllowedMethods::List(vec![
        Method::GET,
        Method::POST,
        Method::DELETE,
    ]),
    allowed_headers: AllowedHeaders::List(vec![
        header::ACCEPT,
        header::AUTHORIZATION,
        header::CONTENT_TYPE,
    ]),
};

Timeout, Body Limit, and Trace ID

These middleware ship with Rapina but are not active by default. Register them with .middleware():

use rapina::middleware::{TimeoutMiddleware, BodyLimitMiddleware, TraceIdMiddleware};
use std::time::Duration;

Rapina::new()
    .middleware(TraceIdMiddleware::new())
    .middleware(TimeoutMiddleware::new(Duration::from_secs(30)))
    .middleware(BodyLimitMiddleware::new(1024 * 1024)) // 1 MB
    .discover()
    .listen("127.0.0.1:3000")
    .await

Timeout

Cancels requests that exceed the configured duration and responds 500 Internal Server Error.

TimeoutMiddleware::default()                      // 30 seconds
TimeoutMiddleware::new(Duration::from_secs(10))  // custom

Body limit

Rejects requests whose Content-Length exceeds the limit with 400 Bad Request. Requests without a Content-Length header are not checked.

BodyLimitMiddleware::default()              // 1 MB
BodyLimitMiddleware::new(5 * 1024 * 1024)  // 5 MB

Trace ID

Assigns a unique identifier to every request for distributed tracing.

  • Accepts an incoming x-trace-id header from upstream; otherwise generates a UUID v4
  • Echoes the trace ID back in the response header x-trace-id

Access it in a handler via the Context extractor:

#[get("/")]
async fn handler(ctx: Context) -> String {
    format!("trace: {}", ctx.trace_id())
}

Custom Middleware

Implement the Middleware trait. Call next.run(req) to continue the chain, or return a response early to short-circuit it.

use rapina::middleware::BoxFuture;
use rapina::response::BoxBody;
use hyper::{Request, Response, body::Incoming};

pub struct ApiKeyMiddleware {
    valid_keys: Vec<String>,
}

impl Middleware for ApiKeyMiddleware {
    fn handle<'a>(
        &'a self,
        req: Request<Incoming>,
        _ctx: &'a RequestContext,
        next: Next<'a>,
    ) -> BoxFuture<'a, Response<BoxBody>> {
        Box::pin(async move {
            let key = req
                .headers()
                .get("x-api-key")
                .and_then(|v| v.to_str().ok());

            match key {
                Some(k) if self.valid_keys.contains(&k.to_string()) => next.run(req).await,
                _ => Error::unauthorized("missing or invalid API key").into_response(),
            }
        })
    }
}

Register with .middleware():

Rapina::new()
    .middleware(ApiKeyMiddleware {
        valid_keys: vec!["secret-123".to_string()],
    })
    .discover()
    .listen("127.0.0.1:3000")
    .await

Injecting data into the request

Middleware can insert typed values into request extensions. Implement FromRequestParts to receive them in a handler:

use rapina::middleware::BoxFuture;
use rapina::response::BoxBody;
use rapina::extract::{FromRequestParts, PathParams};
use rapina::state::AppState;
use hyper::{Request, Response, body::Incoming};
use http::request::Parts;
use std::sync::Arc;

#[derive(Clone)]
pub struct TenantId(pub String);

impl FromRequestParts for TenantId {
    async fn from_request_parts(
        parts: &Parts,
        _params: &PathParams,
        _state: &Arc<AppState>,
    ) -> Result<Self, Error> {
        parts
            .extensions
            .get::<TenantId>()
            .cloned()
            .ok_or_else(|| Error::internal("TenantId not injected by middleware"))
    }
}

pub struct TenantMiddleware;

impl Middleware for TenantMiddleware {
    fn handle<'a>(
        &'a self,
        mut req: Request<Incoming>,
        _ctx: &'a RequestContext,
        next: Next<'a>,
    ) -> BoxFuture<'a, Response<BoxBody>> {
        Box::pin(async move {
            let id = req
                .headers()
                .get("x-tenant-id")
                .and_then(|v| v.to_str().ok())
                .unwrap_or("default")
                .to_string();

            req.extensions_mut().insert(TenantId(id));
            next.run(req).await
        })
    }
}

The handler receives TenantId like any other extractor:

#[get("/data")]
async fn handler(tenant: TenantId) -> Json<serde_json::Value> {
    Json(serde_json::json!({ "tenant": tenant.0 }))
}

Modifying the response

Run next.run(req).await first, then mutate the response before returning it:

use rapina::middleware::BoxFuture;
use rapina::response::BoxBody;
use hyper::{Request, Response, body::Incoming};

pub struct SecurityHeadersMiddleware;

impl Middleware for SecurityHeadersMiddleware {
    fn handle<'a>(
        &'a self,
        req: Request<Incoming>,
        _ctx: &'a RequestContext,
        next: Next<'a>,
    ) -> BoxFuture<'a, Response<BoxBody>> {
        Box::pin(async move {
            let mut res = next.run(req).await;
            let h = res.headers_mut();
            h.insert("X-Content-Type-Options", "nosniff".parse().unwrap());
            h.insert("X-Frame-Options", "DENY".parse().unwrap());
            h.insert("Strict-Transport-Security", "max-age=31536000".parse().unwrap());
            res
        })
    }
}

Middleware ordering

Middleware executes in FIFO order — first registered, first to run on the request and last to run on the response.

Request  →  [A]  →  [B]  →  [C]  →  Handler
Response ←  [A]  ←  [B]  ←  [C]  ←  Handler

Note on authentication: .with_auth() is always appended last during listen(), after all middleware registered via .middleware(), .with_cors(), .with_rate_limit(), and .with_compression().

MiddlewarePositionReason
Trace IDFirstAll downstream logs carry the request ID
CORSBefore rate limitPreflights are answered before consuming any quota
Rate limitBefore authNo JWT work done for clients that will be blocked
CompressionAfter rate limitCompresses the final response including error bodies
AuthLast (framework-managed)Always appended after user-registered middleware
use rapina::middleware::{CorsConfig, CompressionConfig, TraceIdMiddleware};

Rapina::new()
    .middleware(TraceIdMiddleware::new())
    .with_cors(CorsConfig::with_origins(vec!["https://app.example.com".to_string()]))
    .with_rate_limit(RateLimitConfig::per_minute(60))
    .with_compression(CompressionConfig::default())
    .middleware(SecurityHeadersMiddleware)
    .discover()
    .listen("127.0.0.1:3000")
    .await