diff --git a/Cargo.lock b/Cargo.lock index 476fe08a..d207e8f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2405,6 +2405,7 @@ dependencies = [ "tokio-test", "toml", "trusted-server-js", + "trusted-server-openrtb", "url", "urlencoding", "uuid", @@ -2438,6 +2439,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 d2113599..914ab2c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/common", "crates/fastly", "crates/js", + "crates/openrtb", ] # 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 17a0e04b..87cad591 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -42,6 +42,7 @@ serde_json = { workspace = true } sha2 = { workspace = true } tokio = { 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 71804e26..4db8bf7c 100644 --- a/crates/common/src/auction/formats.rs +++ b/crates/common/src/auction/formats.rs @@ -16,7 +16,10 @@ use crate::auction::types::OrchestratorExt; use crate::creative; use crate::error::TrustedServerError; use crate::geo::GeoInfo; -use crate::openrtb::{OpenRtbBid, OpenRtbResponse, ResponseExt, SeatBid}; +use crate::openrtb::{ + build_openrtb_bid, build_openrtb_response, build_seat_bid, maybe_object_from_serializable, + OpenRtbBidFields, ResponseExt, +}; use crate::settings::Settings; use crate::synthetic::{generate_synthetic_id, get_or_generate_synthetic_id}; @@ -206,21 +209,18 @@ pub fn convert_to_openrtb_response( String::new() }; - let openrtb_bid = OpenRtbBid { + let openrtb_bid = build_openrtb_bid(OpenRtbBidFields { id: format!("{}-{}", bid.bidder, slot_id), impid: slot_id.to_string(), price, adm: Some(creative_html), crid: Some(format!("{}-creative", bid.bidder)), - w: Some(bid.width), - h: Some(bid.height), + width: Some(bid.width), + height: Some(bid.height), adomain: Some(bid.adomain.clone().unwrap_or_default()), - }; - - seatbids.push(SeatBid { - seat: Some(bid.bidder.clone()), - bid: vec![openrtb_bid], }); + + seatbids.push(build_seat_bid(Some(bid.bidder.clone()), vec![openrtb_bid])); } // Determine strategy name for response metadata @@ -230,10 +230,10 @@ pub fn convert_to_openrtb_response( "parallel_only" }; - let response_body = OpenRtbResponse { - id: auction_request.id.to_string(), - seatbid: seatbids, - ext: Some(ResponseExt { + let response_body = build_openrtb_response( + auction_request.id.to_string(), + seatbids, + maybe_object_from_serializable(&ResponseExt { orchestrator: OrchestratorExt { strategy: strategy_name.to_string(), providers: result.provider_responses.len(), @@ -241,7 +241,7 @@ pub fn convert_to_openrtb_response( time_ms: result.total_time_ms, }, }), - }; + ); let body_bytes = serde_json::to_vec(&response_body).change_context(TrustedServerError::Auction { diff --git a/crates/common/src/integrations/prebid.rs b/crates/common/src/integrations/prebid.rs index 41e27a8a..1afbfed1 100644 --- a/crates/common/src/integrations/prebid.rs +++ b/crates/common/src/integrations/prebid.rs @@ -22,8 +22,9 @@ use crate::integrations::{ IntegrationEndpoint, IntegrationProxy, IntegrationRegistration, }; use crate::openrtb::{ - Banner, Device, Format, Geo, Imp, ImpExt, OpenRtbRequest, PrebidExt, PrebidImpExt, Regs, - RegsExt, RequestExt, Site, TrustedServerExt, User, UserExt, + build_banner, build_device, build_format, build_geo, build_imp, build_openrtb_request, + build_regs, build_site, build_user, maybe_object_from_serializable, ImpExt, OpenRtbRequest, + PrebidExt, PrebidImpExt, RegsExt, RequestExt, TrustedServerExt, UserExt, }; use crate::request_signing::RequestSigner; use crate::settings::{IntegrationConfig, Settings}; @@ -396,18 +397,15 @@ impl PrebidAuctionProvider { context: &AuctionContext<'_>, signer: Option<(&RequestSigner, String)>, ) -> OpenRtbRequest { - let imps: Vec = request + let imps = request .slots .iter() .map(|slot| { - let formats: Vec = slot + let formats = slot .formats .iter() .filter(|f| f.media_type == MediaType::Banner) - .map(|f| Format { - w: f.width, - h: f.height, - }) + .map(|f| build_format(f.width, f.height)) .collect(); // Use bidder params from the slot (passed through from the request) @@ -424,13 +422,13 @@ impl PrebidAuctionProvider { } } - Imp { - id: slot.id.clone(), - banner: Some(Banner { format: formats }), - ext: Some(ImpExt { + build_imp( + slot.id.clone(), + Some(build_banner(formats)), + maybe_object_from_serializable(&ImpExt { prebid: PrebidImpExt { bidder }, }), - } + ) }) .collect(); @@ -444,31 +442,32 @@ impl PrebidAuctionProvider { }); // Build user object - let user = Some(User { - id: Some(request.user.id.clone()), - ext: Some(UserExt { + let user = Some(build_user( + Some(request.user.id.clone()), + maybe_object_from_serializable(&UserExt { synthetic_fresh: Some(request.user.fresh_id.clone()), }), - }); + )); // Build device object with user-agent and geo if available - let device = request.device.as_ref().map(|d| Device { - ua: d.user_agent.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(), - }), + let device = request.device.as_ref().map(|d| { + build_device( + d.user_agent.clone(), + d.geo.as_ref().map(|geo| { + build_geo( + Some(geo.country.clone()), + Some(geo.city.clone()), + geo.region.clone(), + ) + }), + ) }); // Build regs object if Sec-GPC header is present let regs = if context.request.get_header("Sec-GPC").is_some() { - Some(Regs { - ext: Some(RegsExt { - us_privacy: Some("1YYN".to_string()), - }), - }) + Some(build_regs(maybe_object_from_serializable(&RegsExt { + us_privacy: Some("1YYN".to_string()), + }))) } else { None }; @@ -479,7 +478,7 @@ impl PrebidAuctionProvider { .map(|(s, sig)| (Some(sig), Some(s.kid.clone()))) .unwrap_or((None, None)); - let ext = Some(RequestExt { + let ext = maybe_object_from_serializable(&RequestExt { prebid: if self.config.debug { Some(PrebidExt { debug: Some(true) }) } else { @@ -493,18 +492,15 @@ impl PrebidAuctionProvider { }), }); - OpenRtbRequest { - id: request.id.clone(), - imp: imps, - site: Some(Site { - domain: Some(request.publisher.domain.clone()), - page: page_url, - }), + build_openrtb_request( + request.id.clone(), + imps, + Some(build_site(Some(request.publisher.domain.clone()), page_url)), user, device, regs, ext, - } + ) } /// Parse `OpenRTB` response into auction response. diff --git a/crates/common/src/openrtb.rs b/crates/common/src/openrtb.rs index 3b405209..be4d9d79 100644 --- a/crates/common/src/openrtb.rs +++ b/crates/common/src/openrtb.rs @@ -1,103 +1,334 @@ use serde::Serialize; -use serde_json::Value; +use serde_json::{Map, 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 ext: Option, +pub type Object = trusted_server_openrtb::Object; +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, Device, Format, Geo, Imp, Regs, SeatBid, Site, User}; + +fn clamp_u32_to_i32(value: u32) -> i32 { + value.min(i32::MAX as u32) as i32 } -#[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, +pub fn object_from_serializable(value: &T) -> Object { + match serde_json::to_value(value) { + Ok(Value::Object(map)) => map, + Ok(_) | Err(_) => Map::new(), + } } -#[derive(Debug, Serialize)] -pub struct Banner { - pub format: Vec, +pub fn maybe_object_from_serializable(value: &T) -> Option { + let map = object_from_serializable(value); + if map.is_empty() { + None + } else { + Some(map) + } } -#[derive(Debug, Serialize)] -pub struct Format { - pub w: u32, - pub h: u32, +#[must_use] +pub fn build_format(width: u32, height: u32) -> Format { + Format { + w: Some(clamp_u32_to_i32(width)), + h: Some(clamp_u32_to_i32(height)), + wratio: None, + hratio: None, + wmin: None, + ext: None, + } } -#[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, +#[must_use] +pub fn build_banner(formats: Vec) -> Banner { + Banner { + format: Some(formats), + w: None, + h: None, + btype: None, + battr: None, + pos: None, + mimes: None, + topframe: None, + expdir: None, + api: None, + id: None, + vcm: None, + ext: None, + } } -#[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, +#[must_use] +pub fn build_imp(id: String, banner: Option, ext: Option) -> Imp { + Imp { + id, + metric: None, + banner, + video: None, + audio: None, + native: None, + pmp: None, + displaymanager: None, + displaymanagerver: None, + instl: None, + tagid: None, + bidfloor: None, + bidfloorcur: None, + clickbrowser: None, + secure: None, + iframebuster: None, + rwdd: None, + ssai: None, + exp: None, + qty: None, + dt: None, + refresh: None, + ext, + } } -#[derive(Debug, Serialize, Default)] -pub struct UserExt { - #[serde(skip_serializing_if = "Option::is_none")] - pub synthetic_fresh: Option, +#[must_use] +pub fn build_site(domain: Option, page: Option) -> Site { + Site { + id: None, + name: None, + domain, + cattax: None, + cat: None, + sectioncat: None, + pagecat: None, + page, + r#ref: None, + search: None, + mobile: None, + privacypolicy: None, + publisher: None, + content: None, + keywords: None, + kwarray: None, + ext: None, + } } -#[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 geo: Option, +#[must_use] +pub fn build_user(id: Option, ext: Option) -> User { + User { + id, + buyeruid: None, + yob: None, + gender: None, + keywords: None, + kwarray: None, + customdata: None, + geo: None, + data: None, + consent: None, + eids: None, + ext, + } } -#[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, +#[must_use] +pub fn build_geo(country: Option, city: Option, region: Option) -> Geo { + Geo { + lat: None, + lon: None, + r#type: Some(2), + accuracy: None, + lastfix: None, + ipservice: None, + country, + region, + metro: None, + city, + zip: None, + utcoffset: None, + ext: None, + } +} + +#[must_use] +pub fn build_device(ua: Option, geo: Option) -> Device { + Device { + geo, + dnt: None, + lmt: None, + ua, + sua: None, + ip: None, + ipv6: None, + devicetype: None, + make: None, + model: None, + os: None, + osv: None, + hwv: None, + h: None, + w: None, + ppi: None, + pxratio: None, + js: None, + geofetch: None, + flashver: None, + language: None, + langb: None, + carrier: None, + mccmnc: None, + connectiontype: None, + ifa: None, + didsha1: None, + didmd5: None, + dpidsha1: None, + dpidmd5: None, + macsha1: None, + macmd5: None, + ext: None, + } +} + +#[must_use] +pub fn build_regs(ext: Option) -> Regs { + Regs { + coppa: None, + gdpr: None, + us_privacy: None, + gpp: None, + gpp_sid: None, + ext, + } +} + +#[must_use] +pub fn build_openrtb_request( + id: String, + imp: Vec, + site: Option, + user: Option, + device: Option, + regs: Option, + ext: Option, +) -> OpenRtbRequest { + OpenRtbRequest { + id, + imp, + site, + app: None, + dooh: None, + device, + user, + test: None, + at: None, + tmax: None, + wseat: None, + bseat: None, + allimps: None, + cur: None, + wlang: None, + wlangb: None, + acat: None, + bcat: None, + cattax: None, + badv: None, + bapp: None, + source: None, + regs, + ext, + } +} + +pub struct OpenRtbBidFields { + pub id: String, + pub impid: String, + pub price: f64, + pub adm: Option, + pub crid: Option, + pub width: Option, + pub height: Option, + pub adomain: Option>, +} + +#[must_use] +pub fn build_openrtb_bid(fields: OpenRtbBidFields) -> OpenRtbBid { + OpenRtbBid { + id: fields.id, + impid: fields.impid, + price: fields.price, + nurl: None, + burl: None, + lurl: None, + adm: fields.adm, + adid: None, + adomain: fields.adomain, + bundle: None, + iurl: None, + cid: None, + crid: fields.crid, + tactic: None, + cattax: None, + cat: None, + attr: None, + apis: None, + api: None, + protocol: None, + qagmediarating: None, + language: None, + langb: None, + dealid: None, + w: fields.width.map(clamp_u32_to_i32), + h: fields.height.map(clamp_u32_to_i32), + wratio: None, + hratio: None, + exp: None, + dur: None, + mtype: None, + slotinpod: None, + ext: None, + } +} + +#[must_use] +pub fn build_seat_bid(seat: Option, bid: Vec) -> SeatBid { + SeatBid { + bid, + seat, + group: None, + ext: None, + } } -#[derive(Debug, Serialize, Default)] -pub struct Regs { +#[must_use] +pub fn build_openrtb_response( + id: String, + seatbid: Vec, + ext: Option, +) -> OpenRtbResponse { + OpenRtbResponse { + id, + seatbid: Some(seatbid), + bidid: None, + cur: None, + customdata: None, + nbr: None, + ext, + } +} + +#[derive(Debug, Serialize)] +pub struct UserExt { #[serde(skip_serializing_if = "Option::is_none")] - pub ext: Option, + pub synthetic_fresh: Option, } -#[derive(Debug, Serialize, Default)] +#[derive(Debug, Serialize)] pub struct RegsExt { #[serde(skip_serializing_if = "Option::is_none")] pub us_privacy: Option, } -#[derive(Debug, Serialize, Default)] +#[derive(Debug, Serialize)] pub struct RequestExt { #[serde(skip_serializing_if = "Option::is_none")] pub prebid: Option, @@ -105,13 +336,13 @@ pub struct RequestExt { pub trusted_server: Option, } -#[derive(Debug, Serialize, Default)] +#[derive(Debug, Serialize)] pub struct PrebidExt { #[serde(skip_serializing_if = "Option::is_none")] pub debug: Option, } -#[derive(Debug, Serialize, Default)] +#[derive(Debug, Serialize)] pub struct TrustedServerExt { #[serde(skip_serializing_if = "Option::is_none")] pub signature: Option, @@ -133,102 +364,7 @@ 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, } - -#[cfg(test)] -mod tests { - use super::{OpenRtbBid, OpenRtbResponse, ResponseExt, SeatBid}; - use crate::auction::types::OrchestratorExt; - - #[test] - fn openrtb_response_serializes_expected_fields() { - 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, - }, - }), - }; - - let serialized = serde_json::to_value(&response).expect("should serialize"); - let expected = serde_json::json!({ - "id": "auction-1", - "seatbid": [{ - "seat": "bidder-a", - "bid": [{ - "id": "bidder-a-slot-1", - "impid": "slot-1", - "price": 1.25, - "adm": "
Test Creative HTML
", - "crid": "bidder-a-creative", - "w": 300, - "h": 250, - "adomain": ["example.com"] - }] - }], - "ext": { - "orchestrator": { - "strategy": "parallel_only", - "providers": 2, - "total_bids": 3, - "time_ms": 12 - } - } - }); - - assert_eq!(serialized, expected); - } -} diff --git a/crates/openrtb/Cargo.toml b/crates/openrtb/Cargo.toml new file mode 100644 index 00000000..221cec15 --- /dev/null +++ b/crates/openrtb/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "trusted-server-openrtb" +version = "0.1.0" +authors = [] +edition = "2021" +publish = false +license = "Apache-2.0" + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/openrtb/src/lib.rs b/crates/openrtb/src/lib.rs new file mode 100644 index 00000000..e83604ae --- /dev/null +++ b/crates/openrtb/src/lib.rs @@ -0,0 +1,1028 @@ +//! `OpenRTB` 2.6 request and response data model. + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +pub type Object = Map; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BidRequest { + pub id: String, + pub imp: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub site: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub app: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dooh: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub device: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub user: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub test: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tmax: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub wseat: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub bseat: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub allimps: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cur: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub wlang: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub wlangb: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub acat: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub bcat: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub cattax: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub badv: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub bapp: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub regs: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ext: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Source { + #[serde(skip_serializing_if = "Option::is_none")] + pub fd: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pchain: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub schain: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ext: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Regs { + #[serde(skip_serializing_if = "Option::is_none")] + pub coppa: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub gdpr: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub us_privacy: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub gpp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub gpp_sid: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub ext: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Imp { + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub metric: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub banner: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub video: Option