Skip to content

Commit dc0fee3

Browse files
wip permutive
1 parent 749d961 commit dc0fee3

File tree

7 files changed

+362
-11
lines changed

7 files changed

+362
-11
lines changed

crates/common/src/generic_proxy.rs

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
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+
}

crates/common/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ pub mod constants;
3030
pub mod cookies;
3131
pub mod creative;
3232
pub mod error;
33+
pub mod generic_proxy;
3334
pub mod geo;
3435
pub mod html_processor;
3536
pub mod http_util;

crates/common/src/settings.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,46 @@ impl Synthetic {
142142
}
143143
}
144144

145+
#[derive(Debug, Clone, Deserialize, Serialize, Validate)]
146+
pub struct ProxyMapping {
147+
/// URL prefix to match (e.g., "/permutive/api")
148+
#[validate(length(min = 1))]
149+
pub prefix: String,
150+
/// Target base URL (e.g., "https://api.permutive.com")
151+
#[validate(length(min = 1))]
152+
pub target: String,
153+
/// HTTP methods to allow (e.g., ["GET", "POST"])
154+
#[serde(default = "default_proxy_methods")]
155+
pub methods: Vec<String>,
156+
/// Optional description for documentation
157+
#[serde(default)]
158+
pub description: String,
159+
}
160+
161+
fn default_proxy_methods() -> Vec<String> {
162+
vec!["GET".to_string(), "POST".to_string()]
163+
}
164+
165+
impl ProxyMapping {
166+
/// Check if this mapping matches the given path
167+
#[allow(dead_code)]
168+
pub fn matches_path(&self, path: &str) -> bool {
169+
path.starts_with(&self.prefix)
170+
}
171+
172+
/// Check if this mapping supports the given HTTP method
173+
#[allow(dead_code)]
174+
pub fn supports_method(&self, method: &str) -> bool {
175+
self.methods.iter().any(|m| m.eq_ignore_ascii_case(method))
176+
}
177+
178+
/// Extract the target path by stripping the prefix
179+
#[allow(dead_code)]
180+
pub fn extract_target_path<'a>(&self, path: &'a str) -> Option<&'a str> {
181+
path.strip_prefix(&self.prefix)
182+
}
183+
}
184+
145185
#[derive(Debug, Default, Deserialize, Serialize, Validate)]
146186
pub struct Handler {
147187
#[validate(length(min = 1), custom(function = validate_path))]
@@ -182,6 +222,9 @@ pub struct Settings {
182222
pub handlers: Vec<Handler>,
183223
#[serde(default)]
184224
pub response_headers: HashMap<String, String>,
225+
#[serde(default, deserialize_with = "vec_from_seq_or_map")]
226+
#[validate(nested)]
227+
pub proxy_mappings: Vec<ProxyMapping>,
185228
}
186229

187230
#[allow(unused)]

crates/common/src/test_support.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ pub mod tests {
3333
opid_store = "test-opid-store"
3434
secret_key = "test-secret-key"
3535
template = "{{client_ip}}:{{user_agent}}:{{first_party_id}}:{{auth_user_id}}:{{publisher_domain}}:{{accept_language}}"
36+
37+
[[proxy_mappings]]
38+
prefix = "/test-proxy/api"
39+
target = "https://api.example.com"
40+
methods = ["GET", "POST"]
41+
description = "Test API Proxy"
3642
"#.to_string()
3743
}
3844

crates/fastly/src/main.rs

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,7 @@ use log_fastly::Logger;
44

55
use trusted_server_common::ad::{handle_server_ad, handle_server_ad_get};
66
use trusted_server_common::auth::enforce_basic_auth;
7-
use trusted_server_common::permutive_proxy::{
8-
handle_permutive_api_proxy, handle_permutive_secure_signals_proxy,
9-
};
7+
use trusted_server_common::generic_proxy::{handle_generic_proxy, has_proxy_mapping};
108
use trusted_server_common::permutive_sdk::handle_permutive_sdk;
119
use trusted_server_common::proxy::{
1210
handle_first_party_click, handle_first_party_proxy, handle_first_party_proxy_rebuild,
@@ -51,14 +49,9 @@ async fn route_request(settings: Settings, req: Request) -> Result<Response, Err
5149

5250
// Match known routes and handle them
5351
let result = match (method, path) {
54-
// Permutive API proxy - /permutive/api/* → api.permutive.com/*
55-
(&Method::GET | &Method::POST, path) if path.starts_with("/permutive/api/") => {
56-
handle_permutive_api_proxy(&settings, req).await
57-
}
58-
59-
// Permutive Secure Signals proxy - /permutive/secure-signal/* → secure-signals.permutive.app/*
60-
(&Method::GET | &Method::POST, path) if path.starts_with("/permutive/secure-signal/") => {
61-
handle_permutive_secure_signals_proxy(&settings, req).await
52+
// Generic proxy handler for config-driven proxying
53+
(_, path) if has_proxy_mapping(&settings, path) => {
54+
handle_generic_proxy(&settings, req).await
6255
}
6356

6457
// Serve the Permutive SDK (proxied from Permutive CDN)

0 commit comments

Comments
 (0)