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