sentinel_proxy/
http_helpers.rs

1//! HTTP request and response helpers for Sentinel proxy
2//!
3//! This module provides utilities for:
4//! - Extracting request information from Pingora sessions
5//! - Writing HTTP responses to Pingora sessions
6//! - Trace ID extraction from headers
7//!
8//! These helpers reduce boilerplate in the main proxy logic and ensure
9//! consistent handling of HTTP operations.
10
11use bytes::Bytes;
12use http::Response;
13use http_body_util::{BodyExt, Full};
14use pingora::http::ResponseHeader;
15use pingora::prelude::*;
16use pingora::proxy::Session;
17use std::collections::HashMap;
18
19use crate::routing::RequestInfo;
20use crate::trace_id::{generate_for_format, TraceIdFormat};
21
22// ============================================================================
23// Request Helpers
24// ============================================================================
25
26/// Extract request info from a Pingora session
27///
28/// Builds a `RequestInfo` struct from the session's request headers,
29/// suitable for route matching and processing.
30///
31/// # Example
32///
33/// ```ignore
34/// let request_info = extract_request_info(session);
35/// let route = router.match_request(&request_info);
36/// ```
37pub fn extract_request_info(session: &Session) -> RequestInfo {
38    let req_header = session.req_header();
39
40    let mut headers = HashMap::new();
41    for (name, value) in req_header.headers.iter() {
42        if let Ok(value_str) = value.to_str() {
43            headers.insert(name.as_str().to_lowercase(), value_str.to_string());
44        }
45    }
46
47    let host = headers.get("host").cloned().unwrap_or_default();
48    let path = req_header.uri.path().to_string();
49
50    RequestInfo {
51        method: req_header.method.as_str().to_string(),
52        path: path.clone(),
53        host,
54        headers,
55        query_params: RequestInfo::parse_query_params(&path),
56    }
57}
58
59/// Extract or generate a trace ID from request headers
60///
61/// Looks for existing trace ID headers in order of preference:
62/// 1. `X-Trace-Id`
63/// 2. `X-Correlation-Id`
64/// 3. `X-Request-Id`
65///
66/// If none are found, generates a new TinyFlake trace ID (11 chars).
67/// See [`crate::trace_id`] module for TinyFlake format details.
68///
69/// # Example
70///
71/// ```ignore
72/// let trace_id = get_or_create_trace_id(session, TraceIdFormat::TinyFlake);
73/// tracing::info!(trace_id = %trace_id, "Processing request");
74/// ```
75pub fn get_or_create_trace_id(session: &Session, format: TraceIdFormat) -> String {
76    let req_header = session.req_header();
77
78    // Check for existing trace ID headers (in order of preference)
79    const TRACE_HEADERS: [&str; 3] = ["x-trace-id", "x-correlation-id", "x-request-id"];
80
81    for header_name in &TRACE_HEADERS {
82        if let Some(value) = req_header.headers.get(*header_name) {
83            if let Ok(id) = value.to_str() {
84                if !id.is_empty() {
85                    return id.to_string();
86                }
87            }
88        }
89    }
90
91    // Generate new trace ID using configured format
92    generate_for_format(format)
93}
94
95/// Extract or generate a trace ID (convenience function using TinyFlake default)
96///
97/// This is a convenience wrapper around [`get_or_create_trace_id`] that uses
98/// the default TinyFlake format.
99#[inline]
100pub fn get_or_create_trace_id_default(session: &Session) -> String {
101    get_or_create_trace_id(session, TraceIdFormat::default())
102}
103
104// ============================================================================
105// Response Helpers
106// ============================================================================
107
108/// Write an HTTP response to a Pingora session
109///
110/// Handles the conversion from `http::Response<Full<Bytes>>` to Pingora's
111/// format and writes it to the session.
112///
113/// # Arguments
114///
115/// * `session` - The Pingora session to write to
116/// * `response` - The HTTP response to write
117/// * `keepalive_secs` - Keepalive timeout in seconds (None = disable keepalive)
118///
119/// # Returns
120///
121/// Returns `Ok(())` on success or an error if writing fails.
122///
123/// # Example
124///
125/// ```ignore
126/// let response = Response::builder()
127///     .status(200)
128///     .body(Full::new(Bytes::from("OK")))?;
129/// write_response(session, response, Some(60)).await?;
130/// ```
131pub async fn write_response(
132    session: &mut Session,
133    response: Response<Full<Bytes>>,
134    keepalive_secs: Option<u64>,
135) -> Result<(), Box<Error>> {
136    let status = response.status().as_u16();
137
138    // Collect headers to owned strings to avoid lifetime issues
139    let headers_owned: Vec<(String, String)> = response
140        .headers()
141        .iter()
142        .map(|(k, v)| {
143            (
144                k.as_str().to_string(),
145                v.to_str().unwrap_or("").to_string(),
146            )
147        })
148        .collect();
149
150    // Extract body bytes
151    let full_body = response.into_body();
152    let body_bytes: Bytes = BodyExt::collect(full_body)
153        .await
154        .map(|collected| collected.to_bytes())
155        .unwrap_or_default();
156
157    // Build Pingora response header
158    let mut resp_header = ResponseHeader::build(status, None)?;
159    for (key, value) in headers_owned {
160        resp_header.insert_header(key, &value)?;
161    }
162
163    // Write response to session
164    session.set_keepalive(keepalive_secs);
165    session
166        .write_response_header(Box::new(resp_header), false)
167        .await?;
168    session.write_response_body(Some(body_bytes), true).await?;
169
170    Ok(())
171}
172
173/// Write an error response to a Pingora session
174///
175/// Convenience wrapper for error responses with status code, body, and content type.
176///
177/// # Arguments
178///
179/// * `session` - The Pingora session to write to
180/// * `status` - HTTP status code
181/// * `body` - Response body as string
182/// * `content_type` - Content-Type header value
183pub async fn write_error(
184    session: &mut Session,
185    status: u16,
186    body: &str,
187    content_type: &str,
188) -> Result<(), Box<Error>> {
189    let mut resp_header = ResponseHeader::build(status, None)?;
190    resp_header.insert_header("Content-Type", content_type)?;
191    resp_header.insert_header("Content-Length", &body.len().to_string())?;
192
193    session.set_keepalive(None);
194    session
195        .write_response_header(Box::new(resp_header), false)
196        .await?;
197    session
198        .write_response_body(Some(Bytes::copy_from_slice(body.as_bytes())), true)
199        .await?;
200
201    Ok(())
202}
203
204/// Write a plain text error response
205///
206/// Shorthand for `write_error` with `text/plain; charset=utf-8` content type.
207pub async fn write_text_error(
208    session: &mut Session,
209    status: u16,
210    message: &str,
211) -> Result<(), Box<Error>> {
212    write_error(session, status, message, "text/plain; charset=utf-8").await
213}
214
215/// Write a JSON error response
216///
217/// Creates a JSON object with `error` and optional `message` fields.
218///
219/// # Example
220///
221/// ```ignore
222/// // Produces: {"error":"not_found","message":"Resource does not exist"}
223/// write_json_error(session, 404, "not_found", Some("Resource does not exist")).await?;
224/// ```
225pub async fn write_json_error(
226    session: &mut Session,
227    status: u16,
228    error: &str,
229    message: Option<&str>,
230) -> Result<(), Box<Error>> {
231    let body = match message {
232        Some(msg) => format!(r#"{{"error":"{}","message":"{}"}}"#, error, msg),
233        None => format!(r#"{{"error":"{}"}}"#, error),
234    };
235    write_error(session, status, &body, "application/json").await
236}
237
238// ============================================================================
239// Tests
240// ============================================================================
241
242#[cfg(test)]
243mod tests {
244    // Trace ID generation tests are in crate::trace_id module.
245    // Integration tests for get_or_create_trace_id require mocking Pingora session.
246    // See crates/proxy/tests/ for integration test examples.
247}