diff --git a/Cargo.lock b/Cargo.lock index 94ec4c24..7bd330c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2374,6 +2374,7 @@ dependencies = [ "tokio-test", "toml", "trusted-server-js", + "trusted-server-openrtb", "url", "urlencoding", "uuid", @@ -2407,6 +2408,14 @@ dependencies = [ "which", ] +[[package]] +name = "trusted-server-openrtb" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "typeid" version = "1.0.3" diff --git a/Cargo.toml b/Cargo.toml index 032cbaa7..b637ba47 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,10 @@ members = [ "crates/common", "crates/fastly", "crates/js", + "crates/openrtb", +] +exclude = [ + "crates/openrtb-codegen", ] # Build defaults exclude the web-only tsjs crate, which is compiled via wasm-pack. diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index c360474e..5b5737a8 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -43,6 +43,7 @@ sha2 = { workspace = true } tokio = { workspace = true } toml = { workspace = true } trusted-server-js = { path = "../js" } +trusted-server-openrtb = { path = "../openrtb" } url = { workspace = true } urlencoding = { workspace = true } uuid = { workspace = true } diff --git a/crates/common/src/auction/formats.rs b/crates/common/src/auction/formats.rs index 3eb4d843..31002f1c 100644 --- a/crates/common/src/auction/formats.rs +++ b/crates/common/src/auction/formats.rs @@ -16,7 +16,7 @@ use crate::auction::context::ContextValue; use crate::creative; use crate::error::TrustedServerError; use crate::geo::GeoInfo; -use crate::openrtb::{OpenRtbBid, OpenRtbResponse, ResponseExt, SeatBid}; +use crate::openrtb::{to_openrtb_i32, OpenRtbBid, OpenRtbResponse, ResponseExt, SeatBid, ToExt}; use crate::settings::Settings; use crate::synthetic::{generate_synthetic_id, get_or_generate_synthetic_id}; @@ -205,7 +205,7 @@ pub fn convert_to_openrtb_response( auction_request: &AuctionRequest, ) -> Result> { // Build OpenRTB-style seatbid array - let mut seatbids = Vec::new(); + let mut seatbids = Vec::with_capacity(result.winning_bids.len()); for (slot_id, bid) in &result.winning_bids { let price = bid.price.ok_or_else(|| { @@ -217,6 +217,13 @@ pub fn convert_to_openrtb_response( }) })?; + let bid_context = format!( + "auction {} slot {} bidder {}", + auction_request.id, slot_id, bid.bidder + ); + let width = to_openrtb_i32(bid.width, "width", &bid_context); + let height = to_openrtb_i32(bid.height, "height", &bid_context); + // Process creative HTML if present - rewrite URLs and return inline let creative_html = if let Some(ref raw_creative) = bid.creative { // Rewrite creative HTML with proxy URLs for first-party delivery @@ -241,19 +248,21 @@ pub fn convert_to_openrtb_response( }; let openrtb_bid = OpenRtbBid { - id: format!("{}-{}", bid.bidder, slot_id), - impid: slot_id.to_string(), - price, + id: Some(format!("{}-{}", bid.bidder, slot_id)), + impid: Some(slot_id.to_string()), + price: Some(price), adm: Some(creative_html), crid: Some(format!("{}-creative", bid.bidder)), - w: Some(bid.width), - h: Some(bid.height), - adomain: Some(bid.adomain.clone().unwrap_or_default()), + w: width, + h: height, + adomain: bid.adomain.clone().unwrap_or_default(), + ..Default::default() }; seatbids.push(SeatBid { seat: Some(bid.bidder.clone()), bid: vec![openrtb_bid], + ..Default::default() }); } @@ -272,9 +281,9 @@ pub fn convert_to_openrtb_response( .collect(); let response_body = OpenRtbResponse { - id: auction_request.id.to_string(), + id: Some(auction_request.id.to_string()), seatbid: seatbids, - ext: Some(ResponseExt { + ext: ResponseExt { orchestrator: OrchestratorExt { strategy: strategy_name.to_string(), providers: result.provider_responses.len(), @@ -282,7 +291,9 @@ pub fn convert_to_openrtb_response( time_ms: result.total_time_ms, provider_details, }, - }), + } + .to_ext(), + ..Default::default() }; let body_bytes = diff --git a/crates/common/src/geo.rs b/crates/common/src/geo.rs index 17b44464..a554dc3b 100644 --- a/crates/common/src/geo.rs +++ b/crates/common/src/geo.rs @@ -102,6 +102,39 @@ impl GeoInfo { } } +use std::collections::HashSet; +use std::sync::LazyLock; + +/// EU-27 + EEA-3 (Iceland, Liechtenstein, Norway) + UK (UK GDPR). +/// +/// Two-letter ISO 3166-1 alpha-2 country codes for jurisdictions where GDPR +/// or equivalent legislation applies. Used to infer GDPR applicability from +/// IP-derived geolocation when a more authoritative signal (e.g. TCF consent +/// string) is not yet available. +static GDPR_COUNTRIES: LazyLock> = LazyLock::new(|| { + [ + // EU-27 + "AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR", "DE", "GR", "HU", "IE", "IT", + "LV", "LT", "LU", "MT", "NL", "PL", "PT", "RO", "SK", "SI", "ES", "SE", + // EEA (non-EU) + "IS", "LI", "NO", // UK GDPR + "GB", + ] + .into_iter() + .collect() +}); + +/// Returns `true` if the given two-letter country code falls under GDPR +/// jurisdiction (EU-27, EEA, or UK). +/// +/// The comparison is case-insensitive. Returns `false` for empty or +/// unrecognised codes. +#[must_use] +pub fn is_gdpr_country(country_code: &str) -> bool { + let upper = country_code.to_ascii_uppercase(); + GDPR_COUNTRIES.contains(upper.as_str()) +} + #[cfg(test)] mod tests { use super::*; @@ -211,6 +244,39 @@ mod tests { ); } + #[test] + fn is_gdpr_country_detects_eu_members() { + assert!(is_gdpr_country("DE"), "Germany is EU"); + assert!(is_gdpr_country("FR"), "France is EU"); + assert!(is_gdpr_country("IT"), "Italy is EU"); + } + + #[test] + fn is_gdpr_country_detects_eea_and_uk() { + assert!(is_gdpr_country("NO"), "Norway is EEA"); + assert!(is_gdpr_country("IS"), "Iceland is EEA"); + assert!(is_gdpr_country("GB"), "UK has UK GDPR"); + } + + #[test] + fn is_gdpr_country_rejects_non_gdpr() { + assert!(!is_gdpr_country("US"), "US is not GDPR"); + assert!(!is_gdpr_country("CN"), "China is not GDPR"); + assert!(!is_gdpr_country("BR"), "Brazil is not GDPR"); + } + + #[test] + fn is_gdpr_country_is_case_insensitive() { + assert!(is_gdpr_country("de"), "lowercase should match"); + assert!(is_gdpr_country("De"), "mixed case should match"); + } + + #[test] + fn is_gdpr_country_handles_empty_and_unknown() { + assert!(!is_gdpr_country(""), "empty string is not GDPR"); + assert!(!is_gdpr_country("XX"), "unknown code is not GDPR"); + } + #[test] fn set_response_headers_omits_region_when_none() { let geo = GeoInfo { diff --git a/crates/common/src/integrations/prebid.rs b/crates/common/src/integrations/prebid.rs index fb3fccc8..b23aa3f9 100644 --- a/crates/common/src/integrations/prebid.rs +++ b/crates/common/src/integrations/prebid.rs @@ -16,6 +16,7 @@ use crate::auction::types::{ }; use crate::backend::BackendConfig; use crate::error::TrustedServerError; +use crate::geo::is_gdpr_country; use crate::http_util::RequestInfo; use crate::integrations::{ AttributeRewriteAction, IntegrationAttributeContext, IntegrationAttributeRewriter, @@ -23,8 +24,8 @@ use crate::integrations::{ IntegrationRegistration, }; use crate::openrtb::{ - Banner, Device, Format, Geo, Imp, ImpExt, OpenRtbRequest, PrebidExt, PrebidImpExt, Regs, - RegsExt, RequestExt, Site, TrustedServerExt, User, UserExt, + to_openrtb_i32, Banner, Device, Format, Geo, Imp, ImpExt, OpenRtbRequest, PrebidExt, + PrebidImpExt, Publisher, Regs, RequestExt, Site, ToExt, TrustedServerExt, User, UserExt, }; use crate::request_signing::{RequestSigner, SigningParams, SIGNING_VERSION}; use crate::settings::{IntegrationConfig, Settings}; @@ -34,6 +35,17 @@ const TRUSTED_SERVER_BIDDER: &str = "trustedServer"; const BIDDER_PARAMS_KEY: &str = "bidderParams"; const ZONE_KEY: &str = "zone"; +/// Default currency for `OpenRTB` bid floors and responses. +const DEFAULT_CURRENCY: &str = "USD"; + +/// CCPA/US-privacy string sent when the `Sec-GPC` header signals opt-out. +/// +/// Encodes: notice given (`1`), user opted out (`Y`), LSPA not signed (`N`). +/// The opt-out (position 2 = `Y`) matches GPC intent. Position 3 (`N` = LSPA +/// not applicable) is a conservative default that may not hold for all +/// publishers — consider making this configurable per-publisher in the future. +const GPC_US_PRIVACY: &str = "1YYN"; + #[derive(Debug, Clone, Deserialize, Serialize, Validate)] pub struct PrebidIntegrationConfig { #[serde(default = "default_enabled")] @@ -488,17 +500,27 @@ impl PrebidAuctionProvider { context: &AuctionContext<'_>, signer: Option<(&RequestSigner, String, &SigningParams)>, ) -> OpenRtbRequest { - let imps: Vec = request + let imps = request .slots .iter() .map(|slot| { - let formats: Vec = slot + let slot_context = format!("slot '{}'", slot.id); + let formats = slot .formats .iter() .filter(|f| f.media_type == MediaType::Banner) - .map(|f| Format { - w: f.width, - h: f.height, + .filter_map(|f| { + let width = to_openrtb_i32(f.width, "format.w", &slot_context); + let height = to_openrtb_i32(f.height, "format.h", &slot_context); + + match (width, height) { + (Some(width), Some(height)) => Some(Format { + w: Some(width), + h: Some(height), + ..Default::default() + }), + _ => None, + } }) .collect(); @@ -553,11 +575,23 @@ impl PrebidAuctionProvider { } Imp { - id: slot.id.clone(), - banner: Some(Banner { format: formats }), - ext: Some(ImpExt { - prebid: PrebidImpExt { bidder }, + id: Some(slot.id.clone()), + banner: Some(Banner { + format: formats, + ..Default::default() }), + bidfloor: slot.floor_price, + // NOTE: Currency defaults to DEFAULT_CURRENCY. If + // multi-currency support is needed, this should come from + // config or the AdSlot itself. + bidfloorcur: slot.floor_price.map(|_| DEFAULT_CURRENCY.to_string()), + secure: Some(true), // require HTTPS creatives + tagid: Some(slot.id.clone()), + ext: ImpExt { + prebid: PrebidImpExt { bidder }, + } + .to_ext(), + ..Default::default() } }) .collect(); @@ -571,15 +605,46 @@ impl PrebidAuctionProvider { } }); - // Build user object + // Build user object with consent string when available let user = Some(User { id: Some(request.user.id.clone()), - ext: Some(UserExt { + consent: request.user.consent.clone(), + ext: UserExt { synthetic_fresh: Some(request.user.fresh_id.clone()), - }), + } + .to_ext(), + ..Default::default() }); - // Build device object with user-agent, client IP, and geo if available. + // Extract DNT header and Accept-Language from the original request + let dnt = context.request.get_header_str("DNT").and_then(|v| { + if v.trim() == "1" { + Some(true) + } else { + None + } + }); + + let language = context + .request + .get_header_str(header::ACCEPT_LANGUAGE) + .and_then(|v| { + // Extract the primary ISO-639 language tag (e.g., "en" from + // "en-US,en;q=0.9"). Strip the region subtag so bidders get a + // normalised two-letter code that maximises match quality. + v.split(',') + .next() + .and_then(|tag| tag.split(';').next()) + .map(|tag| { + tag.split('-') + .next() + .expect("should have at least one split segment") + .trim() + .to_string() + }) + }); + + // Build device object with user-agent, client IP, geo, DNT, and language. // Forwarding the real client IP is critical: without it PBS infers the // IP from the incoming connection (a data-center / edge IP), causing // bidders like PubMatic to filter the traffic as non-human. @@ -587,19 +652,63 @@ impl PrebidAuctionProvider { ua: d.user_agent.clone(), ip: d.ip.clone(), geo: d.geo.as_ref().map(|geo| Geo { - geo_type: 2, // IP address per OpenRTB spec country: Some(geo.country.clone()), city: Some(geo.city.clone()), region: geo.region.clone(), + lat: Some(geo.latitude), + lon: Some(geo.longitude), + // DMA/metro code: convert i64 to string for OpenRTB + metro: if geo.metro_code > 0 { + Some(geo.metro_code.to_string()) + } else { + None + }, + r#type: Some(2), + ..Default::default() }), + dnt, + language, + ..Default::default() }); - // Build regs object if Sec-GPC header is present - let regs = if context.request.get_header("Sec-GPC").is_some() { + // Build regs object. + // + // GDPR applicability is determined from the user's geo (EU/EEA/UK + // country check). When geo is unavailable we fall back to the + // conservative assumption that GDPR applies if a consent string is + // present. A future enhancement can parse TCF segment 0 for the + // authoritative `isSubjectToGDPR` signal. + // + // us_privacy is set when Sec-GPC header signals opt-out. + let gdpr_from_geo = request + .device + .as_ref() + .and_then(|d| d.geo.as_ref()) + .map(|geo| is_gdpr_country(&geo.country)); + + let gdpr = match gdpr_from_geo { + Some(applies) => Some(applies), + // No geo available — conservatively assume GDPR if consent string + // is present (a CMP was active). + None => request.user.consent.as_ref().map(|_| true), + }; + + let has_gpc = context + .request + .get_header_str("Sec-GPC") + .is_some_and(|v| v.trim() == "1"); + + let us_privacy = if has_gpc { + Some(GPC_US_PRIVACY.to_string()) + } else { + None + }; + + let regs = if gdpr.is_some() || us_privacy.is_some() { Some(Regs { - ext: Some(RegsExt { - us_privacy: Some("1YYN".to_string()), - }), + gdpr, + us_privacy, + ..Default::default() }) } else { None @@ -620,7 +729,7 @@ impl PrebidAuctionProvider { let debug_enabled = self.config.debug; - let ext = Some(RequestExt { + let ext = RequestExt { prebid: Some(PrebidExt { debug: debug_enabled.then_some(true), returnallbidstatus: debug_enabled.then_some(true), @@ -633,20 +742,38 @@ impl PrebidAuctionProvider { request_scheme: Some(request_info.scheme), ts, }), - }); + } + .to_ext(); + + // Extract Referer header for site.ref + let referer = context + .request + .get_header_str(header::REFERER) + .map(std::string::ToString::to_string); + + let tmax = to_openrtb_i32(self.config.timeout_ms, "tmax", "request"); OpenRtbRequest { - id: request.id.clone(), + id: Some(request.id.clone()), imp: imps, site: Some(Site { domain: Some(request.publisher.domain.clone()), page: page_url, + r#ref: referer, + publisher: Some(Publisher { + domain: Some(request.publisher.domain.clone()), + ..Default::default() + }), + ..Default::default() }), user, device, regs, - test: self.config.test_mode.then_some(1), + test: self.config.test_mode.then_some(true), + tmax, + cur: vec![DEFAULT_CURRENCY.to_string()], ext, + ..Default::default() } } @@ -775,7 +902,7 @@ impl PrebidAuctionProvider { Ok(AuctionBid { slot_id, price: Some(price), // Prebid provides decoded prices - currency: "USD".to_string(), + currency: DEFAULT_CURRENCY.to_string(), creative, adomain, bidder: seat.to_string(), @@ -827,7 +954,7 @@ impl AuctionProvider for PrebidAuctionProvider { .map(|(s, sig, params)| (s, sig.clone(), params)), ); - // Log the outgoing OpenRTB request for debugging + // Log the outgoing OpenRTB request for debugging. if log::log_enabled!(log::Level::Debug) { match serde_json::to_string_pretty(&openrtb) { Ok(json) => log::debug!( @@ -993,6 +1120,7 @@ mod tests { use crate::auction::types::{ AdFormat, AdSlot, AuctionContext, AuctionRequest, DeviceInfo, PublisherInfo, UserInfo, }; + use crate::geo::GeoInfo; use crate::html_processor::{create_html_processor, HtmlProcessorConfig}; use crate::integrations::{ AttributeRewriteAction, IntegrationDocumentState, IntegrationRegistry, @@ -1306,7 +1434,7 @@ template = "{{client_ip}}:{{user_agent}}" } #[test] - fn test_script_patterns_config_parsing() { + fn script_patterns_config_parsing() { let config = parse_prebid_toml( r#" [integrations.prebid] @@ -1324,7 +1452,7 @@ script_patterns = ["/prebid.js", "/custom/prebid.min.js"] } #[test] - fn test_script_patterns_defaults() { + fn script_patterns_defaults() { let config = parse_prebid_toml( r#" [integrations.prebid] @@ -1341,7 +1469,7 @@ server_url = "https://prebid.example" } #[test] - fn test_script_handler_returns_empty_js() { + fn script_handler_returns_empty_js() { let integration = PrebidIntegration::new(base_config()); let response = integration @@ -1365,7 +1493,7 @@ server_url = "https://prebid.example" } #[test] - fn test_routes_includes_script_patterns() { + fn routes_include_script_patterns() { let integration = PrebidIntegration::new(base_config()); let routes = integration.routes(); @@ -1490,11 +1618,7 @@ server_url = "https://prebid.example" "debug alone should not set top-level OpenRTB test field" ); - let prebid_ext = openrtb - .ext - .as_ref() - .and_then(|ext| ext.prebid.as_ref()) - .expect("should include ext.prebid"); + let prebid_ext = get_prebid_ext(&openrtb); assert_eq!( prebid_ext.debug, Some(true), @@ -1538,7 +1662,7 @@ server_url = "https://prebid.example" assert_eq!( openrtb.test, - Some(1), + Some(true), "should set top-level OpenRTB test field when test_mode is enabled" ); @@ -1597,11 +1721,7 @@ server_url = "https://prebid.example" "should omit top-level OpenRTB test field when test_mode is disabled" ); - let prebid_ext = openrtb - .ext - .as_ref() - .and_then(|ext| ext.prebid.as_ref()) - .expect("should include ext.prebid"); + let prebid_ext = get_prebid_ext(&openrtb); assert_eq!( prebid_ext.debug, None, "should omit ext.prebid.debug when debug is disabled" @@ -1630,6 +1750,415 @@ server_url = "https://prebid.example" ); } + // ======================================================================== + // OpenRTB field enrichment tests + // ======================================================================== + + #[test] + fn to_openrtb_sets_bidfloor_from_slot_floor_price() { + let provider = PrebidAuctionProvider::new(base_config()); + let mut auction_request = create_test_auction_request(); + auction_request.slots[0].floor_price = Some(1.5); + + let settings = make_settings(); + let request = Request::get("https://pub.example/auction"); + let context = create_test_auction_context(&settings, &request); + + let openrtb = provider.to_openrtb(&auction_request, &context, None); + let imp = &openrtb.imp[0]; + + assert_eq!(imp.bidfloor, Some(1.5), "should set bidfloor from slot"); + assert_eq!( + imp.bidfloorcur.as_deref(), + Some("USD"), + "should set bidfloorcur when floor is present" + ); + } + + #[test] + fn to_openrtb_omits_bidfloor_when_no_floor_price() { + let provider = PrebidAuctionProvider::new(base_config()); + let auction_request = create_test_auction_request(); // floor_price is None + + let settings = make_settings(); + let request = Request::get("https://pub.example/auction"); + let context = create_test_auction_context(&settings, &request); + + let openrtb = provider.to_openrtb(&auction_request, &context, None); + let imp = &openrtb.imp[0]; + + assert_eq!(imp.bidfloor, None, "should omit bidfloor when not set"); + assert_eq!( + imp.bidfloorcur, None, + "should omit bidfloorcur when floor not set" + ); + } + + #[test] + fn to_openrtb_sets_secure_and_tagid() { + let provider = PrebidAuctionProvider::new(base_config()); + let auction_request = create_test_auction_request(); + + let settings = make_settings(); + let request = Request::get("https://pub.example/auction"); + let context = create_test_auction_context(&settings, &request); + + let openrtb = provider.to_openrtb(&auction_request, &context, None); + let imp = &openrtb.imp[0]; + + assert_eq!(imp.secure, Some(true), "should require HTTPS creatives"); + assert_eq!( + imp.tagid.as_deref(), + Some("slot-1"), + "should set tagid from slot id" + ); + } + + #[test] + fn to_openrtb_includes_consent_and_gdpr_flag_from_geo() { + let provider = PrebidAuctionProvider::new(base_config()); + let mut auction_request = create_test_auction_request(); + auction_request.user.consent = Some("BOtest-consent-string".to_string()); + // Set device with EU geo so GDPR applies from geo check + auction_request.device = Some(DeviceInfo { + user_agent: Some("TestAgent".to_string()), + ip: None, + geo: Some(GeoInfo { + city: "Berlin".to_string(), + country: "DE".to_string(), + continent: "EU".to_string(), + latitude: 52.52, + longitude: 13.405, + metro_code: 0, + region: None, + }), + }); + + let settings = make_settings(); + let request = Request::get("https://pub.example/auction"); + let context = create_test_auction_context(&settings, &request); + + let openrtb = provider.to_openrtb(&auction_request, &context, None); + + assert_eq!( + openrtb.user.as_ref().and_then(|u| u.consent.as_deref()), + Some("BOtest-consent-string"), + "should forward consent string to user.consent" + ); + assert_eq!( + openrtb.regs.as_ref().and_then(|r| r.gdpr), + Some(true), + "should set regs.gdpr=true for EU country" + ); + } + + #[test] + fn to_openrtb_sets_gdpr_false_for_non_eu_country() { + let provider = PrebidAuctionProvider::new(base_config()); + let mut auction_request = create_test_auction_request(); + auction_request.user.consent = Some("BOtest-consent-string".to_string()); + // US geo — GDPR should not apply + auction_request.device = Some(DeviceInfo { + user_agent: Some("TestAgent".to_string()), + ip: None, + geo: Some(GeoInfo { + city: "New York".to_string(), + country: "US".to_string(), + continent: "NA".to_string(), + latitude: 40.7128, + longitude: -74.006, + metro_code: 501, + region: Some("NY".to_string()), + }), + }); + + let settings = make_settings(); + let request = Request::get("https://pub.example/auction"); + let context = create_test_auction_context(&settings, &request); + + let openrtb = provider.to_openrtb(&auction_request, &context, None); + + assert_eq!( + openrtb.regs.as_ref().and_then(|r| r.gdpr), + Some(false), + "should set regs.gdpr=false for non-EU country even with consent string" + ); + } + + #[test] + fn to_openrtb_falls_back_to_consent_when_no_geo() { + let provider = PrebidAuctionProvider::new(base_config()); + let mut auction_request = create_test_auction_request(); + auction_request.user.consent = Some("BOtest-consent-string".to_string()); + // No device/geo + + let settings = make_settings(); + let request = Request::get("https://pub.example/auction"); + let context = create_test_auction_context(&settings, &request); + + let openrtb = provider.to_openrtb(&auction_request, &context, None); + + assert_eq!( + openrtb.regs.as_ref().and_then(|r| r.gdpr), + Some(true), + "should conservatively assume GDPR when geo is absent but consent exists" + ); + } + + #[test] + fn to_openrtb_omits_regs_when_no_consent_or_gpc() { + let provider = PrebidAuctionProvider::new(base_config()); + let auction_request = create_test_auction_request(); // consent=None, no geo + + let settings = make_settings(); + let request = Request::get("https://pub.example/auction"); + let context = create_test_auction_context(&settings, &request); + + let openrtb = provider.to_openrtb(&auction_request, &context, None); + + assert!(openrtb.regs.is_none(), "should omit regs entirely"); + } + + #[test] + fn to_openrtb_sets_gpc_us_privacy() { + let provider = PrebidAuctionProvider::new(base_config()); + let auction_request = create_test_auction_request(); + + let settings = make_settings(); + let mut request = Request::get("https://pub.example/auction"); + request.set_header("Sec-GPC", "1"); + let context = create_test_auction_context(&settings, &request); + + let openrtb = provider.to_openrtb(&auction_request, &context, None); + let regs = openrtb.regs.as_ref().expect("should have regs"); + + assert_eq!( + regs.us_privacy.as_deref(), + Some(GPC_US_PRIVACY), + "should set us_privacy from Sec-GPC: 1" + ); + } + + #[test] + fn to_openrtb_ignores_gpc_header_with_non_one_value() { + let provider = PrebidAuctionProvider::new(base_config()); + let auction_request = create_test_auction_request(); + + let settings = make_settings(); + let mut request = Request::get("https://pub.example/auction"); + request.set_header("Sec-GPC", "0"); + let context = create_test_auction_context(&settings, &request); + + let openrtb = provider.to_openrtb(&auction_request, &context, None); + + assert!( + openrtb.regs.is_none(), + "should not set regs when Sec-GPC is not '1'" + ); + } + + #[test] + fn to_openrtb_sets_dnt_from_header() { + let provider = PrebidAuctionProvider::new(base_config()); + let mut auction_request = create_test_auction_request(); + auction_request.device = Some(DeviceInfo { + user_agent: Some("TestAgent".to_string()), + ip: None, + geo: None, + }); + + let settings = make_settings(); + let mut request = Request::get("https://pub.example/auction"); + request.set_header("DNT", "1"); + let context = create_test_auction_context(&settings, &request); + + let openrtb = provider.to_openrtb(&auction_request, &context, None); + let device = openrtb.device.as_ref().expect("should have device"); + + assert_eq!(device.dnt, Some(true), "should set dnt from DNT header"); + } + + #[test] + fn to_openrtb_sets_language_from_accept_language() { + let provider = PrebidAuctionProvider::new(base_config()); + let mut auction_request = create_test_auction_request(); + auction_request.device = Some(DeviceInfo { + user_agent: Some("TestAgent".to_string()), + ip: None, + geo: None, + }); + + let settings = make_settings(); + let mut request = Request::get("https://pub.example/auction"); + request.set_header("Accept-Language", "en-US,en;q=0.9,fr;q=0.8"); + let context = create_test_auction_context(&settings, &request); + + let openrtb = provider.to_openrtb(&auction_request, &context, None); + let device = openrtb.device.as_ref().expect("should have device"); + + assert_eq!( + device.language.as_deref(), + Some("en"), + "should extract primary ISO-639 language tag (stripped of locale subtag)" + ); + } + + #[test] + fn to_openrtb_sets_geo_lat_lon_metro() { + let provider = PrebidAuctionProvider::new(base_config()); + let mut auction_request = create_test_auction_request(); + auction_request.device = Some(DeviceInfo { + user_agent: Some("TestAgent".to_string()), + ip: Some("1.2.3.4".to_string()), + geo: Some(GeoInfo { + city: "New York".to_string(), + country: "US".to_string(), + continent: "NA".to_string(), + latitude: 40.7128, + longitude: -74.006, + metro_code: 501, + region: Some("NY".to_string()), + }), + }); + + let settings = make_settings(); + let request = Request::get("https://pub.example/auction"); + let context = create_test_auction_context(&settings, &request); + + let openrtb = provider.to_openrtb(&auction_request, &context, None); + let geo = openrtb + .device + .as_ref() + .and_then(|d| d.geo.as_ref()) + .expect("should have geo"); + + assert_eq!(geo.lat, Some(40.7128), "should set latitude"); + assert_eq!(geo.lon, Some(-74.006), "should set longitude"); + assert_eq!( + geo.metro.as_deref(), + Some("501"), + "should set metro (DMA code)" + ); + assert_eq!(geo.country.as_deref(), Some("US")); + assert_eq!(geo.city.as_deref(), Some("New York")); + assert_eq!(geo.region.as_deref(), Some("NY")); + } + + #[test] + fn to_openrtb_sets_tmax_and_cur() { + let provider = PrebidAuctionProvider::new(base_config()); + let auction_request = create_test_auction_request(); + + let settings = make_settings(); + let request = Request::get("https://pub.example/auction"); + let context = create_test_auction_context(&settings, &request); + + let openrtb = provider.to_openrtb(&auction_request, &context, None); + + assert_eq!( + openrtb.tmax, + Some(1000), + "should set tmax from config timeout_ms" + ); + assert_eq!( + openrtb.cur, + vec!["USD".to_string()], + "should set cur to USD" + ); + } + + #[test] + fn to_openrtb_omits_tmax_when_timeout_exceeds_i32_max() { + let mut config = base_config(); + config.timeout_ms = i32::MAX as u32 + 1; + let provider = PrebidAuctionProvider::new(config); + let auction_request = create_test_auction_request(); + + let settings = make_settings(); + let request = Request::get("https://pub.example/auction"); + let context = create_test_auction_context(&settings, &request); + + let openrtb = provider.to_openrtb(&auction_request, &context, None); + + assert_eq!( + openrtb.tmax, None, + "should omit tmax when timeout_ms exceeds i32::MAX" + ); + } + + #[test] + fn to_openrtb_drops_banner_format_with_out_of_range_dimensions() { + let provider = PrebidAuctionProvider::new(base_config()); + let mut auction_request = create_test_auction_request(); + auction_request.slots[0].formats.push(AdFormat { + media_type: MediaType::Banner, + width: i32::MAX as u32 + 1, + height: 250, + }); + + let settings = make_settings(); + let request = Request::get("https://pub.example/auction"); + let context = create_test_auction_context(&settings, &request); + + let openrtb = provider.to_openrtb(&auction_request, &context, None); + let formats = &openrtb.imp[0] + .banner + .as_ref() + .expect("should have banner") + .format; + + assert_eq!( + formats.len(), + 1, + "should keep only valid banner formats when one is out of range" + ); + assert_eq!(formats[0].w, Some(300), "should preserve valid width"); + assert_eq!(formats[0].h, Some(250), "should preserve valid height"); + } + + #[test] + fn to_openrtb_sets_site_ref_from_referer_header() { + let provider = PrebidAuctionProvider::new(base_config()); + let auction_request = create_test_auction_request(); + + let settings = make_settings(); + let mut request = Request::get("https://pub.example/auction"); + request.set_header("Referer", "https://google.com/search?q=test"); + let context = create_test_auction_context(&settings, &request); + + let openrtb = provider.to_openrtb(&auction_request, &context, None); + let site = openrtb.site.as_ref().expect("should have site"); + + assert_eq!( + site.r#ref.as_deref(), + Some("https://google.com/search?q=test"), + "should set site.ref from Referer header" + ); + } + + #[test] + fn to_openrtb_sets_site_publisher() { + let provider = PrebidAuctionProvider::new(base_config()); + let auction_request = create_test_auction_request(); + + let settings = make_settings(); + let request = Request::get("https://pub.example/auction"); + let context = create_test_auction_context(&settings, &request); + + let openrtb = provider.to_openrtb(&auction_request, &context, None); + let publisher = openrtb + .site + .as_ref() + .and_then(|s| s.publisher.as_ref()) + .expect("should have site.publisher"); + + assert_eq!( + publisher.domain.as_deref(), + Some("pub.example"), + "should set publisher domain" + ); + } + #[test] fn expand_trusted_server_bidders_uses_per_bidder_map_when_present() { let params = json!({ @@ -1686,7 +2215,7 @@ server_url = "https://prebid.example" } #[test] - fn test_routes_with_empty_script_patterns() { + fn routes_with_empty_script_patterns() { let mut config = base_config(); config.script_patterns = vec![]; let integration = PrebidIntegration::new(config); @@ -1697,32 +2226,28 @@ server_url = "https://prebid.example" assert_eq!(routes.len(), 0); } - /// Proves that the body-preview truncation in `parse_response` is not - /// UTF-8-safe. The production code does: - /// - /// `&body_preview[..body_preview.len().min(1000)]` - /// - /// which is a byte-index slice on a `str`. When byte 1000 lands inside a - /// multibyte character, Rust panics at runtime. This test constructs such - /// a string and asserts the truncation point is NOT a char boundary— - /// proving the bug without actually panicking (which would abort under - /// wasm32 `panic = "abort"`). + /// Verifies body-preview truncation keeps a UTF-8 char boundary. #[test] - fn body_preview_truncation_is_not_utf8_safe() { + fn body_preview_truncation_is_utf8_safe() { // 999 ASCII bytes + U+2603 SNOWMAN (3 bytes: E2 98 83) = 1002 bytes. // Byte index 1000 lands on 0x98, the second byte of the snowman. let mut body = "x".repeat(999); body.push('\u{2603}'); // ☃ assert_eq!(body.len(), 1002); - let truncation_index = body.len().min(1000); // = 1000 - - // This is the condition that causes `&body[..1000]` to panic. + let truncation_index = body.floor_char_boundary(1000); assert!( - !body.is_char_boundary(truncation_index), - "Byte index {} is not a char boundary — the truncation in \ - parse_response would panic on this input", - truncation_index + body.is_char_boundary(truncation_index), + "should truncate at a valid UTF-8 boundary" + ); + assert_eq!( + body[..truncation_index].len(), + 999, + "should drop the partial multibyte character" + ); + assert_eq!( + truncation_index, 999, + "should step back to the previous char boundary" ); } @@ -1779,13 +2304,20 @@ server_url = "https://prebid.example" provider.to_openrtb(request, &context, None) } - fn bidder_params(ortb: &OpenRtbRequest) -> &HashMap { - &ortb.imp[0] - .ext - .as_ref() - .expect("should have imp ext") - .prebid - .bidder + fn bidder_params(ortb: &OpenRtbRequest) -> &serde_json::Map { + let ext = ortb.imp[0].ext.as_ref().expect("should have imp ext"); + ext.get("prebid") + .and_then(|p| p.get("bidder")) + .and_then(|b| b.as_object()) + .expect("should have prebid.bidder in imp ext") + } + + /// Typed helper to extract `ext.prebid` from an `OpenRTB` request, + /// deserialising into [`PrebidExt`] so test assertions catch field name + /// typos at compile time. + fn get_prebid_ext(req: &OpenRtbRequest) -> PrebidExt { + let ext = req.ext.as_ref().expect("should have request ext"); + serde_json::from_value(ext["prebid"].clone()).expect("should deserialise ext.prebid") } // ======================================================================== @@ -1940,7 +2472,8 @@ server_url = "https://prebid.example" let request = make_auction_request(vec![slot]); let ortb = call_to_openrtb(config, &request); - let kargo = &bidder_params(&ortb)["kargo"]; + let params = bidder_params(&ortb); + let kargo = ¶ms["kargo"]; assert_eq!( kargo["placementId"], "s2s_header", "overridden field should have the zone value" diff --git a/crates/common/src/openrtb.rs b/crates/common/src/openrtb.rs index 9af3be69..d72bce66 100644 --- a/crates/common/src/openrtb.rs +++ b/crates/common/src/openrtb.rs @@ -3,105 +3,45 @@ use serde_json::Value; use crate::auction::types::OrchestratorExt; -/// Minimal subset of `OpenRTB` 2.x bid request used by Trusted Server. -#[derive(Debug, Serialize)] -#[serde(rename_all = "lowercase")] -pub struct OpenRtbRequest { - /// Unique ID of the bid request, provided by the exchange. - pub id: String, - 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 test: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub ext: Option, -} - -#[derive(Debug, Serialize)] -pub struct Imp { - pub id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub banner: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub ext: Option, -} - -#[derive(Debug, Serialize)] -pub struct Banner { - pub format: Vec, +pub type OpenRtbRequest = trusted_server_openrtb::BidRequest; +pub type OpenRtbResponse = trusted_server_openrtb::BidResponse; +pub type OpenRtbBid = trusted_server_openrtb::Bid; + +pub use trusted_server_openrtb::{ + Banner, Bid, BidResponse, Device, Format, Geo, Imp, Publisher, Regs, SeatBid, Site, ToExt, User, +}; + +/// Convert a `u32` value to `i32` for `OpenRTB` fields, logging a warning and +/// returning `None` if the value exceeds `i32::MAX`. +#[must_use] +pub fn to_openrtb_i32(value: u32, field_name: &str, context: &str) -> Option { + match i32::try_from(value) { + Ok(converted) => Some(converted), + Err(_) => { + log::warn!( + "openrtb: omitting {}={} for {} because value exceeds i32::MAX", + field_name, + value, + context + ); + None + } + } } -#[derive(Debug, Serialize)] -pub struct Format { - pub w: u32, - pub h: u32, -} +// ============================================================================ +// Extension types (project-specific, not part of the OpenRTB spec) +// ============================================================================ #[derive(Debug, Serialize)] -pub struct Site { - #[serde(skip_serializing_if = "Option::is_none")] - pub domain: Option, - #[serde(skip_serializing_if = "Option::is_none")] - 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 ua: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub ip: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub geo: Option, -} +impl ToExt for UserExt {} #[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, @@ -109,7 +49,7 @@ pub struct RequestExt { pub trusted_server: Option, } -#[derive(Debug, Serialize, Default)] +#[derive(Debug, Serialize, serde::Deserialize)] pub struct PrebidExt { #[serde(skip_serializing_if = "Option::is_none")] pub debug: Option, @@ -117,7 +57,9 @@ pub struct PrebidExt { pub returnallbidstatus: Option, } -#[derive(Debug, Serialize, Default)] +impl ToExt for RequestExt {} + +#[derive(Debug, Serialize)] pub struct TrustedServerExt { /// Version of the signing protocol (e.g., "1.1") #[serde(skip_serializing_if = "Option::is_none")] @@ -140,80 +82,61 @@ pub struct ImpExt { pub prebid: PrebidImpExt, } +impl ToExt for ImpExt {} + #[derive(Debug, Serialize)] pub struct PrebidImpExt { pub bidder: std::collections::HashMap, } -/// Minimal subset of `OpenRTB` 2.x bid response used by Trusted Server. -#[derive(Debug, Serialize)] -pub struct OpenRtbResponse { - pub id: String, - pub seatbid: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub ext: Option, -} - -#[derive(Debug, Serialize)] -pub struct SeatBid { - #[serde(skip_serializing_if = "Option::is_none")] - pub seat: Option, - pub bid: Vec, -} - -#[derive(Debug, Serialize)] -pub struct OpenRtbBid { - pub id: String, - pub impid: String, - pub price: f64, - #[serde(skip_serializing_if = "Option::is_none")] - pub adm: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub crid: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub w: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub h: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub adomain: Option>, -} - #[derive(Debug, Serialize)] pub struct ResponseExt { pub orchestrator: OrchestratorExt, } +impl ToExt for ResponseExt {} + #[cfg(test)] mod tests { - use super::{OpenRtbBid, OpenRtbResponse, ResponseExt, SeatBid}; + use super::*; use crate::auction::types::OrchestratorExt; #[test] - fn openrtb_response_serializes_expected_fields() { + fn openrtb_response_round_trips_with_struct_literals() { + let bid = OpenRtbBid { + id: Some("bidder-a-slot-1".to_string()), + impid: Some("slot-1".to_string()), + price: Some(1.25), + adm: Some("
Test Creative HTML
".to_string()), + crid: Some("bidder-a-creative".to_string()), + w: Some(300), + h: Some(250), + adomain: vec!["example.com".to_string()], + ..Default::default() + }; + + let seatbid = SeatBid { + seat: Some("bidder-a".to_string()), + bid: vec![bid], + ..Default::default() + }; + + let ext = ResponseExt { + orchestrator: OrchestratorExt { + strategy: "parallel_only".to_string(), + providers: 2, + total_bids: 3, + time_ms: 12, + provider_details: vec![], + }, + } + .to_ext(); + let response = OpenRtbResponse { - id: "auction-1".to_string(), - seatbid: vec![SeatBid { - seat: Some("bidder-a".to_string()), - bid: vec![OpenRtbBid { - id: "bidder-a-slot-1".to_string(), - impid: "slot-1".to_string(), - price: 1.25, - adm: Some("
Test Creative HTML
".to_string()), - crid: Some("bidder-a-creative".to_string()), - w: Some(300), - h: Some(250), - adomain: Some(vec!["example.com".to_string()]), - }], - }], - ext: Some(ResponseExt { - orchestrator: OrchestratorExt { - strategy: "parallel_only".to_string(), - providers: 2, - total_bids: 3, - time_ms: 12, - provider_details: vec![], - }, - }), + id: Some("auction-1".to_string()), + seatbid: vec![seatbid], + ext, + ..Default::default() }; let serialized = serde_json::to_value(&response).expect("should serialize"); diff --git a/crates/openrtb-codegen/.gitignore b/crates/openrtb-codegen/.gitignore new file mode 100644 index 00000000..b83d2226 --- /dev/null +++ b/crates/openrtb-codegen/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/crates/openrtb-codegen/Cargo.lock b/crates/openrtb-codegen/Cargo.lock new file mode 100644 index 00000000..c8f8519a --- /dev/null +++ b/crates/openrtb-codegen/Cargo.lock @@ -0,0 +1,575 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "openrtb-codegen" +version = "0.1.0" +dependencies = [ + "prost-build", +] + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/crates/openrtb-codegen/Cargo.toml b/crates/openrtb-codegen/Cargo.toml new file mode 100644 index 00000000..9f9ef4bf --- /dev/null +++ b/crates/openrtb-codegen/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "openrtb-codegen" +version = "0.1.0" +edition = "2024" +publish = false +license = "Apache-2.0" + +[dependencies] +prost-build = "0.13" diff --git a/crates/openrtb-codegen/src/main.rs b/crates/openrtb-codegen/src/main.rs new file mode 100644 index 00000000..e8022682 --- /dev/null +++ b/crates/openrtb-codegen/src/main.rs @@ -0,0 +1,57 @@ +//! Generates `crates/openrtb/src/generated.rs` from `proto/openrtb.proto`. +//! +//! Run via the wrapper script: +//! +//! ```sh +//! ./crates/openrtb/generate.sh +//! ``` +//! +//! Requires `protoc` to be installed. + +use std::fs; +use std::path::PathBuf; + +// Pull in postprocess() and helpers from the openrtb crate's codegen module. +include!("../../openrtb/src/codegen.rs"); + +fn main() { + let openrtb_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../openrtb"); + let proto_path = openrtb_dir.join("proto/openrtb.proto"); + let proto_dir = openrtb_dir.join("proto"); + let output_path = openrtb_dir.join("src/generated.rs"); + + // Use a temporary directory for raw prost-build output. + let tmp = std::env::temp_dir().join("openrtb-codegen"); + fs::create_dir_all(&tmp).expect("should create temp directory"); + + // Phase 1: Compile proto with prost-build. + eprintln!("Compiling {}...", proto_path.display()); + prost_build::Config::new() + .out_dir(&tmp) + .compile_protos( + &[proto_path.to_str().expect("should have valid proto path")], + &[proto_dir.to_str().expect("should have valid proto dir")], + ) + .expect("should compile openrtb.proto (is protoc installed?)"); + + // Phase 2: Post-process the generated file. + let raw_path = tmp.join("com.iabtechlab.openrtb.v2.rs"); + let code = fs::read_to_string(&raw_path).expect("should read raw prost output"); + let processed = postprocess(&code); + + // Phase 3: Write the checked-in file with a header. + let output = format!( + "\ +// @generated by openrtb-codegen from proto/openrtb.proto +// Do not edit manually — run `./crates/openrtb/generate.sh` to regenerate. +// source: proto/openrtb.proto + +{processed}" + ); + + fs::write(&output_path, output).expect("should write generated.rs"); + eprintln!("Wrote {}", output_path.display()); + + // Clean up temp files. + let _ = fs::remove_dir_all(&tmp); +} diff --git a/crates/openrtb/Cargo.toml b/crates/openrtb/Cargo.toml new file mode 100644 index 00000000..553f47ae --- /dev/null +++ b/crates/openrtb/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "trusted-server-openrtb" +version = "0.1.0" +authors = [] +edition = "2024" +publish = false +license = "Apache-2.0" + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/openrtb/README.md b/crates/openrtb/README.md new file mode 100644 index 00000000..426e218d --- /dev/null +++ b/crates/openrtb/README.md @@ -0,0 +1,49 @@ +# trusted-server-openrtb + +OpenRTB 2.6 data model generated from the [IAB Tech Lab protobuf schema](https://github.com/nicoboss/openrtb/blob/master/proto/openrtb.proto). Types are used exclusively with JSON serde — protobuf binary encoding is stripped at build time. + +## Build dependency + +This crate requires `protoc` at compile time (invoked by `prost-build`): + +```sh +# macOS +brew install protobuf + +# Debian / Ubuntu +apt install protobuf-compiler +``` + +## How types are generated + +The `build.rs` pipeline has three phases: + +1. **Proto compilation** — `prost-build` compiles `proto/openrtb.proto` into Rust structs. +2. **Strip protobuf concerns** — `prost::Message` derives and `#[prost(...)]` attributes are removed since we only use JSON encoding. +3. **Add serde + OpenRTB support** — `Serialize`/`Deserialize` derives are injected along with `skip_serializing_if` for `Option` and `Vec` fields. Extensible structs receive an `ext: Option>` field, and `Option` fields get the `bool_as_int` serde adapter for the OpenRTB `0`/`1` convention. + +### Proto modifications from upstream + +The IAB proto uses `edition = "2023"` which generates non-optional scalars. The local copy converts to `proto2` with explicit `optional` on every field so prost generates `Option`, matching OpenRTB's "omit if not set" semantics. `Ext` messages are removed from the proto and re-injected by the build script as `Option`. See the header comment in `proto/openrtb.proto` for the full list of changes. + +## Crate API + +All generated types are re-exported at the crate root for flat access: + +```rust +use trusted_server_openrtb::{BidRequest, BidResponse, Imp, Banner, Device, User}; +``` + +### `bool_as_int` + +Serde helper module that transparently converts `Option` to/from `0`/`1` integers on the wire. Applied automatically to generated boolean fields. + +### `ToExt` + +Trait for converting any `Serialize` type into an `Option>` suitable for an `ext` field. Returns `None` for empty maps so `ext` is omitted from JSON output rather than serialized as `"ext": {}`. + +```rust +use trusted_server_openrtb::ToExt; + +let ext_value = my_custom_ext.to_ext(); // Option> +``` diff --git a/crates/openrtb/generate.sh b/crates/openrtb/generate.sh new file mode 100755 index 00000000..edecfe87 --- /dev/null +++ b/crates/openrtb/generate.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Regenerate src/generated.rs from proto/openrtb.proto. +# +# This only needs to be run when the proto file changes. Normal builds use the +# checked-in generated.rs directly — no protoc required. +# +# Usage: +# ./crates/openrtb/generate.sh +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CODEGEN_DIR="$SCRIPT_DIR/../openrtb-codegen" + +# Check for protoc +if ! command -v protoc &>/dev/null; then + echo "error: protoc is required but not installed" >&2 + echo "Install from https://github.com/protocolbuffers/protobuf/releases" >&2 + echo " macOS: brew install protobuf" >&2 + echo " Ubuntu: apt install -y protobuf-compiler" >&2 + exit 1 +fi + +# Build as a native binary (the repo's .cargo/config.toml defaults to wasm32). +NATIVE_TARGET="$(rustc -vV | grep '^host:' | cut -d' ' -f2)" + +echo "protoc version: $(protoc --version)" +echo "Generating OpenRTB types from proto/openrtb.proto..." + +cargo run \ + --manifest-path "$CODEGEN_DIR/Cargo.toml" \ + --target "$NATIVE_TARGET" + +echo "Formatting generated code..." +rustfmt "$SCRIPT_DIR/src/generated.rs" + +echo "Done." diff --git a/crates/openrtb/proto/openrtb.proto b/crates/openrtb/proto/openrtb.proto new file mode 100644 index 00000000..5bcb8077 --- /dev/null +++ b/crates/openrtb/proto/openrtb.proto @@ -0,0 +1,736 @@ +// OpenRTB 2.6 Protocol Buffer schema. +// +// Derived from the IAB Tech Lab OpenRTB proto (edition = "2023") and converted +// to proto2 syntax so that prost generates `Option` for every scalar field. +// +// Modifications from the upstream IAB proto: +// - Changed `edition = "2023"` to `syntax = "proto2"`. +// - Added `optional` keyword to all non-repeated fields. +// - Flattened `oneof` blocks into separate optional fields (JSON compat). +// - Removed `Ext` messages and `ext` fields (re-added by build.rs as +// `Option>`). +// - Removed InterestGroupAuction* types and google/protobuf/struct.proto +// import (not used). +// - Removed `extensions` declarations (not supported by prost). +// +// Copyright 2020 IAB Tech Lab — Apache License 2.0. + +syntax = "proto2"; + +package com.iabtechlab.openrtb.v2; + +// OpenRTB 2.0: The top-level bid request object. +message BidRequest { + optional string id = 1; + repeated Imp imp = 2; + + // Distribution channel — flattened from oneof for JSON compat. + optional Site site = 3; + optional App app = 4; + optional Dooh dooh = 22; + + optional Device device = 5; + optional User user = 6; + optional bool test = 15 [default = false]; + optional int32 at = 7 [default = 2]; + optional int32 tmax = 8; + repeated string wseat = 9; + repeated string bseat = 17; + optional bool allimps = 10 [default = false]; + repeated string cur = 11; + repeated string wlang = 18; + repeated string wlangb = 20; + repeated string acat = 23; + repeated string bcat = 12; + optional int32 cattax = 21 [default = 1]; + repeated string badv = 13; + repeated string bapp = 16; + optional Source source = 19; + optional Regs regs = 14; + + // Source object. + message Source { + optional bool fd = 1; + optional string tid = 2; + optional string pchain = 3; + optional SupplyChain schain = 4; + } + + // SupplyChain object. + message SupplyChain { + optional bool complete = 1; + repeated SupplyChainNode nodes = 2; + optional string ver = 3; + } + + // SupplyChainNode object. + message SupplyChainNode { + optional string asi = 1; + optional string sid = 2; + optional string rid = 3; + optional string name = 4; + optional string domain = 5; + optional bool hp = 6; + } + + // Imp object. + message Imp { + optional string id = 1; + repeated Metric metric = 17; + optional Banner banner = 2; + optional Video video = 3; + optional Audio audio = 15; + optional Native native = 13; + optional Pmp pmp = 11; + optional string displaymanager = 4; + optional string displaymanagerver = 5; + optional bool instl = 6; + optional string tagid = 7; + optional double bidfloor = 8 [default = 0]; + optional string bidfloorcur = 9 [default = "USD"]; + optional bool clickbrowser = 16; + optional bool secure = 12; + repeated string iframebuster = 10; + optional bool rwdd = 18 [default = false]; + optional int32 ssai = 19 [default = 0]; + optional int32 exp = 14; + optional Qty qty = 20; + optional double dt = 21; + optional Refresh refresh = 22; + } + + // Metric object. + message Metric { + optional string type = 1; + optional double value = 2; + optional string vendor = 3; + } + + // Banner object. + message Banner { + repeated Format format = 15; + optional int32 w = 1; + optional int32 h = 2; + repeated int32 btype = 5; + repeated int32 battr = 6; + optional int32 pos = 4; + repeated string mimes = 7; + optional bool topframe = 8; + repeated int32 expdir = 9; + repeated int32 api = 10; + optional string id = 3; + optional bool vcm = 16; + optional int32 wmax = 11 [deprecated = true]; + optional int32 hmax = 12 [deprecated = true]; + optional int32 wmin = 13 [deprecated = true]; + optional int32 hmin = 14 [deprecated = true]; + } + + // Format object. + message Format { + optional int32 w = 1; + optional int32 h = 2; + optional int32 wratio = 3; + optional int32 hratio = 4; + optional int32 wmin = 5; + } + + // Video object. + message Video { + repeated string mimes = 1; + optional int32 minduration = 3 [default = 0]; + optional int32 maxduration = 4; + optional int32 startdelay = 8; + optional int32 maxseq = 28; + optional int32 poddur = 29; + repeated int32 protocols = 21; + optional int32 w = 6; + optional int32 h = 7; + optional string podid = 30; + optional int32 podseq = 31 [default = 0]; + repeated int32 rqddurs = 32; + optional int32 placement = 26 [deprecated = true]; + optional int32 plcmt = 35; + optional int32 linearity = 2; + optional bool skip = 23; + optional int32 skipmin = 24; + optional int32 skipafter = 25; + optional int32 sequence = 9 [default = 0, deprecated = true]; + optional int32 slotinpod = 33 [default = 0]; + optional double mincpmpersec = 34; + repeated int32 battr = 10; + optional int32 maxextended = 11; + optional int32 minbitrate = 12; + optional int32 maxbitrate = 13; + optional bool boxingallowed = 14 [default = true]; + repeated int32 playbackmethod = 15; + optional int32 playbackend = 27; + repeated int32 delivery = 16; + optional int32 pos = 17; + repeated Banner companionad = 18; + repeated int32 api = 19; + repeated int32 companiontype = 20; + repeated int32 poddedupe = 37; + repeated DurFloors durfloors = 36; + optional int32 protocol = 5 [deprecated = true]; + + reserved 22; + } + + // Audio object. + message Audio { + repeated string mimes = 1; + optional int32 minduration = 2 [default = 0]; + optional int32 maxduration = 3; + optional int32 poddur = 25; + repeated int32 protocols = 4; + optional int32 startdelay = 5; + repeated int32 rqddurs = 26; + optional string podid = 27; + optional int32 podseq = 28 [default = 0]; + optional int32 sequence = 6 [default = 0, deprecated = true]; + optional int32 slotinpod = 29 [default = 0]; + optional double mincpmpersec = 30; + repeated int32 battr = 7; + optional int32 maxextended = 8; + optional int32 minbitrate = 9; + optional int32 maxbitrate = 10; + repeated int32 delivery = 11; + repeated Banner companionad = 12; + repeated int32 api = 13; + repeated int32 companiontype = 20; + optional int32 maxseq = 21; + optional int32 feed = 22; + optional bool stitched = 23; + optional int32 nvol = 24; + repeated DurFloors durfloors = 31; + } + + // Native object. + // Flattened from oneof request_oneof for JSON compat. + message Native { + optional string request = 1; + optional NativeRequest request_native = 50; + optional string ver = 2; + repeated int32 api = 3; + repeated int32 battr = 4; + } + + // Qty object. + message Qty { + optional double multiplier = 1; + optional int32 sourcetype = 2; + optional string vendor = 3; + } + + // Refresh object. + message Refresh { + repeated RefSettings refsettings = 1; + optional int32 count = 2; + } + + // RefSettings object. + message RefSettings { + optional int32 reftype = 1 [default = 0]; + optional int32 minint = 2; + } + + // Pmp object. + message Pmp { + optional bool private_auction = 1 [default = false]; + repeated Deal deals = 2; + } + + // Deal object. + message Deal { + optional string id = 1; + optional double bidfloor = 2 [default = 0]; + optional string bidfloorcur = 3 [default = "USD"]; + optional int32 at = 6; + repeated string wseat = 4; + repeated string wadomain = 5; + optional int32 guar = 7 [default = 0]; + optional double mincpmpersec = 8; + repeated DurFloors durfloors = 9; + } + + // Site object. + message Site { + optional string id = 1; + optional string name = 2; + optional string domain = 3; + optional int32 cattax = 16 [default = 1]; + repeated string cat = 4; + repeated string sectioncat = 5; + repeated string pagecat = 6; + optional string page = 7; + optional string ref = 9; + optional string search = 10; + optional bool mobile = 15; + optional bool privacypolicy = 8; + optional Publisher publisher = 11; + optional Content content = 12; + optional string keywords = 13; + repeated string kwarray = 18; + optional string inventorypartnerdomain = 17; + + reserved 14; + } + + // App object. + message App { + optional string id = 1; + optional string name = 2; + optional string bundle = 8; + optional string domain = 3; + optional string storeurl = 16; + optional int32 cattax = 17 [default = 1]; + repeated string cat = 4; + repeated string sectioncat = 5; + repeated string pagecat = 6; + optional string ver = 7; + optional bool privacypolicy = 9; + optional bool paid = 10; + optional Publisher publisher = 11; + optional Content content = 12; + optional string keywords = 13; + repeated string kwarray = 19; + optional string inventorypartnerdomain = 18; + + reserved 14; + } + + // Dooh object. + message Dooh { + optional string id = 1; + optional string name = 2; + repeated string venuetype = 3; + optional int32 venuetypetax = 4 [default = 1]; + optional Publisher publisher = 5; + optional string domain = 6; + optional string keywords = 7; + optional Content content = 8; + } + + // Publisher object. + message Publisher { + optional string id = 1; + optional string name = 2; + optional int32 cattax = 5 [default = 1]; + repeated string cat = 3; + optional string domain = 4; + } + + // Content object. + message Content { + optional string id = 1; + optional int32 episode = 2; + optional string title = 3; + optional string series = 4; + optional string season = 5; + optional string artist = 21; + optional string genre = 22; + optional int32 gtax = 33 [default = 9]; + repeated string genres = 35; + optional string album = 23; + optional string isrc = 24; + optional Producer producer = 15; + optional string url = 6; + optional int32 cattax = 27 [default = 1]; + repeated string cat = 7; + optional int32 prodq = 25; + optional int32 context = 20; + optional string contentrating = 10; + optional string userrating = 11; + optional int32 qagmediarating = 17; + optional string keywords = 9; + repeated string kwarray = 32; + optional bool livestream = 13; + optional bool sourcerelationship = 14; + optional int32 len = 16; + optional string language = 19; + optional string langb = 29; + optional bool embeddable = 18; + repeated Data data = 28; + optional Network network = 30; + optional Channel channel = 31; + optional int32 videoquality = 8 [deprecated = true]; + + reserved 12, 26, 34; + } + + // Producer object. + message Producer { + optional string id = 1; + optional string name = 2; + optional int32 cattax = 5 [default = 1]; + repeated string cat = 3; + optional string domain = 4; + } + + // Network object. + message Network { + optional string id = 1; + optional string name = 2; + optional string domain = 3; + } + + // Channel object. + message Channel { + optional string id = 1; + optional string name = 2; + optional string domain = 3; + } + + // Device object. + message Device { + optional Geo geo = 4; + optional bool dnt = 1; + optional bool lmt = 23; + optional string ua = 2; + optional UserAgent sua = 31; + optional string ip = 3; + optional string ipv6 = 9; + optional int32 devicetype = 18; + optional string make = 12; + optional string model = 13; + optional string os = 14; + optional string osv = 15; + optional string hwv = 24; + optional int32 w = 25; + optional int32 h = 26; + optional int32 ppi = 27; + optional double pxratio = 28; + optional bool js = 16; + optional bool geofetch = 29; + optional string flashver = 19; + optional string language = 11; + optional string langb = 32; + optional string carrier = 10; + optional string mccmnc = 30; + optional int32 connectiontype = 17; + optional string ifa = 20; + optional string didsha1 = 5 [deprecated = true]; + optional string didmd5 = 6 [deprecated = true]; + optional string dpidsha1 = 7 [deprecated = true]; + optional string dpidmd5 = 8 [deprecated = true]; + optional string macsha1 = 21 [deprecated = true]; + optional string macmd5 = 22 [deprecated = true]; + } + + // Geo object. + message Geo { + optional double lat = 1; + optional double lon = 2; + optional int32 type = 9; + optional int32 accuracy = 11; + optional int32 lastfix = 12; + optional int32 ipservice = 13; + optional string country = 3; + optional string region = 4; + optional string regionfips104 = 5; + optional string metro = 6; + optional string city = 7; + optional string zip = 8; + optional int32 utcoffset = 10; + } + + // UserAgent object (Structured UA / Client Hints). + message UserAgent { + repeated BrandVersion browsers = 1; + optional BrandVersion platform = 2; + optional bool mobile = 3; + optional string architecture = 4; + optional string bitness = 5; + optional string model = 6; + optional int32 source = 7 [default = 0]; + } + + // BrandVersion object. + message BrandVersion { + optional string brand = 1; + repeated string version = 2; + } + + // User object. + message User { + optional string id = 1; + optional string buyeruid = 2; + optional int32 yob = 3 [deprecated = true]; + optional string gender = 4 [deprecated = true]; + optional string keywords = 5; + repeated string kwarray = 9; + optional string customdata = 6; + optional Geo geo = 7; + repeated Data data = 8; + optional string consent = 10; + repeated EID eids = 11; + } + + // EID (Extended Identifier) object. + message EID { + optional string inserter = 3; + optional string source = 1; + optional string matcher = 4; + optional int32 mm = 5; + repeated UID uids = 2; + + // UID object. + message UID { + optional string id = 1; + optional int32 atype = 2; + } + } + + // Data object. + message Data { + optional string id = 1; + optional string name = 2; + repeated Segment segment = 3; + } + + // Segment object. + message Segment { + optional string id = 1; + optional string name = 2; + optional string value = 3; + } + + // Regs object. + message Regs { + optional bool coppa = 1; + optional bool gdpr = 4; + optional string us_privacy = 5; + optional string gpp = 2; + repeated int32 gpp_sid = 3; + } + + // DurFloors object. + message DurFloors { + optional int32 mindur = 1; + optional int32 maxdur = 2; + optional double bidfloor = 3 [default = 0.0]; + } +} + +// BidResponse — top-level response object. +message BidResponse { + optional string id = 1; + repeated SeatBid seatbid = 2; + optional string bidid = 3; + optional string cur = 4; + optional string customdata = 5; + optional int32 nbr = 6; + + // SeatBid object. + message SeatBid { + repeated Bid bid = 1; + optional string seat = 2; + optional bool group = 3 [default = false]; + } + + // Bid object. + message Bid { + optional string id = 1; + optional string impid = 2; + optional double price = 3; + optional string nurl = 5; + optional string burl = 22; + optional string lurl = 23; + + // Flattened from oneof adm_oneof for JSON compat. + optional string adm = 6; + optional NativeResponse adm_native = 50; + + optional string adid = 4; + repeated string adomain = 7; + optional string bundle = 14; + optional string iurl = 8; + optional string cid = 9; + optional string crid = 10; + optional string tactic = 24; + optional int32 cattax = 30 [default = 1]; + repeated string cat = 15; + repeated int32 attr = 11; + repeated int32 apis = 31; + optional int32 api = 18 [deprecated = true]; + optional int32 protocol = 19; + optional int32 qagmediarating = 20; + optional string language = 25; + optional string langb = 29; + optional string dealid = 13; + optional int32 w = 16; + optional int32 h = 17; + optional int32 wratio = 26; + optional int32 hratio = 27; + optional int32 exp = 21; + optional int32 dur = 32; + optional int32 mtype = 33; + optional int32 slotinpod = 28 [default = 0]; + } +} + +// Transparency object (shared by request and response DSA). +message Transparency { + optional string domain = 1; + repeated int32 dsaparams = 2; +} + +// NativeRequest object. +message NativeRequest { + optional string ver = 1; + optional int32 layout = 2; + optional int32 adunit = 3; + optional int32 context = 7; + optional int32 contextsubtype = 8; + optional int32 plcmttype = 9; + optional int32 plcmtcnt = 4 [default = 1]; + optional int32 seq = 5 [default = 0]; + repeated Asset assets = 6; + optional bool aurlsupport = 11; + optional bool durlsupport = 12; + repeated EventTrackers eventtrackers = 13; + optional bool privacy = 14; + + // Asset object. + message Asset { + optional int32 id = 1; + optional bool required = 2 [default = false]; + optional Title title = 3; + optional Image img = 4; + optional BidRequest.Video video = 5; + optional Data data = 6; + } + + // Title object. + message Title { + optional int32 len = 1; + } + + // Image object. + message Image { + optional int32 type = 1; + optional int32 w = 2; + optional int32 h = 3; + optional int32 wmin = 4; + optional int32 hmin = 5; + repeated string mimes = 6; + } + + // Data object. + message Data { + optional int32 type = 1; + optional int32 len = 2; + } + + // EventTrackers object. + message EventTrackers { + optional int32 event = 1; + repeated int32 methods = 2; + } +} + +// NativeResponse object. +message NativeResponse { + optional string ver = 1; + repeated Asset assets = 2; + optional string assetsurl = 6; + optional string dcourl = 7; + optional Link link = 3; + repeated string imptrackers = 4; + optional string jstracker = 5; + repeated EventTracker eventtrackers = 8; + optional string privacy = 9; + + // Link object. + message Link { + optional string url = 1; + repeated string clicktrackers = 2; + optional string fallback = 3; + } + + // Asset object. + message Asset { + optional int32 id = 1; + optional bool required = 2 [default = false]; + optional Title title = 3; + optional Image img = 4; + optional Video video = 5; + optional Data data = 6; + optional Link link = 7; + } + + // Title object. + message Title { + optional string text = 1; + optional int32 len = 2; + } + + // Image object. + message Image { + optional int32 type = 4; + optional string url = 1; + optional int32 w = 2; + optional int32 h = 3; + } + + // Data object. + message Data { + optional int32 type = 3; + optional int32 len = 4; + optional string label = 1 [deprecated = true]; + optional string value = 2; + } + + // Video object. + message Video { + optional string vasttag = 1; + } + + // EventTracker object. + message EventTracker { + optional int32 event = 1; + optional int32 method = 2; + optional string url = 3; + optional string customdata = 4; + } +} + +// ***** OpenRTB Core enums **************************************************** + +enum BannerAdType { + BannerAdType_UNKNOWN = 0; + XHTML_TEXT_AD = 1; + XHTML_BANNER_AD = 2; + JAVASCRIPT_AD = 3; + IFRAME = 4; +} + +// ***** OpenRTB Native enums ************************************************** + +enum LayoutId { + LayoutId_UNKNOWN = 0; + CONTENT_WALL = 1; + APP_WALL = 2; + NEWS_FEED = 3; + CHAT_LIST = 4; + CAROUSEL = 5; + CONTENT_STREAM = 6; + GRID = 7; +} + +enum AdUnitId { + AdUnitId_UNKNOWN = 0; + PAID_SEARCH_UNIT = 1; + RECOMMENDATION_WIDGET = 2; + PROMOTED_LISTING = 3; + IAB_IN_AD_NATIVE = 4; + ADUNITID_CUSTOM = 5; +} + +enum ContextType { + ContextType_UNKNOWN = 0; + CONTENT = 1; + SOCIAL = 2; + PRODUCT = 3; +} diff --git a/crates/openrtb/src/codegen.rs b/crates/openrtb/src/codegen.rs new file mode 100644 index 00000000..e0d84b27 --- /dev/null +++ b/crates/openrtb/src/codegen.rs @@ -0,0 +1,375 @@ +/// Names of structs that should receive an `ext` field. +const EXT_STRUCTS: &[&str] = &[ + "BidRequest", + "Source", + "SupplyChain", + "SupplyChainNode", + "Imp", + "Metric", + "Banner", + "Format", + "Video", + "Audio", + "Native", + "Qty", + "Refresh", + "RefSettings", + "Pmp", + "Deal", + "Site", + "App", + "Dooh", + "Publisher", + "Content", + "Producer", + "Network", + "Channel", + "Device", + "Geo", + "UserAgent", + "BrandVersion", + "User", + "Eid", + "Uid", + "Data", + "Segment", + "Regs", + "DurFloors", + "BidResponse", + "SeatBid", + "Bid", + "Transparency", +]; + +/// Post-process prost-generated Rust code. +/// +/// Strips `::prost::Message` derives and `#[prost(...)]` field attributes (we +/// don't need protobuf binary encoding), then adds serde derives, skip +/// annotations, and `ext` fields. +fn postprocess(code: &str) -> String { + let mut output = String::with_capacity(code.len() * 2); + let lines: Vec<&str> = code.lines().collect(); + let len = lines.len(); + + // Stack tracking for ext injection: when we enter a struct that needs ext, + // push the brace depth at entry. On the matching close brace we inject. + let mut struct_ext_stack: Vec = Vec::new(); + let mut brace_depth: usize = 0; + + let mut i = 0; + while i < len { + let line = lines[i]; + let trimmed = line.trim(); + + // --- Derive replacement --- + + // Struct derives: replace prost::Message with Default + serde. + // Handles both `Clone, PartialEq` and `Clone, Copy, PartialEq`. + // Remove Copy since ext fields (Map) are not Copy. + if trimmed.contains("::prost::Message)]") && trimmed.starts_with("#[derive(Clone,") { + let indent = leading_whitespace(line); + output.push_str(&format!( + "{indent}#[derive(Clone, Debug, Default, PartialEq, \ + ::serde::Serialize, ::serde::Deserialize)]\n" + )); + output.push_str(&format!("{indent}#[serde(default)]\n")); + i += 1; + continue; + } + + // Enum derives: strip prost::Enumeration (no runtime prost dependency), + // add serde. Do NOT add Default — the `#[repr(i32)]` enums in this + // proto are catalog/documentation enums not used as struct fields. + if trimmed.contains("::prost::Enumeration)]") && trimmed.starts_with("#[derive(") { + let indent = leading_whitespace(line); + output.push_str(&format!( + "{indent}#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, \ + PartialOrd, Ord, ::serde::Serialize, ::serde::Deserialize)]\n", + )); + i += 1; + continue; + } + + // Oneof derives: strip prost::Oneof, add serde. + if trimmed.contains("::prost::Oneof)]") && trimmed.starts_with("#[derive(") { + let indent = leading_whitespace(line); + output.push_str(&format!( + "{indent}#[derive(Clone, Debug, PartialEq, \ + ::serde::Serialize, ::serde::Deserialize)]\n", + )); + i += 1; + continue; + } + + // --- Strip #[prost(...)] attributes, inject serde attributes --- + + if is_prost_attr(trimmed) { + // Look ahead to the next line to determine serde annotation. + if i + 1 < len { + let next_trimmed = lines[i + 1].trim(); + let indent = leading_whitespace(lines[i + 1]); + if is_bool_prost_attr(trimmed) && is_option_bool_field(next_trimmed) { + // OpenRTB JSON uses 0/1 for boolean fields, not true/false. + output.push_str(&format!( + "{indent}#[serde(with = \"crate::bool_as_int\", \ + skip_serializing_if = \"Option::is_none\")]\n" + )); + } else if is_option_field(next_trimmed) { + output.push_str(&format!( + "{indent}#[serde(skip_serializing_if = \"Option::is_none\")]\n" + )); + } else if is_vec_field(next_trimmed) { + output.push_str(&format!( + "{indent}#[serde(skip_serializing_if = \"Vec::is_empty\", default)]\n" + )); + } else if is_hashmap_field(next_trimmed) { + output.push_str(&format!( + "{indent}#[serde(skip_serializing_if = \ + \"::std::collections::HashMap::is_empty\", default)]\n" + )); + } + // else: plain scalar field — no serde attribute needed. + } + // Skip the #[prost(...)] line entirely. + i += 1; + continue; + } + + // --- Track struct entries for ext injection --- + + if trimmed.starts_with("pub struct ") + && trimmed.ends_with('{') + && let Some(name) = extract_struct_name(trimmed) + && EXT_STRUCTS.contains(&name) + { + struct_ext_stack.push(brace_depth + 1); + } + + // Count braces on the current line to track struct scope depth. + // NOTE: This assumes no string literals contain unbalanced braces. + // That holds for prost-generated output but would need revision for + // other inputs (e.g. code with brace-containing default values). + for ch in trimmed.chars() { + match ch { + '{' => brace_depth += 1, + '}' => brace_depth = brace_depth.saturating_sub(1), + _ => {} + } + } + + // Check if this closing brace matches a struct that needs ext. + if trimmed == "}" + && let Some(&expected_depth) = struct_ext_stack.last() + && brace_depth + 1 == expected_depth + { + struct_ext_stack.pop(); + let indent = leading_whitespace(line); + let field_indent = format!("{indent} "); + output.push_str(&format!( + "{field_indent}/// Placeholder for exchange-specific extensions to OpenRTB.\n" + )); + output.push_str(&format!( + "{field_indent}#[serde(skip_serializing_if = \"Option::is_none\")]\n" + )); + output.push_str(&format!( + "{field_indent}pub ext: ::core::option::Option<\ + ::serde_json::Map<::std::string::String, ::serde_json::Value>>,\n" + )); + } + + output.push_str(line); + output.push('\n'); + i += 1; + } + + // Replace prost alloc types with std equivalents so the generated code + // does not depend on the prost crate at runtime. + output + .replace("::prost::alloc::string::String", "::std::string::String") + .replace("::prost::alloc::vec::Vec", "::std::vec::Vec") + .replace("::prost::alloc::boxed::Box", "::std::boxed::Box") +} + +/// Extract leading whitespace from a line. +fn leading_whitespace(line: &str) -> &str { + let trimmed_len = line.trim_start().len(); + &line[..line.len() - trimmed_len] +} + +/// Extract struct name from a line like `pub struct Foo {`. +fn extract_struct_name(trimmed: &str) -> Option<&str> { + let after = trimmed.strip_prefix("pub struct ")?; + let end = after.find(|c: char| !c.is_alphanumeric() && c != '_')?; + Some(&after[..end]) +} + +/// Returns true if the line declares an `Option<...>` field. +fn is_option_field(trimmed: &str) -> bool { + trimmed.starts_with("pub ") && trimmed.contains("::core::option::Option<") +} + +/// Returns true if the line declares a `Vec<...>` field (not inside Option). +fn is_vec_field(trimmed: &str) -> bool { + trimmed.starts_with("pub ") + && trimmed.contains("::prost::alloc::vec::Vec<") + && !trimmed.contains("Option<") +} + +/// Returns true if the line declares a `HashMap<...>` field. +fn is_hashmap_field(trimmed: &str) -> bool { + trimmed.starts_with("pub ") && trimmed.contains("::std::collections::HashMap<") +} + +/// Returns true if the line is a `#[prost(...)]` attribute. +fn is_prost_attr(trimmed: &str) -> bool { + trimmed.starts_with("#[prost(") +} + +/// Returns true if the prost attribute declares a `bool` field. +fn is_bool_prost_attr(trimmed: &str) -> bool { + trimmed.starts_with("#[prost(bool,") +} + +/// Returns true if the line declares an `Option` field. +fn is_option_bool_field(trimmed: &str) -> bool { + trimmed.starts_with("pub ") && trimmed.contains("::core::option::Option") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Minimal prost-like struct output to verify derive replacement and ext + /// injection. + const PROST_STRUCT: &str = "\ +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct BidRequest { + #[prost(string, optional, tag = \"1\")] + pub id: ::core::option::Option<::prost::alloc::string::String>, + #[prost(message, repeated, tag = \"2\")] + pub imp: ::prost::alloc::vec::Vec, +} +"; + + #[test] + fn replaces_struct_derives_with_serde() { + let output = postprocess(PROST_STRUCT); + assert!( + output.contains("::serde::Serialize, ::serde::Deserialize"), + "should add serde derives: {output}" + ); + assert!( + !output.contains("::prost::Message"), + "should strip prost::Message: {output}" + ); + assert!( + output.contains("#[serde(default)]"), + "should add #[serde(default)]: {output}" + ); + } + + #[test] + fn strips_prost_field_attrs_and_injects_serde_skip() { + let output = postprocess(PROST_STRUCT); + assert!( + !output.contains("#[prost("), + "should strip all #[prost(...)] attrs: {output}" + ); + assert!( + output.contains("skip_serializing_if = \"Option::is_none\""), + "should add skip_serializing_if for Option fields: {output}" + ); + assert!( + output.contains("skip_serializing_if = \"Vec::is_empty\""), + "should add skip_serializing_if for Vec fields: {output}" + ); + } + + #[test] + fn injects_ext_field_for_ext_structs() { + let output = postprocess(PROST_STRUCT); + assert!( + output.contains("pub ext: ::core::option::Option<"), + "should inject ext field for BidRequest: {output}" + ); + } + + #[test] + fn does_not_inject_ext_for_unlisted_structs() { + let input = "\ +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Unlisted { + #[prost(string, optional, tag = \"1\")] + pub name: ::core::option::Option<::prost::alloc::string::String>, +} +"; + let output = postprocess(input); + assert!( + !output.contains("pub ext:"), + "should not inject ext for unlisted struct: {output}" + ); + } + + /// Verify enum derives strip prost::Enumeration and add serde. + const PROST_ENUM: &str = "\ +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum BannerAdType { + Unknown = 0, + XhtmlTextAd = 1, +} +"; + + #[test] + fn replaces_enum_derives_without_prost() { + let output = postprocess(PROST_ENUM); + assert!( + !output.contains("::prost::Enumeration"), + "should strip prost::Enumeration: {output}" + ); + assert!( + output.contains("::serde::Serialize, ::serde::Deserialize"), + "should add serde derives to enums: {output}" + ); + assert!( + output.contains("#[repr(i32)]"), + "should preserve #[repr(i32)]: {output}" + ); + } + + #[test] + fn bool_field_gets_bool_as_int_serde_module() { + let input = "\ +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Regs { + #[prost(bool, optional, tag = \"1\")] + pub coppa: ::core::option::Option, +} +"; + let output = postprocess(input); + assert!( + output.contains("crate::bool_as_int"), + "should use bool_as_int for Option fields: {output}" + ); + } + + #[test] + fn oneof_derives_strip_prost_oneof() { + let input = "\ +#[derive(Clone, PartialEq, ::prost::Oneof)] +pub enum DistributionChannel { + Site(Site), + App(App), +} +"; + let output = postprocess(input); + assert!( + !output.contains("::prost::Oneof"), + "should strip prost::Oneof: {output}" + ); + assert!( + output.contains("::serde::Serialize"), + "should add serde to oneof: {output}" + ); + } +} diff --git a/crates/openrtb/src/generated.rs b/crates/openrtb/src/generated.rs new file mode 100644 index 00000000..d546be6c --- /dev/null +++ b/crates/openrtb/src/generated.rs @@ -0,0 +1,1566 @@ +// @generated by openrtb-codegen from proto/openrtb.proto +// Do not edit manually — run `./crates/openrtb/generate.sh` to regenerate. +// source: proto/openrtb.proto + +// This file is @generated by prost-build. +/// OpenRTB 2.0: The top-level bid request object. +#[derive(Clone, Debug, Default, PartialEq, ::serde::Serialize, ::serde::Deserialize)] +#[serde(default)] +pub struct BidRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: ::core::option::Option<::std::string::String>, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub imp: ::std::vec::Vec, + /// Distribution channel — flattened from oneof for JSON compat. + #[serde(skip_serializing_if = "Option::is_none")] + pub site: ::core::option::Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub app: ::core::option::Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dooh: ::core::option::Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub device: ::core::option::Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub user: ::core::option::Option, + #[serde(with = "crate::bool_as_int", skip_serializing_if = "Option::is_none")] + pub test: ::core::option::Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub at: ::core::option::Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tmax: ::core::option::Option, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub wseat: ::std::vec::Vec<::std::string::String>, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub bseat: ::std::vec::Vec<::std::string::String>, + #[serde(with = "crate::bool_as_int", skip_serializing_if = "Option::is_none")] + pub allimps: ::core::option::Option, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub cur: ::std::vec::Vec<::std::string::String>, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub wlang: ::std::vec::Vec<::std::string::String>, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub wlangb: ::std::vec::Vec<::std::string::String>, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub acat: ::std::vec::Vec<::std::string::String>, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub bcat: ::std::vec::Vec<::std::string::String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub cattax: ::core::option::Option, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub badv: ::std::vec::Vec<::std::string::String>, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub bapp: ::std::vec::Vec<::std::string::String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub source: ::core::option::Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub regs: ::core::option::Option, + /// Placeholder for exchange-specific extensions to OpenRTB. + #[serde(skip_serializing_if = "Option::is_none")] + pub ext: ::core::option::Option<::serde_json::Map<::std::string::String, ::serde_json::Value>>, +} +/// Nested message and enum types in `BidRequest`. +pub mod bid_request { + /// Source object. + #[derive(Clone, Debug, Default, PartialEq, ::serde::Serialize, ::serde::Deserialize)] + #[serde(default)] + pub struct Source { + #[serde(with = "crate::bool_as_int", skip_serializing_if = "Option::is_none")] + pub fd: ::core::option::Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tid: ::core::option::Option<::std::string::String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub pchain: ::core::option::Option<::std::string::String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub schain: ::core::option::Option, + /// Placeholder for exchange-specific extensions to OpenRTB. + #[serde(skip_serializing_if = "Option::is_none")] + pub ext: + ::core::option::Option<::serde_json::Map<::std::string::String, ::serde_json::Value>>, + } + /// SupplyChain object. + #[derive(Clone, Debug, Default, PartialEq, ::serde::Serialize, ::serde::Deserialize)] + #[serde(default)] + pub struct SupplyChain { + #[serde(with = "crate::bool_as_int", skip_serializing_if = "Option::is_none")] + pub complete: ::core::option::Option, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub nodes: ::std::vec::Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub ver: ::core::option::Option<::std::string::String>, + /// Placeholder for exchange-specific extensions to OpenRTB. + #[serde(skip_serializing_if = "Option::is_none")] + pub ext: + ::core::option::Option<::serde_json::Map<::std::string::String, ::serde_json::Value>>, + } + /// SupplyChainNode object. + #[derive(Clone, Debug, Default, PartialEq, ::serde::Serialize, ::serde::Deserialize)] + #[serde(default)] + pub struct SupplyChainNode { + #[serde(skip_serializing_if = "Option::is_none")] + pub asi: ::core::option::Option<::std::string::String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub sid: ::core::option::Option<::std::string::String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub rid: ::core::option::Option<::std::string::String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: ::core::option::Option<::std::string::String>, + #[serde(skip_serializing_if = "Option::is_none")] + pub domain: ::core::option::Option<::std::string::String>, + #[serde(with = "crate::bool_as_int", skip_serializing_if = "Option::is_none")] + pub hp: ::core::option::Option, + /// Placeholder for exchange-specific extensions to OpenRTB. + #[serde(skip_serializing_if = "Option::is_none")] + pub ext: + ::core::option::Option<::serde_json::Map<::std::string::String, ::serde_json::Value>>, + } + /// Imp object. + #[derive(Clone, Debug, Default, PartialEq, ::serde::Serialize, ::serde::Deserialize)] + #[serde(default)] + pub struct Imp { + #[serde(skip_serializing_if = "Option::is_none")] + pub id: ::core::option::Option<::std::string::String>, + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub metric: ::std::vec::Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub banner: ::core::option::Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub video: ::core::option::Option