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}