From 15468090b85d54a0fc65e9e4f2f8c04b5f4a27c5 Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 1 Dec 2025 16:00:22 -0600 Subject: [PATCH 1/3] support wildcard matching --- crates/common/src/integrations/registry.rs | 225 ++++++++++++++++++++- 1 file changed, 217 insertions(+), 8 deletions(-) diff --git a/crates/common/src/integrations/registry.rs b/crates/common/src/integrations/registry.rs index b13e610..3961c01 100644 --- a/crates/common/src/integrations/registry.rs +++ b/crates/common/src/integrations/registry.rs @@ -292,11 +292,36 @@ impl IntegrationRegistry { } } + fn find_route(&self, method: &Method, path: &str) -> Option<&RouteValue> { + // First try exact match + let key = (method.clone(), path.to_string()); + if let Some(route_value) = self.inner.route_map.get(&key) { + return Some(route_value); + } + + // If no exact match, try wildcard matching + // Routes ending with /* should match any path with that prefix + additional segments + for ((route_method, route_path), route_value) in &self.inner.route_map { + if route_method != method { + continue; + } + + if let Some(prefix) = route_path.strip_suffix("/*") { + if path.starts_with(prefix) + && path.len() > prefix.len() + && path[prefix.len()..].starts_with('/') + { + return Some(route_value); + } + } + } + + None + } + /// Return true when any proxy is registered for the provided route. pub fn has_route(&self, method: &Method, path: &str) -> bool { - self.inner - .route_map - .contains_key(&(method.clone(), path.to_string())) + self.find_route(method, path).is_some() } /// Dispatch a proxy request when an integration handles the path. @@ -307,11 +332,7 @@ impl IntegrationRegistry { settings: &Settings, req: Request, ) -> Option>> { - if let Some((proxy, _)) = self - .inner - .route_map - .get(&(method.clone(), path.to_string())) - { + if let Some((proxy, _)) = self.find_route(method, path) { Some(proxy.handle(settings, req).await) } else { None @@ -399,4 +420,192 @@ impl IntegrationRegistry { }), } } + + #[cfg(test)] + pub fn from_routes(routes: HashMap) -> Self { + Self { + inner: Arc::new(IntegrationRegistryInner { + route_map: routes, + routes: Vec::new(), + html_rewriters: Vec::new(), + script_rewriters: Vec::new(), + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Mock integration proxy for testing + struct MockProxy; + + #[async_trait(?Send)] + impl IntegrationProxy for MockProxy { + fn routes(&self) -> Vec { + vec![] + } + + async fn handle( + &self, + _settings: &Settings, + _req: Request, + ) -> Result> { + Ok(Response::new()) + } + } + + #[test] + fn test_exact_route_matching() { + let mut routes = HashMap::new(); + routes.insert( + (Method::GET, "/integrations/test/exact".to_string()), + (Arc::new(MockProxy) as Arc, "test"), + ); + + let registry = IntegrationRegistry::from_routes(routes); + + // Should match exact route + assert!(registry.has_route(&Method::GET, "/integrations/test/exact")); + + // Should not match different paths + assert!(!registry.has_route(&Method::GET, "/integrations/test/other")); + assert!(!registry.has_route(&Method::GET, "/integrations/test/exact/nested")); + + // Should not match different methods + assert!(!registry.has_route(&Method::POST, "/integrations/test/exact")); + } + + #[test] + fn test_wildcard_route_matching() { + let mut routes = HashMap::new(); + routes.insert( + (Method::GET, "/integrations/lockr/api/*".to_string()), + (Arc::new(MockProxy) as Arc, "lockr"), + ); + + let registry = IntegrationRegistry::from_routes(routes); + + // Should match paths under the wildcard prefix + assert!(registry.has_route(&Method::GET, "/integrations/lockr/api/settings")); + assert!(registry.has_route( + &Method::GET, + "/integrations/lockr/api/publisher/app/v1/identityLockr/settings" + )); + assert!(registry.has_route(&Method::GET, "/integrations/lockr/api/page-view")); + assert!(registry.has_route(&Method::GET, "/integrations/lockr/api/a/b/c/d/e")); + + // Should not match paths that don't start with the prefix + assert!(!registry.has_route(&Method::GET, "/integrations/lockr/sdk")); + assert!(!registry.has_route(&Method::GET, "/integrations/lockr/other")); + assert!(!registry.has_route(&Method::GET, "/integrations/other/api/settings")); + + // Should not match different methods + assert!(!registry.has_route(&Method::POST, "/integrations/lockr/api/settings")); + } + + #[test] + fn test_wildcard_and_exact_routes_coexist() { + let mut routes = HashMap::new(); + routes.insert( + (Method::GET, "/integrations/test/api/*".to_string()), + (Arc::new(MockProxy) as Arc, "test"), + ); + routes.insert( + (Method::GET, "/integrations/test/exact".to_string()), + (Arc::new(MockProxy) as Arc, "test"), + ); + + let registry = IntegrationRegistry::from_routes(routes); + + // Exact route should match + assert!(registry.has_route(&Method::GET, "/integrations/test/exact")); + + // Wildcard routes should match + assert!(registry.has_route(&Method::GET, "/integrations/test/api/anything")); + assert!(registry.has_route(&Method::GET, "/integrations/test/api/nested/path")); + + // Non-matching should fail + assert!(!registry.has_route(&Method::GET, "/integrations/test/other")); + } + + #[test] + fn test_multiple_wildcard_routes() { + let mut routes = HashMap::new(); + routes.insert( + (Method::GET, "/integrations/lockr/api/*".to_string()), + (Arc::new(MockProxy) as Arc, "lockr"), + ); + routes.insert( + (Method::POST, "/integrations/lockr/api/*".to_string()), + (Arc::new(MockProxy) as Arc, "lockr"), + ); + routes.insert( + (Method::GET, "/integrations/testlight/api/*".to_string()), + ( + Arc::new(MockProxy) as Arc, + "testlight", + ), + ); + + let registry = IntegrationRegistry::from_routes(routes); + + // Lockr GET routes should match + assert!(registry.has_route(&Method::GET, "/integrations/lockr/api/settings")); + + // Lockr POST routes should match + assert!(registry.has_route(&Method::POST, "/integrations/lockr/api/settings")); + + // Testlight routes should match + assert!(registry.has_route(&Method::GET, "/integrations/testlight/api/auction")); + assert!(registry.has_route(&Method::GET, "/integrations/testlight/api/any-path")); + + // Cross-integration paths should not match + assert!(!registry.has_route(&Method::GET, "/integrations/lockr/other-endpoint")); + assert!(!registry.has_route(&Method::GET, "/integrations/other/api/test")); + } + + #[test] + fn test_wildcard_preserves_casing() { + let mut routes = HashMap::new(); + routes.insert( + (Method::GET, "/integrations/lockr/api/*".to_string()), + (Arc::new(MockProxy) as Arc, "lockr"), + ); + + let registry = IntegrationRegistry::from_routes(routes); + + // Should match with camelCase preserved + assert!(registry.has_route( + &Method::GET, + "/integrations/lockr/api/publisher/app/v1/identityLockr/settings" + )); + assert!(registry.has_route( + &Method::GET, + "/integrations/lockr/api/publisher/app/v1/identitylockr/settings" + )); + } + + #[test] + fn test_wildcard_edge_cases() { + let mut routes = HashMap::new(); + routes.insert( + (Method::GET, "/api/*".to_string()), + (Arc::new(MockProxy) as Arc, "test"), + ); + + let registry = IntegrationRegistry::from_routes(routes); + + // Should match paths under /api/ + assert!(registry.has_route(&Method::GET, "/api/v1")); + assert!(registry.has_route(&Method::GET, "/api/v1/users")); + + // Should not match /api without trailing content + // The current implementation requires a / after the prefix + assert!(!registry.has_route(&Method::GET, "/api")); + + // Should not match partial prefix matches + assert!(!registry.has_route(&Method::GET, "/apiv1")); + } } From 97b805668f6c200be9eb80d25a558b9d21d50245 Mon Sep 17 00:00:00 2001 From: Christian Date: Fri, 5 Dec 2025 12:29:55 -0600 Subject: [PATCH 2/3] namespace routes helpers --- crates/common/src/integrations/prebid.rs | 12 ++++++- crates/common/src/integrations/registry.rs | 36 +++++++++++++++++++- crates/common/src/integrations/testlight.rs | 6 +++- docs/integration_guide.md | 37 +++++++++++++++------ 4 files changed, 77 insertions(+), 14 deletions(-) diff --git a/crates/common/src/integrations/prebid.rs b/crates/common/src/integrations/prebid.rs index 6d5cabf..2df7015 100644 --- a/crates/common/src/integrations/prebid.rs +++ b/crates/common/src/integrations/prebid.rs @@ -26,6 +26,8 @@ use crate::settings::{IntegrationConfig, Settings}; use crate::synthetic::{generate_synthetic_id, get_or_generate_synthetic_id}; const PREBID_INTEGRATION_ID: &str = "prebid"; + +// Legacy route paths (kept for backwards compatibility) const ROUTE_FIRST_PARTY_AD: &str = "/first-party/ad"; const ROUTE_THIRD_PARTY_AD: &str = "/third-party/ad"; @@ -136,7 +138,11 @@ impl PrebidIntegration { }, )?; - log::info!("/third-party/ad: received {} adUnits", body.ad_units.len()); + log::info!( + "{}: received {} adUnits", + ROUTE_THIRD_PARTY_AD, + body.ad_units.len() + ); for unit in &body.ad_units { if let Some(mt) = &unit.media_types { if let Some(banner) = &mt.banner { @@ -264,6 +270,10 @@ pub fn register(settings: &Settings) -> Option { #[async_trait(?Send)] impl IntegrationProxy for PrebidIntegration { + fn integration_name(&self) -> &'static str { + PREBID_INTEGRATION_ID + } + fn routes(&self) -> Vec { let mut routes = vec![ IntegrationEndpoint::get(ROUTE_FIRST_PARTY_AD), diff --git a/crates/common/src/integrations/registry.rs b/crates/common/src/integrations/registry.rs index 3961c01..260115d 100644 --- a/crates/common/src/integrations/registry.rs +++ b/crates/common/src/integrations/registry.rs @@ -121,7 +121,13 @@ impl IntegrationEndpoint { /// Trait implemented by integration proxies that expose HTTP endpoints. #[async_trait(?Send)] pub trait IntegrationProxy: Send + Sync { - /// Routes handled by this integration (e.g. `/integrations/example/auction`). + /// Integration identifier used for logging and optional URL namespace. + /// Use this with the `namespaced_*` helper methods to automatically prefix routes. + fn integration_name(&self) -> &'static str; + + /// Routes handled by this integration. + /// to automatically namespace routes under `/integrations/{integration_name()}/`, + /// or define routes manually for backwards compatibility. fn routes(&self) -> Vec; /// Handle the proxied request. @@ -130,6 +136,30 @@ pub trait IntegrationProxy: Send + Sync { settings: &Settings, req: Request, ) -> Result>; + + /// Helper to create a namespaced GET endpoint. + /// Automatically prefixes the path with `/integrations/{integration_name()}`. + /// + /// # Example + /// ```ignore + /// self.namespaced_get("/auction") // becomes /integrations/my_integration/auction + /// ``` + fn get(&self, path: &str) -> IntegrationEndpoint { + let full_path = format!("/integrations/{}{}", self.integration_name(), path); + IntegrationEndpoint::get(Box::leak(full_path.into_boxed_str())) + } + + /// Helper to create a namespaced POST endpoint. + /// Automatically prefixes the path with `/integrations/{integration_name()}`. + /// + /// # Example + /// ```ignore + /// self.namespaced_post("/auction") // becomes /integrations/my_integration/auction + /// ``` + fn post(&self, path: &str) -> IntegrationEndpoint { + let full_path = format!("/integrations/{}{}", self.integration_name(), path); + IntegrationEndpoint::post(Box::leak(full_path.into_boxed_str())) + } } /// Trait for integration-provided HTML attribute rewrite hooks. @@ -443,6 +473,10 @@ mod tests { #[async_trait(?Send)] impl IntegrationProxy for MockProxy { + fn integration_name(&self) -> &'static str { + "test" + } + fn routes(&self) -> Vec { vec![] } diff --git a/crates/common/src/integrations/testlight.rs b/crates/common/src/integrations/testlight.rs index 7fddd0a..31aaac2 100644 --- a/crates/common/src/integrations/testlight.rs +++ b/crates/common/src/integrations/testlight.rs @@ -121,8 +121,12 @@ pub fn register(settings: &Settings) -> Option { #[async_trait(?Send)] impl IntegrationProxy for TestlightIntegration { + fn integration_name(&self) -> &'static str { + TESTLIGHT_INTEGRATION_ID + } + fn routes(&self) -> Vec { - vec![IntegrationEndpoint::post("/integrations/testlight/auction")] + vec![self.post("/auction")] } async fn handle( diff --git a/docs/integration_guide.md b/docs/integration_guide.md index 514af0e..3834e28 100644 --- a/docs/integration_guide.md +++ b/docs/integration_guide.md @@ -112,10 +112,14 @@ Implement the trait from `registry.rs` when your integration needs its own HTTP ```rust #[async_trait(?Send)] impl IntegrationProxy for MyIntegration { + fn integration_name(&self) -> &'static str { + "my_integration" + } + fn routes(&self) -> Vec { vec![ - IntegrationEndpoint::post("/integrations/my-integration/auction"), - IntegrationEndpoint::get("/integrations/my-integration/status"), + self.post("/auction"), + self.get("/status"), ] } @@ -129,11 +133,20 @@ impl IntegrationProxy for MyIntegration { } ``` -Routes are matched verbatim in `crates/fastly/src/main.rs`, so stick to stable paths -(`/integrations//…`) and register whichever HTTP methods you need. The shared context -already injects Trusted Server logging, headers, and error handling; the handler only -needs to deserialize the request, call the upstream endpoint, and stamp integration-specific -headers. +**Recommended:** Use the provided helper methods `get()` or `post()` +to automatically namespace your routes under `/integrations/{integration_name()}/`. +This lets you define routes with just their relative paths (e.g., `self.post("/auction")` becomes +`"/integrations/my_integration/auction"`). You can also define routes manually using +`IntegrationEndpoint::get()` / `IntegrationEndpoint::post()` for backwards compatibility or +special cases. + +Routes are matched verbatim in `crates/fastly/src/main.rs`, so stick to stable paths and +register whichever HTTP methods you need. **New integrations should namespace their routes under +`/integrations/{INTEGRATION_NAME}/`** using the helper methods (`self.get()` or `self.post()`) +for consistency, but you can define routes manually if needed (e.g., for backwards compatibility). +The shared context already injects Trusted Server logging, headers, +and error handling; the handler only needs to deserialize the request, call the upstream endpoint, +and stamp integration-specific headers. #### Proxying upstream requests @@ -308,10 +321,12 @@ Prebid applies the same steps outlined above with a few notable patterns: `settings.integrations.insert_config("prebid", &serde_json::json!({...}))`, the same helper that other integrations use. -2. **Routes owned by the integration** – `IntegrationProxy::routes` declares the legacy - `/first-party/ad` (GET) and `/third-party/ad` (POST) endpoints. Both handlers share helpers that - shape OpenRTB payloads, inject synthetic IDs + geo/request-signing context, forward requests via - `ensure_backend_from_url`, and run the HTML creative rewrites before responding. +2. **Routes owned by the integration** – `IntegrationProxy::routes` declares the + `/integrations/prebid/first-party/ad` (GET) and `/integrations/prebid/third-party/ad` (POST) + endpoints. Both handlers share helpers that shape OpenRTB payloads, inject synthetic IDs + + geo/request-signing context, forward requests via `ensure_backend_from_url`, and run the HTML + creative rewrites before responding. All routes are properly namespaced under + `/integrations/prebid/` to follow the integration routing pattern. 3. **HTML rewrites through the registry** – When `auto_configure` is enabled, the integration’s `IntegrationAttributeRewriter` removes any `