diff --git a/crates/common/src/auction/formats.rs b/crates/common/src/auction/formats.rs index c90308cd..79ae469c 100644 --- a/crates/common/src/auction/formats.rs +++ b/crates/common/src/auction/formats.rs @@ -39,6 +39,16 @@ pub struct AdRequest { pub struct AdUnit { pub code: String, pub media_types: Option, + pub bids: Option>, +} + +/// Bidder configuration from the request. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct BidConfig { + pub bidder: String, + #[serde(default)] + pub params: serde_json::Value, } #[derive(Debug, Deserialize)] @@ -91,11 +101,20 @@ pub fn convert_tsjs_to_auction_request( }); } + // Extract bidder params from the bids array + let mut bidders = std::collections::HashMap::new(); + if let Some(bids) = &unit.bids { + for bid in bids { + bidders.insert(bid.bidder.clone(), bid.params.clone()); + } + } + slots.push(AdSlot { id: unit.code.clone(), formats, floor_price: None, targeting: std::collections::HashMap::new(), + bidders, }); } } diff --git a/crates/common/src/auction/orchestrator.rs b/crates/common/src/auction/orchestrator.rs index 3163a57f..09866b4f 100644 --- a/crates/common/src/auction/orchestrator.rs +++ b/crates/common/src/auction/orchestrator.rs @@ -478,6 +478,7 @@ mod tests { }], floor_price: Some(1.50), targeting: HashMap::new(), + bidders: HashMap::new(), }, AdSlot { id: "sidebar".to_string(), @@ -488,6 +489,7 @@ mod tests { }], floor_price: Some(1.00), targeting: HashMap::new(), + bidders: HashMap::new(), }, ], publisher: PublisherInfo { diff --git a/crates/common/src/auction/types.rs b/crates/common/src/auction/types.rs index 6ea1242a..2792ffcf 100644 --- a/crates/common/src/auction/types.rs +++ b/crates/common/src/auction/types.rs @@ -37,6 +37,8 @@ pub struct AdSlot { pub floor_price: Option, /// Slot-specific targeting pub targeting: HashMap, + /// Bidder configurations (bidder name -> params) + pub bidders: HashMap, } /// Ad format specification. diff --git a/crates/common/src/integrations/adserver_mock.rs b/crates/common/src/integrations/adserver_mock.rs index feaeecdd..4b0e3137 100644 --- a/crates/common/src/integrations/adserver_mock.rs +++ b/crates/common/src/integrations/adserver_mock.rs @@ -396,6 +396,7 @@ mod tests { }], floor_price: Some(1.50), targeting: HashMap::new(), + bidders: HashMap::new(), }], publisher: PublisherInfo { domain: "test.com".to_string(), @@ -568,6 +569,7 @@ mod tests { }], floor_price: None, targeting: HashMap::new(), + bidders: HashMap::new(), }], publisher: PublisherInfo { domain: "test.com".to_string(), diff --git a/crates/common/src/integrations/aps.rs b/crates/common/src/integrations/aps.rs index 6ff2f311..88747a66 100644 --- a/crates/common/src/integrations/aps.rs +++ b/crates/common/src/integrations/aps.rs @@ -580,6 +580,7 @@ mod tests { ], floor_price: Some(1.50), targeting: HashMap::new(), + bidders: HashMap::new(), }, AdSlot { id: "sidebar".to_string(), @@ -590,6 +591,7 @@ mod tests { }], floor_price: Some(1.00), targeting: HashMap::new(), + bidders: HashMap::new(), }, ], publisher: PublisherInfo { diff --git a/crates/common/src/integrations/prebid.rs b/crates/common/src/integrations/prebid.rs index fa6622ad..5a523478 100644 --- a/crates/common/src/integrations/prebid.rs +++ b/crates/common/src/integrations/prebid.rs @@ -22,7 +22,10 @@ use crate::integrations::{ AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter, IntegrationEndpoint, IntegrationProxy, IntegrationRegistration, }; -use crate::openrtb::{Banner, Format, Imp, ImpExt, OpenRtbRequest, PrebidImpExt, Site}; +use crate::openrtb::{ + Banner, Device, Format, Geo, Imp, ImpExt, OpenRtbRequest, PrebidExt, PrebidImpExt, Regs, + RegsExt, RequestExt, Site, TrustedServerExt, User, UserExt, +}; use crate::request_signing::RequestSigner; use crate::settings::{IntegrationConfig, Settings}; use crate::synthetic::{generate_synthetic_id, get_or_generate_synthetic_id}; @@ -47,6 +50,8 @@ pub struct PrebidIntegrationConfig { pub debug: bool, #[serde(default)] pub script_handler: Option, + #[serde(default)] + pub debug_query_params: Option, } impl IntegrationConfig for PrebidIntegrationConfig { @@ -275,8 +280,12 @@ fn build_openrtb_from_ts( imp: imps, site: Some(Site { domain: Some(settings.publisher.domain.clone()), - page: Some(format!("https://{}", settings.publisher.domain)), + page: Some(format!("https://{}", &settings.publisher.domain)), }), + user: None, + device: None, + regs: None, + ext: None, } } @@ -318,6 +327,7 @@ async fn handle_prebid_auction( &fresh_id, settings, &req, + config, )?; let mut pbs_req = Request::new( @@ -332,6 +342,7 @@ async fn handle_prebid_auction( })?; log::info!("Sending request to Prebid Server"); + let backend_name = ensure_backend_from_url(&config.server_url)?; let mut pbs_response = pbs_req @@ -377,6 +388,7 @@ fn enhance_openrtb_request( fresh_id: &str, settings: &Settings, req: &Request, + config: &PrebidIntegrationConfig, ) -> Result<(), Report> { if !request["user"].is_object() { request["user"] = json!({}); @@ -413,10 +425,25 @@ fn enhance_openrtb_request( } if !request["site"].is_object() { + let mut page_url = format!("https://{}", settings.publisher.domain); + + // Append debug query params if configured + if let Some(ref params) = config.debug_query_params { + page_url = append_query_params(&page_url, params); + } + request["site"] = json!({ "domain": settings.publisher.domain, - "page": format!("https://{}", settings.publisher.domain), + "page": page_url, }); + } else if let Some(ref params) = config.debug_query_params { + // If site already exists, append debug params to existing page URL + if let Some(page_url) = request["site"]["page"].as_str() { + let updated_url = append_query_params(page_url, params); + if updated_url != page_url { + request["site"]["page"] = json!(updated_url); + } + } } if let Some(request_signing_config) = &settings.request_signing { @@ -437,6 +464,16 @@ fn enhance_openrtb_request( } } + if config.debug { + if !request["ext"].is_object() { + request["ext"] = json!({}); + } + if !request["ext"]["prebid"].is_object() { + request["ext"]["prebid"] = json!({}); + } + request["ext"]["prebid"]["debug"] = json!(true); + } + Ok(()) } @@ -559,6 +596,19 @@ fn get_request_scheme(req: &Request) -> String { "https".to_string() } +/// Appends query parameters to a URL, handling both URLs with and without existing query strings. +/// Returns the original URL unchanged if params are empty or already present. +fn append_query_params(url: &str, params: &str) -> String { + if params.is_empty() || url.contains(params) { + return url.to_string(); + } + if url.contains('?') { + format!("{}&{}", url, params) + } else { + format!("{}?{}", url, params) + } +} + // ============================================================================ // Prebid Auction Provider // ============================================================================ @@ -574,8 +624,13 @@ impl PrebidAuctionProvider { Self { config } } - /// Convert auction request to OpenRTB format. - fn to_openrtb(&self, request: &AuctionRequest) -> OpenRtbRequest { + /// Convert auction request to OpenRTB format with all enrichments. + fn to_openrtb( + &self, + request: &AuctionRequest, + context: &AuctionContext<'_>, + signer: Option<(&RequestSigner, String)>, + ) -> OpenRtbRequest { let imps: Vec = request .slots .iter() @@ -590,9 +645,18 @@ impl PrebidAuctionProvider { }) .collect(); - let mut bidder = std::collections::HashMap::new(); - for bidder_name in &self.config.bidders { - bidder.insert(bidder_name.clone(), Json::Object(serde_json::Map::new())); + // Use bidder params from the slot (passed through from the request) + let mut bidder: HashMap = slot + .bidders + .iter() + .map(|(name, params)| (name.clone(), params.clone())) + .collect(); + + // Fallback to config bidders if none provided + if bidder.is_empty() { + for b in &self.config.bidders { + bidder.insert(b.clone(), Json::Object(serde_json::Map::new())); + } } Imp { @@ -605,13 +669,79 @@ impl PrebidAuctionProvider { }) .collect(); + // Build page URL with debug query params if configured + let page_url = request.publisher.page_url.as_ref().map(|url| { + if let Some(ref params) = self.config.debug_query_params { + append_query_params(url, params) + } else { + url.clone() + } + }); + + // Build user object + let user = Some(User { + id: Some(request.user.id.clone()), + ext: Some(UserExt { + synthetic_fresh: Some(request.user.fresh_id.clone()), + }), + }); + + // Build device object with geo if available + let device = request.device.as_ref().and_then(|d| { + d.geo.as_ref().map(|geo| Device { + geo: Some(Geo { + geo_type: 2, // IP address per OpenRTB spec + country: Some(geo.country.clone()), + city: Some(geo.city.clone()), + region: geo.region.clone(), + }), + }) + }); + + // Build regs object if Sec-GPC header is present + let regs = if context.request.get_header("Sec-GPC").is_some() { + Some(Regs { + ext: Some(RegsExt { + us_privacy: Some("1YYN".to_string()), + }), + }) + } else { + None + }; + + // Build ext object + let request_host = get_request_host(context.request); + let request_scheme = get_request_scheme(context.request); + + let (signature, kid) = signer + .map(|(s, sig)| (Some(sig), Some(s.kid.clone()))) + .unwrap_or((None, None)); + + let ext = Some(RequestExt { + prebid: if self.config.debug { + Some(PrebidExt { debug: Some(true) }) + } else { + None + }, + trusted_server: Some(TrustedServerExt { + signature, + kid, + request_host: Some(request_host), + request_scheme: Some(request_scheme), + }), + }); + OpenRtbRequest { id: request.id.clone(), imp: imps, site: Some(Site { domain: Some(request.publisher.domain.clone()), - page: request.publisher.page_url.clone(), + page: page_url, }), + user, + device, + regs, + ext, } } @@ -708,75 +838,28 @@ impl AuctionProvider for PrebidAuctionProvider { ) -> Result> { log::info!("Prebid: requesting bids for {} slots", request.slots.len()); - // Convert to OpenRTB - let openrtb = self.to_openrtb(request); - let mut openrtb_json = - serde_json::to_value(&openrtb).change_context(TrustedServerError::Prebid { - message: "Failed to serialize OpenRTB request".to_string(), - })?; - - // Enhance with user info - if !openrtb_json["user"].is_object() { - openrtb_json["user"] = json!({}); - } - openrtb_json["user"]["id"] = json!(&request.user.id); - if !openrtb_json["user"]["ext"].is_object() { - openrtb_json["user"]["ext"] = json!({}); - } - openrtb_json["user"]["ext"]["synthetic_fresh"] = json!(&request.user.fresh_id); - - // Add device info if available - if let Some(device) = &request.device { - if let Some(geo) = &device.geo { - let geo_obj = json!({ - "type": 2, - "country": geo.country, - "city": geo.city, - "region": geo.region, - }); - if !openrtb_json["device"].is_object() { - openrtb_json["device"] = json!({}); + // Create signer and compute signature if request signing is enabled + let signer_with_signature = + if let Some(request_signing_config) = &context.settings.request_signing { + if request_signing_config.enabled { + let signer = RequestSigner::from_config()?; + let signature = signer.sign(request.id.as_bytes())?; + Some((signer, signature)) + } else { + None } - openrtb_json["device"]["geo"] = geo_obj; - } - } - - // Add privacy regulations based on Sec-GPC header - if context.request.get_header("Sec-GPC").is_some() { - if !openrtb_json["regs"].is_object() { - openrtb_json["regs"] = json!({}); - } - if !openrtb_json["regs"]["ext"].is_object() { - openrtb_json["regs"]["ext"] = json!({}); - } - openrtb_json["regs"]["ext"]["us_privacy"] = json!("1YYN"); - } - - if !openrtb_json["ext"].is_object() { - openrtb_json["ext"] = json!({}); - } - if !openrtb_json["ext"]["trusted_server"].is_object() { - openrtb_json["ext"]["trusted_server"] = json!({}); - } - - let request_host = get_request_host(context.request); - let request_scheme = get_request_scheme(context.request); - openrtb_json["ext"]["trusted_server"]["request_host"] = json!(request_host); - openrtb_json["ext"]["trusted_server"]["request_scheme"] = json!(request_scheme); - - // Add request signing if enabled - if let Some(request_signing_config) = &context.settings.request_signing { - if request_signing_config.enabled && openrtb_json["id"].is_string() { - let id = openrtb_json["id"] - .as_str() - .expect("should have string id when is_string checked"); - let signer = RequestSigner::from_config()?; - let signature = signer.sign(id.as_bytes())?; - - openrtb_json["ext"]["trusted_server"]["signature"] = json!(signature); - openrtb_json["ext"]["trusted_server"]["kid"] = json!(signer.kid); - } - } + } else { + None + }; + + // Convert to OpenRTB with all enrichments + let openrtb = self.to_openrtb( + request, + context, + signer_with_signature + .as_ref() + .map(|(s, sig)| (s, sig.clone())), + ); // Create HTTP request let mut pbs_req = Request::new( @@ -786,7 +869,7 @@ impl AuctionProvider for PrebidAuctionProvider { copy_request_headers(context.request, &mut pbs_req); pbs_req - .set_body_json(&openrtb_json) + .set_body_json(&openrtb) .change_context(TrustedServerError::Prebid { message: "Failed to set request body".to_string(), })?; @@ -818,6 +901,7 @@ impl AuctionProvider for PrebidAuctionProvider { } let body_bytes = response.take_body_bytes(); + let mut response_json: Json = serde_json::from_slice(&body_bytes).change_context(TrustedServerError::Prebid { message: "Failed to parse Prebid response".to_string(), @@ -913,6 +997,7 @@ mod tests { auto_configure: true, debug: false, script_handler: None, + debug_query_params: None, } } @@ -1077,8 +1162,17 @@ mod tests { let mut req = Request::new(Method::POST, "https://edge.example/auction"); req.set_header("Sec-GPC", "1"); - enhance_openrtb_request(&mut request_json, synthetic_id, fresh_id, &settings, &req) - .expect("should enhance request"); + let config = base_config(); + + enhance_openrtb_request( + &mut request_json, + synthetic_id, + fresh_id, + &settings, + &req, + &config, + ) + .expect("should enhance request"); assert_eq!(request_json["user"]["id"], synthetic_id); assert_eq!(request_json["user"]["ext"]["synthetic_fresh"], fresh_id); @@ -1099,6 +1193,66 @@ mod tests { ); } + #[test] + fn enhance_openrtb_request_adds_debug_flag_when_enabled() { + let settings = make_settings(); + let mut request_json = json!({ + "id": "openrtb-request-id" + }); + + let synthetic_id = "synthetic-123"; + let fresh_id = "fresh-456"; + let req = Request::new(Method::POST, "https://edge.example/auction"); + + let mut config = base_config(); + config.debug = true; + + enhance_openrtb_request( + &mut request_json, + synthetic_id, + fresh_id, + &settings, + &req, + &config, + ) + .expect("should enhance request"); + + assert_eq!( + request_json["ext"]["prebid"]["debug"], true, + "debug flag should be set to true when config.debug is enabled" + ); + } + + #[test] + fn enhance_openrtb_request_does_not_add_debug_flag_when_disabled() { + let settings = make_settings(); + let mut request_json = json!({ + "id": "openrtb-request-id" + }); + + let synthetic_id = "synthetic-123"; + let fresh_id = "fresh-456"; + let req = Request::new(Method::POST, "https://edge.example/auction"); + + let mut config = base_config(); + config.debug = false; + + enhance_openrtb_request( + &mut request_json, + synthetic_id, + fresh_id, + &settings, + &req, + &config, + ) + .expect("should enhance request"); + + assert!( + request_json["ext"]["prebid"]["debug"].is_null(), + "debug flag should not be set when config.debug is disabled" + ); + } + #[test] fn transform_prebid_response_rewrites_creatives_and_tracking() { let mut response = json!({ @@ -1227,6 +1381,7 @@ server_url = "https://prebid.example" auto_configure: false, debug: false, script_handler: Some("/prebid.js".to_string()), + debug_query_params: None, }; let integration = PrebidIntegration::new(config); @@ -1261,6 +1416,7 @@ server_url = "https://prebid.example" auto_configure: false, debug: false, script_handler: Some("/prebid.js".to_string()), + debug_query_params: None, }; let integration = PrebidIntegration::new(config); @@ -1285,4 +1441,110 @@ server_url = "https://prebid.example" // Should have 0 routes when no script handler configured assert_eq!(routes.len(), 0); } + + #[test] + fn debug_query_params_appended_to_existing_site_page_in_enhance() { + let settings = make_settings(); + let mut config = base_config(); + config.debug_query_params = Some("kargo_debug=true".to_string()); + + let req = Request::new(Method::GET, "https://example.com/test"); + let synthetic_id = "test-synthetic-id"; + let fresh_id = "test-fresh-id"; + + // Test with existing site.page + let mut request = json!({ + "id": "test-id", + "site": { + "domain": "example.com", + "page": "https://example.com/page" + } + }); + + enhance_openrtb_request( + &mut request, + synthetic_id, + fresh_id, + &settings, + &req, + &config, + ) + .expect("should enhance request"); + + let page = request["site"]["page"].as_str().unwrap(); + assert_eq!(page, "https://example.com/page?kargo_debug=true"); + } + + #[test] + fn debug_query_params_appended_to_url_with_existing_query() { + let settings = make_settings(); + let mut config = base_config(); + config.debug_query_params = Some("kargo_debug=true".to_string()); + + let req = Request::new(Method::GET, "https://example.com/test"); + let synthetic_id = "test-synthetic-id"; + let fresh_id = "test-fresh-id"; + + // Test with existing query params in site.page + let mut request = json!({ + "id": "test-id", + "site": { + "domain": "example.com", + "page": "https://example.com/page?existing=param" + } + }); + + enhance_openrtb_request( + &mut request, + synthetic_id, + fresh_id, + &settings, + &req, + &config, + ) + .expect("should enhance request"); + + let page = request["site"]["page"].as_str().unwrap(); + assert_eq!( + page, + "https://example.com/page?existing=param&kargo_debug=true" + ); + } + + #[test] + fn debug_query_params_not_duplicated() { + // Verify that if params are already in the URL, they aren't added again + let settings = make_settings(); + let mut config = base_config(); + config.debug_query_params = Some("kargo_debug=true".to_string()); + + let req = Request::new(Method::GET, "https://example.com/test"); + let synthetic_id = "test-synthetic-id"; + let fresh_id = "test-fresh-id"; + + // Test with URL that already has the debug params + let mut request = json!({ + "id": "test-id", + "site": { + "domain": "example.com", + "page": "https://example.com/page?kargo_debug=true" + } + }); + + enhance_openrtb_request( + &mut request, + synthetic_id, + fresh_id, + &settings, + &req, + &config, + ) + .expect("should enhance request"); + + let page = request["site"]["page"].as_str().unwrap(); + // Should still only have params once + assert_eq!(page, "https://example.com/page?kargo_debug=true"); + // Verify params appear exactly once + assert_eq!(page.matches("kargo_debug=true").count(), 1); + } } diff --git a/crates/common/src/openrtb.rs b/crates/common/src/openrtb.rs index 6881af85..3d2d7b84 100644 --- a/crates/common/src/openrtb.rs +++ b/crates/common/src/openrtb.rs @@ -12,6 +12,14 @@ pub struct OpenRtbRequest { pub imp: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub site: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub user: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub device: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub regs: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ext: Option, } #[derive(Debug, Serialize)] @@ -42,6 +50,77 @@ pub struct Site { pub page: Option, } +#[derive(Debug, Serialize, Default)] +pub struct User { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ext: Option, +} + +#[derive(Debug, Serialize, Default)] +pub struct UserExt { + #[serde(skip_serializing_if = "Option::is_none")] + pub synthetic_fresh: Option, +} + +#[derive(Debug, Serialize, Default)] +pub struct Device { + #[serde(skip_serializing_if = "Option::is_none")] + pub geo: Option, +} + +#[derive(Debug, Serialize)] +pub struct Geo { + /// Location type per OpenRTB spec (1=GPS, 2=IP address, 3=user provided) + #[serde(rename = "type")] + pub geo_type: u8, + #[serde(skip_serializing_if = "Option::is_none")] + pub country: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub city: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub region: Option, +} + +#[derive(Debug, Serialize, Default)] +pub struct Regs { + #[serde(skip_serializing_if = "Option::is_none")] + pub ext: Option, +} + +#[derive(Debug, Serialize, Default)] +pub struct RegsExt { + #[serde(skip_serializing_if = "Option::is_none")] + pub us_privacy: Option, +} + +#[derive(Debug, Serialize, Default)] +pub struct RequestExt { + #[serde(skip_serializing_if = "Option::is_none")] + pub prebid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub trusted_server: Option, +} + +#[derive(Debug, Serialize, Default)] +pub struct PrebidExt { + #[serde(skip_serializing_if = "Option::is_none")] + pub debug: Option, +} + +#[derive(Debug, Serialize, Default)] +pub struct TrustedServerExt { + #[serde(skip_serializing_if = "Option::is_none")] + pub signature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub kid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_host: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_scheme: Option, +} + #[derive(Debug, Serialize)] pub struct ImpExt { pub prebid: PrebidImpExt, diff --git a/crates/js/lib/src/core/types.ts b/crates/js/lib/src/core/types.ts index 3bf1b499..72365fb9 100644 --- a/crates/js/lib/src/core/types.ts +++ b/crates/js/lib/src/core/types.ts @@ -9,9 +9,15 @@ export interface MediaTypes { banner?: Banner; } +export interface Bid { + bidder: string; + params?: Record; +} + export interface AdUnit { code: string; mediaTypes?: MediaTypes; + bids?: Bid[]; } export interface TsjsApi {