|
| 1 | +//! Generic proxy handler for configuration-driven transparent proxying. |
| 2 | +//! |
| 3 | +//! This module provides a flexible proxy system that routes requests based on |
| 4 | +//! configuration rather than hardcoded routes, making it easy to add new |
| 5 | +//! integration partners without code changes. |
| 6 | +
|
| 7 | +use error_stack::{Report, ResultExt}; |
| 8 | +use fastly::http::{header, Method}; |
| 9 | +use fastly::{Request, Response}; |
| 10 | + |
| 11 | +use crate::backend::ensure_backend_from_url; |
| 12 | +use crate::error::TrustedServerError; |
| 13 | +use crate::settings::{ProxyMapping, Settings}; |
| 14 | + |
| 15 | +/// Handles generic transparent proxying based on configuration mappings. |
| 16 | +/// |
| 17 | +/// This function: |
| 18 | +/// 1. Finds a matching proxy mapping from settings |
| 19 | +/// 2. Validates the HTTP method is allowed |
| 20 | +/// 3. Extracts the path after the prefix |
| 21 | +/// 4. Builds the target URL with query parameters |
| 22 | +/// 5. Copies headers and body |
| 23 | +/// 6. Forwards the request to the target |
| 24 | +/// 7. Returns the response transparently |
| 25 | +/// |
| 26 | +/// # Example Flow |
| 27 | +/// |
| 28 | +/// ```text |
| 29 | +/// Config: |
| 30 | +/// prefix: "/permutive/api" |
| 31 | +/// target: "https://api.permutive.com" |
| 32 | +/// |
| 33 | +/// Request: GET /permutive/api/v2/projects?key=123 |
| 34 | +/// ↓ |
| 35 | +/// Extract path: /v2/projects |
| 36 | +/// ↓ |
| 37 | +/// Build URL: https://api.permutive.com/v2/projects?key=123 |
| 38 | +/// ↓ |
| 39 | +/// Forward and return response |
| 40 | +/// ``` |
| 41 | +/// |
| 42 | +/// # Errors |
| 43 | +/// |
| 44 | +/// Returns a [`TrustedServerError`] if: |
| 45 | +/// - No matching proxy mapping found |
| 46 | +/// - HTTP method not allowed for this mapping |
| 47 | +/// - Target URL construction fails |
| 48 | +/// - Backend communication fails |
| 49 | +pub async fn handle_generic_proxy( |
| 50 | + settings: &Settings, |
| 51 | + mut req: Request, |
| 52 | +) -> Result<Response, Report<TrustedServerError>> { |
| 53 | + let path = req.get_path(); |
| 54 | + let method = req.get_method(); |
| 55 | + |
| 56 | + log::info!("Generic proxy request: {} {}", method, path); |
| 57 | + |
| 58 | + // Find matching proxy mapping |
| 59 | + let mapping = find_proxy_mapping(settings, path, method)?; |
| 60 | + |
| 61 | + log::info!( |
| 62 | + "Matched proxy mapping: {} → {} ({})", |
| 63 | + mapping.prefix, |
| 64 | + mapping.target, |
| 65 | + mapping.description |
| 66 | + ); |
| 67 | + |
| 68 | + // Extract target path |
| 69 | + let target_path = mapping |
| 70 | + .extract_target_path(path) |
| 71 | + .ok_or_else(|| TrustedServerError::Proxy { |
| 72 | + message: format!( |
| 73 | + "Failed to extract target path from {} with prefix {}", |
| 74 | + path, mapping.prefix |
| 75 | + ), |
| 76 | + })?; |
| 77 | + |
| 78 | + // Build full target URL with query parameters |
| 79 | + let target_url = build_target_url(&mapping.target, target_path, &req)?; |
| 80 | + |
| 81 | + log::info!("Forwarding to: {}", target_url); |
| 82 | + |
| 83 | + // Create new request to target |
| 84 | + let mut target_req = Request::new(method.clone(), &target_url); |
| 85 | + |
| 86 | + // Copy headers |
| 87 | + copy_request_headers(&req, &mut target_req); |
| 88 | + |
| 89 | + // Copy body for methods that support it |
| 90 | + if has_body(method) { |
| 91 | + let body = req.take_body(); |
| 92 | + target_req.set_body(body); |
| 93 | + } |
| 94 | + |
| 95 | + // Get backend and forward request |
| 96 | + let backend_name = ensure_backend_from_url(&mapping.target)?; |
| 97 | + |
| 98 | + let target_response = target_req |
| 99 | + .send(backend_name) |
| 100 | + .change_context(TrustedServerError::Proxy { |
| 101 | + message: format!("Failed to forward request to {}", target_url), |
| 102 | + })?; |
| 103 | + |
| 104 | + log::info!( |
| 105 | + "Target responded with status: {}", |
| 106 | + target_response.get_status() |
| 107 | + ); |
| 108 | + |
| 109 | + // Return response transparently |
| 110 | + Ok(target_response) |
| 111 | +} |
| 112 | + |
| 113 | +/// Finds a proxy mapping that matches the given path and method. |
| 114 | +fn find_proxy_mapping<'a>( |
| 115 | + settings: &'a Settings, |
| 116 | + path: &str, |
| 117 | + method: &Method, |
| 118 | +) -> Result<&'a ProxyMapping, Report<TrustedServerError>> { |
| 119 | + settings |
| 120 | + .proxy_mappings |
| 121 | + .iter() |
| 122 | + .find(|mapping| { |
| 123 | + mapping.matches_path(path) && mapping.supports_method(method.as_str()) |
| 124 | + }) |
| 125 | + .ok_or_else(|| { |
| 126 | + TrustedServerError::Proxy { |
| 127 | + message: format!( |
| 128 | + "No proxy mapping found for {} {}. Available prefixes: [{}]", |
| 129 | + method, |
| 130 | + path, |
| 131 | + settings |
| 132 | + .proxy_mappings |
| 133 | + .iter() |
| 134 | + .map(|m| m.prefix.as_str()) |
| 135 | + .collect::<Vec<_>>() |
| 136 | + .join(", ") |
| 137 | + ), |
| 138 | + } |
| 139 | + .into() |
| 140 | + }) |
| 141 | +} |
| 142 | + |
| 143 | +/// Builds the full target URL including path and query parameters. |
| 144 | +fn build_target_url( |
| 145 | + base_url: &str, |
| 146 | + target_path: &str, |
| 147 | + req: &Request, |
| 148 | +) -> Result<String, Report<TrustedServerError>> { |
| 149 | + // Get query string if present |
| 150 | + let query = req |
| 151 | + .get_url() |
| 152 | + .query() |
| 153 | + .map(|q| format!("?{}", q)) |
| 154 | + .unwrap_or_default(); |
| 155 | + |
| 156 | + // Build full URL |
| 157 | + let url = format!("{}{}{}", base_url, target_path, query); |
| 158 | + |
| 159 | + Ok(url) |
| 160 | +} |
| 161 | + |
| 162 | +/// Copies relevant headers from the original request to the target request. |
| 163 | +fn copy_request_headers(from: &Request, to: &mut Request) { |
| 164 | + // Standard headers to forward |
| 165 | + let headers_to_copy = [ |
| 166 | + header::CONTENT_TYPE, |
| 167 | + header::ACCEPT, |
| 168 | + header::USER_AGENT, |
| 169 | + header::AUTHORIZATION, |
| 170 | + header::ACCEPT_LANGUAGE, |
| 171 | + header::ACCEPT_ENCODING, |
| 172 | + ]; |
| 173 | + |
| 174 | + for header_name in &headers_to_copy { |
| 175 | + if let Some(value) = from.get_header(header_name) { |
| 176 | + to.set_header(header_name, value); |
| 177 | + } |
| 178 | + } |
| 179 | + |
| 180 | + // Copy any X-* custom headers |
| 181 | + for header_name in from.get_header_names() { |
| 182 | + let name_str = header_name.as_str(); |
| 183 | + if name_str.starts_with("x-") || name_str.starts_with("X-") { |
| 184 | + if let Some(value) = from.get_header(header_name) { |
| 185 | + to.set_header(header_name, value); |
| 186 | + } |
| 187 | + } |
| 188 | + } |
| 189 | +} |
| 190 | + |
| 191 | +/// Checks if the HTTP method typically includes a request body. |
| 192 | +fn has_body(method: &Method) -> bool { |
| 193 | + matches!(method, &Method::POST | &Method::PUT | &Method::PATCH) |
| 194 | +} |
| 195 | + |
| 196 | +/// Helper function to check if any proxy mapping matches the given path. |
| 197 | +pub fn has_proxy_mapping(settings: &Settings, path: &str) -> bool { |
| 198 | + settings |
| 199 | + .proxy_mappings |
| 200 | + .iter() |
| 201 | + .any(|mapping| mapping.matches_path(path)) |
| 202 | +} |
| 203 | + |
| 204 | +#[cfg(test)] |
| 205 | +mod tests { |
| 206 | + use super::*; |
| 207 | + use crate::settings::ProxyMapping; |
| 208 | + |
| 209 | + #[test] |
| 210 | + fn test_proxy_mapping_matches_path() { |
| 211 | + let mapping = ProxyMapping { |
| 212 | + prefix: "/permutive/api".to_string(), |
| 213 | + target: "https://api.permutive.com".to_string(), |
| 214 | + methods: vec!["GET".to_string(), "POST".to_string()], |
| 215 | + description: "Test".to_string(), |
| 216 | + }; |
| 217 | + |
| 218 | + assert!(mapping.matches_path("/permutive/api/v2/projects")); |
| 219 | + assert!(mapping.matches_path("/permutive/api")); |
| 220 | + assert!(!mapping.matches_path("/permutive/other")); |
| 221 | + assert!(!mapping.matches_path("/other/api")); |
| 222 | + } |
| 223 | + |
| 224 | + #[test] |
| 225 | + fn test_proxy_mapping_supports_method() { |
| 226 | + let mapping = ProxyMapping { |
| 227 | + prefix: "/test".to_string(), |
| 228 | + target: "https://example.com".to_string(), |
| 229 | + methods: vec!["GET".to_string(), "POST".to_string()], |
| 230 | + description: "Test".to_string(), |
| 231 | + }; |
| 232 | + |
| 233 | + assert!(mapping.supports_method("GET")); |
| 234 | + assert!(mapping.supports_method("POST")); |
| 235 | + assert!(mapping.supports_method("get")); // case insensitive |
| 236 | + assert!(mapping.supports_method("post")); |
| 237 | + assert!(!mapping.supports_method("DELETE")); |
| 238 | + assert!(!mapping.supports_method("PUT")); |
| 239 | + } |
| 240 | + |
| 241 | + #[test] |
| 242 | + fn test_proxy_mapping_extract_target_path() { |
| 243 | + let mapping = ProxyMapping { |
| 244 | + prefix: "/permutive/api".to_string(), |
| 245 | + target: "https://api.permutive.com".to_string(), |
| 246 | + methods: vec!["GET".to_string()], |
| 247 | + description: "Test".to_string(), |
| 248 | + }; |
| 249 | + |
| 250 | + assert_eq!( |
| 251 | + mapping.extract_target_path("/permutive/api/v2/projects"), |
| 252 | + Some("/v2/projects") |
| 253 | + ); |
| 254 | + assert_eq!(mapping.extract_target_path("/permutive/api"), Some("")); |
| 255 | + assert_eq!( |
| 256 | + mapping.extract_target_path("/permutive/api/"), |
| 257 | + Some("/") |
| 258 | + ); |
| 259 | + assert_eq!(mapping.extract_target_path("/other/path"), None); |
| 260 | + } |
| 261 | + |
| 262 | + #[test] |
| 263 | + fn test_build_target_url_without_query() { |
| 264 | + let base_url = "https://api.permutive.com"; |
| 265 | + let target_path = "/v2/projects"; |
| 266 | + let expected = "https://api.permutive.com/v2/projects"; |
| 267 | + |
| 268 | + let url = format!("{}{}", base_url, target_path); |
| 269 | + assert_eq!(url, expected); |
| 270 | + } |
| 271 | + |
| 272 | + #[test] |
| 273 | + fn test_build_target_url_with_query() { |
| 274 | + let base_url = "https://api.permutive.com"; |
| 275 | + let target_path = "/v2/projects"; |
| 276 | + let query = "?key=123&foo=bar"; |
| 277 | + let expected = "https://api.permutive.com/v2/projects?key=123&foo=bar"; |
| 278 | + |
| 279 | + let url = format!("{}{}{}", base_url, target_path, query); |
| 280 | + assert_eq!(url, expected); |
| 281 | + } |
| 282 | + |
| 283 | + #[test] |
| 284 | + fn test_has_body() { |
| 285 | + assert!(has_body(&Method::POST)); |
| 286 | + assert!(has_body(&Method::PUT)); |
| 287 | + assert!(has_body(&Method::PATCH)); |
| 288 | + assert!(!has_body(&Method::GET)); |
| 289 | + assert!(!has_body(&Method::DELETE)); |
| 290 | + assert!(!has_body(&Method::HEAD)); |
| 291 | + } |
| 292 | +} |
0 commit comments