lmrc-proxy 0.3.16

HTTP reverse proxy and API gateway utilities for LMRC Stack applications
Documentation
//! HTTP reverse proxy client
//!
//! Provides functionality for proxying HTTP requests to backend services.

use axum::{
    body::Body,
    extract::Request,
    http::HeaderName,
    response::Response,
};
use reqwest::Client;
use std::time::Duration;

use crate::error::{ProxyError, ProxyResult};

/// Proxy configuration
#[derive(Debug, Clone)]
pub struct ProxyConfig {
    /// Preserve Host header from original request
    pub preserve_host: bool,
    /// Request timeout
    pub timeout: Duration,
    /// Maximum body size in bytes
    pub max_body_size: usize,
    /// Add X-Forwarded-* headers
    pub add_forwarded_headers: bool,
}

impl Default for ProxyConfig {
    fn default() -> Self {
        Self {
            preserve_host: false,
            timeout: Duration::from_secs(30),
            max_body_size: 10 * 1024 * 1024, // 10MB
            add_forwarded_headers: true,
        }
    }
}

/// Proxy an HTTP request to a backend service
///
/// ## Example
///
/// ```rust,no_run
/// use axum::extract::Request;
/// use lmrc_proxy::{proxy_request, ProxyConfig};
///
/// async fn handler(request: Request) -> Result<axum::response::Response, lmrc_proxy::ProxyError> {
///     proxy_request(request, "http://backend:8080", ProxyConfig::default()).await
/// }
/// ```
pub async fn proxy_request(
    request: Request,
    backend_url: &str,
    config: ProxyConfig,
) -> ProxyResult<Response> {
    let client = Client::builder()
        .timeout(config.timeout)
        .build()
        .map_err(|e| ProxyError::ClientCreation(e.to_string()))?;

    // Extract request components
    let method = request.method().clone();
    let uri = request.uri().clone();
    let headers = request.headers().clone();

    let body = axum::body::to_bytes(request.into_body(), config.max_body_size)
        .await
        .map_err(|e| ProxyError::RequestBody(e.to_string()))?;

    // Build target URL
    let path_and_query = uri.path_and_query().map(|pq| pq.as_str()).unwrap_or("/");
    let target_url = format!("{}{}", backend_url.trim_end_matches('/'), path_and_query);

    tracing::debug!(
        method = %method,
        uri = %uri,
        target = %target_url,
        "Proxying request"
    );

    // Build request
    let mut req_builder = client.request(method.clone(), &target_url);

    // Forward headers (filter out hop-by-hop headers)
    for (name, value) in headers.iter() {
        if !is_hop_by_hop_header(name) {
            req_builder = req_builder.header(name, value);
        }
    }

    // Add X-Forwarded headers
    if config.add_forwarded_headers
        && let Some(host) = headers.get("host").and_then(|h| h.to_str().ok())
    {
        req_builder = req_builder.header("X-Forwarded-Host", host);
    }
    // Could add X-Forwarded-For, X-Forwarded-Proto, etc.

    // Send request
    let backend_response = req_builder
        .body(body.to_vec())
        .send()
        .await
        .map_err(|e| ProxyError::BackendRequest(e.to_string()))?;

    // Build response
    let status = backend_response.status();
    let response_headers = backend_response.headers().clone();
    let body_bytes = backend_response
        .bytes()
        .await
        .map_err(|e| ProxyError::ResponseBody(e.to_string()))?;

    let mut response = Response::new(Body::from(body_bytes));
    *response.status_mut() = status;

    // Copy response headers
    for (name, value) in response_headers.iter() {
        if !is_hop_by_hop_header(name) {
            response.headers_mut().insert(name, value.clone());
        }
    }

    Ok(response)
}

/// Check if header is hop-by-hop (should not be forwarded)
///
/// Hop-by-hop headers are meaningful only for a single transport-level connection
/// and must not be stored by caches or forwarded by proxies.
fn is_hop_by_hop_header(name: &HeaderName) -> bool {
    matches!(
        name.as_str().to_lowercase().as_str(),
        "connection"
            | "keep-alive"
            | "proxy-authenticate"
            | "proxy-authorization"
            | "te"
            | "trailers"
            | "transfer-encoding"
            | "upgrade"
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_hop_by_hop_headers() {
        assert!(is_hop_by_hop_header(&HeaderName::from_static("connection")));
        assert!(is_hop_by_hop_header(&HeaderName::from_static("keep-alive")));
        assert!(is_hop_by_hop_header(&HeaderName::from_static("upgrade")));

        assert!(!is_hop_by_hop_header(&HeaderName::from_static("content-type")));
        assert!(!is_hop_by_hop_header(&HeaderName::from_static("authorization")));
    }

    #[test]
    fn test_default_config() {
        let config = ProxyConfig::default();
        assert!(!config.preserve_host);
        assert!(config.add_forwarded_headers);
        assert_eq!(config.timeout, Duration::from_secs(30));
        assert_eq!(config.max_body_size, 10 * 1024 * 1024);
    }
}