lmrc_proxy/
client.rs

1//! HTTP reverse proxy client
2//!
3//! Provides functionality for proxying HTTP requests to backend services.
4
5use axum::{
6    body::Body,
7    extract::Request,
8    http::HeaderName,
9    response::Response,
10};
11use reqwest::Client;
12use std::time::Duration;
13
14use crate::error::{ProxyError, ProxyResult};
15
16/// Proxy configuration
17#[derive(Debug, Clone)]
18pub struct ProxyConfig {
19    /// Preserve Host header from original request
20    pub preserve_host: bool,
21    /// Request timeout
22    pub timeout: Duration,
23    /// Maximum body size in bytes
24    pub max_body_size: usize,
25    /// Add X-Forwarded-* headers
26    pub add_forwarded_headers: bool,
27}
28
29impl Default for ProxyConfig {
30    fn default() -> Self {
31        Self {
32            preserve_host: false,
33            timeout: Duration::from_secs(30),
34            max_body_size: 10 * 1024 * 1024, // 10MB
35            add_forwarded_headers: true,
36        }
37    }
38}
39
40/// Proxy an HTTP request to a backend service
41///
42/// ## Example
43///
44/// ```rust,no_run
45/// use axum::extract::Request;
46/// use lmrc_proxy::{proxy_request, ProxyConfig};
47///
48/// async fn handler(request: Request) -> Result<axum::response::Response, lmrc_proxy::ProxyError> {
49///     proxy_request(request, "http://backend:8080", ProxyConfig::default()).await
50/// }
51/// ```
52pub async fn proxy_request(
53    request: Request,
54    backend_url: &str,
55    config: ProxyConfig,
56) -> ProxyResult<Response> {
57    let client = Client::builder()
58        .timeout(config.timeout)
59        .build()
60        .map_err(|e| ProxyError::ClientCreation(e.to_string()))?;
61
62    // Extract request components
63    let method = request.method().clone();
64    let uri = request.uri().clone();
65    let headers = request.headers().clone();
66
67    let body = axum::body::to_bytes(request.into_body(), config.max_body_size)
68        .await
69        .map_err(|e| ProxyError::RequestBody(e.to_string()))?;
70
71    // Build target URL
72    let path_and_query = uri.path_and_query().map(|pq| pq.as_str()).unwrap_or("/");
73    let target_url = format!("{}{}", backend_url.trim_end_matches('/'), path_and_query);
74
75    tracing::debug!(
76        method = %method,
77        uri = %uri,
78        target = %target_url,
79        "Proxying request"
80    );
81
82    // Build request
83    let mut req_builder = client.request(method.clone(), &target_url);
84
85    // Forward headers (filter out hop-by-hop headers)
86    for (name, value) in headers.iter() {
87        if !is_hop_by_hop_header(name) {
88            req_builder = req_builder.header(name, value);
89        }
90    }
91
92    // Add X-Forwarded headers
93    if config.add_forwarded_headers
94        && let Some(host) = headers.get("host").and_then(|h| h.to_str().ok())
95    {
96        req_builder = req_builder.header("X-Forwarded-Host", host);
97    }
98    // Could add X-Forwarded-For, X-Forwarded-Proto, etc.
99
100    // Send request
101    let backend_response = req_builder
102        .body(body.to_vec())
103        .send()
104        .await
105        .map_err(|e| ProxyError::BackendRequest(e.to_string()))?;
106
107    // Build response
108    let status = backend_response.status();
109    let response_headers = backend_response.headers().clone();
110    let body_bytes = backend_response
111        .bytes()
112        .await
113        .map_err(|e| ProxyError::ResponseBody(e.to_string()))?;
114
115    let mut response = Response::new(Body::from(body_bytes));
116    *response.status_mut() = status;
117
118    // Copy response headers
119    for (name, value) in response_headers.iter() {
120        if !is_hop_by_hop_header(name) {
121            response.headers_mut().insert(name, value.clone());
122        }
123    }
124
125    Ok(response)
126}
127
128/// Check if header is hop-by-hop (should not be forwarded)
129///
130/// Hop-by-hop headers are meaningful only for a single transport-level connection
131/// and must not be stored by caches or forwarded by proxies.
132fn is_hop_by_hop_header(name: &HeaderName) -> bool {
133    matches!(
134        name.as_str().to_lowercase().as_str(),
135        "connection"
136            | "keep-alive"
137            | "proxy-authenticate"
138            | "proxy-authorization"
139            | "te"
140            | "trailers"
141            | "transfer-encoding"
142            | "upgrade"
143    )
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    #[test]
151    fn test_hop_by_hop_headers() {
152        assert!(is_hop_by_hop_header(&HeaderName::from_static("connection")));
153        assert!(is_hop_by_hop_header(&HeaderName::from_static("keep-alive")));
154        assert!(is_hop_by_hop_header(&HeaderName::from_static("upgrade")));
155
156        assert!(!is_hop_by_hop_header(&HeaderName::from_static("content-type")));
157        assert!(!is_hop_by_hop_header(&HeaderName::from_static("authorization")));
158    }
159
160    #[test]
161    fn test_default_config() {
162        let config = ProxyConfig::default();
163        assert!(!config.preserve_host);
164        assert!(config.add_forwarded_headers);
165        assert_eq!(config.timeout, Duration::from_secs(30));
166        assert_eq!(config.max_body_size, 10 * 1024 * 1024);
167    }
168}