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}