diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 7a0e0af..4021196 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "agentic-rtb-framework-service" @@ -11,6 +11,7 @@ dependencies = [ "futures-util", "hyper 0.14.32", "prost", + "prost-reflect", "rustc-hash", "serde_json", "tokio", @@ -18,6 +19,7 @@ dependencies = [ "tonic", "tonic-prost", "tonic-prost-build", + "tonic-reflection", ] [[package]] @@ -74,6 +76,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "axum" version = "0.8.8" @@ -567,12 +575,30 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -685,6 +711,19 @@ dependencies = [ "syn", ] +[[package]] +name = "prost-reflect" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590aa145fee8f7a26b5a6055365e7c5e89a5c1caae9869de76ec0ee73181a2f9" +dependencies = [ + "base64", + "prost", + "prost-types", + "serde", + "serde-value", +] + [[package]] name = "prost-types" version = "0.14.3" @@ -792,6 +831,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -939,9 +988,9 @@ dependencies = [ [[package]] name = "tonic" -version = "0.14.3" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a286e33f82f8a1ee2df63f4fa35c0becf4a85a0cb03091a15fd7bf0b402dc94a" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" dependencies = [ "async-trait", "axum", @@ -980,9 +1029,9 @@ dependencies = [ [[package]] name = "tonic-prost" -version = "0.14.3" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6c55a2d6a14174563de34409c9f92ff981d006f56da9c6ecd40d9d4a31500b0" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" dependencies = [ "bytes", "prost", @@ -1005,6 +1054,20 @@ dependencies = [ "tonic-build", ] +[[package]] +name = "tonic-reflection" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acccd136a4bf19810a1fde9c74edc6129b42a66b44d0c1c8aaa67aeb49a146a7" +dependencies = [ + "prost", + "prost-types", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", +] + [[package]] name = "tower" version = "0.5.3" diff --git a/rust/build.rs b/rust/build.rs index c181492..426bd24 100644 --- a/rust/build.rs +++ b/rust/build.rs @@ -1,8 +1,13 @@ +use std::env; +use std::path::PathBuf; + fn main() -> Result<(), Box> { + let descriptor_path = PathBuf::from(env::var("OUT_DIR")?).join("artf_descriptor.bin"); tonic_prost_build::configure() .build_server(true) .build_client(false) .out_dir("./src") + .file_descriptor_set_path(descriptor_path) .compile_protos(&["./proto/agenticrtbframeworkservices.proto"], &["proto"])?; Ok(()) -} \ No newline at end of file +} diff --git a/rust/cargo.toml b/rust/cargo.toml index 31bd579..7c412ef 100644 --- a/rust/cargo.toml +++ b/rust/cargo.toml @@ -21,6 +21,7 @@ tokio-stream = "0.1.17" async-stream = "0.3.6" futures = "0.3.31" futures-util = "0.3.31" +tonic-reflection = "0.14.6" [build-dependencies] tonic-prost-build = "0.14.2" @@ -30,3 +31,6 @@ strip = true # Automatically strip symbols from the binary. opt-level = "z" # Optimize for size. lto = true # Enable link-time optimization. codegen-units = 1 # Use a single codegen unit to reduce binary size. + +[dev-dependencies] +prost-reflect = { version = "0.16.4", features = ["serde"] } diff --git a/rust/src/bidder/evaluator.rs b/rust/src/bidder/evaluator.rs index 0eaeb5b..85b0975 100644 --- a/rust/src/bidder/evaluator.rs +++ b/rust/src/bidder/evaluator.rs @@ -13,7 +13,16 @@ pub async fn evaluate(req: RtbRequest) -> RtbResponse { // and determine the appropriate mutations based on the request data. let metadata = build_metadata(); - match req.id.as_str() { + // Route on the auction id carried by the enclosed bid request; the + // top-level `req.id` is the extension point envelope id assigned by the + // exchange and is only echoed back in the response. + let auction_id = req + .bid_request + .as_ref() + .and_then(|bid_request| bid_request.id.clone()) + .unwrap_or_default(); + + match auction_id.as_str() { "auction-123" => RtbResponse { id: req.id, mutations: vec![ @@ -49,7 +58,7 @@ pub async fn evaluate(req: RtbRequest) -> RtbResponse { ], metadata: Some(metadata), }, - "app-123" => RtbResponse { + "auction-native-123" => RtbResponse { id: req.id, mutations: vec![ activate_segments(&["demo-18-24"]), @@ -64,3 +73,99 @@ pub async fn evaluate(req: RtbRequest) -> RtbResponse { }, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::mutation::types::{PATH_IMP_1, PATH_SEATBID_BID_ABC, PATH_USER_SEGMENT}; + use crate::proto::com::iabtechlab::bidstream::mutation::v1::{ + mutation::Value, Intent, Operation, + }; + use crate::proto::com::iabtechlab::openrtb::v2::BidRequest; + + fn request(envelope_id: &str, auction_id: &str) -> RtbRequest { + RtbRequest { + id: envelope_id.to_string(), + bid_request: Some(BidRequest { + id: Some(auction_id.to_string()), + ..Default::default() + }), + ..Default::default() + } + } + + #[tokio::test] + async fn known_auctions_produce_expected_mutation_counts() { + let cases = [ + ("auction-123", 2), + ("auction-456", 2), + ("auction-789", 3), + ("auction-multi-123", 4), + ("auction-native-123", 2), + ]; + for (auction_id, expected) in cases { + let response = evaluate(request("req-1", auction_id)).await; + assert_eq!(response.mutations.len(), expected, "auction {auction_id}"); + assert!(response.metadata.is_some(), "auction {auction_id}"); + } + } + + #[tokio::test] + async fn response_echoes_envelope_id_not_auction_id() { + let response = evaluate(request("envelope-42", "auction-123")).await; + assert_eq!(response.id, "envelope-42"); + } + + #[tokio::test] + async fn segment_and_deal_mutations_carry_expected_fields() { + let response = evaluate(request("req-1", "auction-123")).await; + + let segments = &response.mutations[0]; + assert_eq!(segments.intent, Intent::ActivateSegments as i32); + assert_eq!(segments.op, Operation::Add as i32); + assert_eq!(segments.path, PATH_USER_SEGMENT); + match segments.value.as_ref() { + Some(Value::Ids(ids)) => { + assert_eq!(ids.id, vec!["seg-sports", "demo-25-35", "gender-male"]) + } + other => panic!("expected IDs payload, got {other:?}"), + } + + let deals = &response.mutations[1]; + assert_eq!(deals.intent, Intent::ActivateDeals as i32); + assert_eq!(deals.op, Operation::Add as i32); + assert_eq!(deals.path, PATH_IMP_1); + } + + #[tokio::test] + async fn bid_shade_mutation_replaces_price_at_bid_path() { + let response = evaluate(request("req-1", "auction-789")).await; + + let shade = response.mutations.last().expect("bid shade mutation"); + assert_eq!(shade.intent, Intent::BidShade as i32); + assert_eq!(shade.op, Operation::Replace as i32); + assert_eq!(shade.path, PATH_SEATBID_BID_ABC); + match shade.value.as_ref() { + Some(Value::AdjustBid(adjust)) => assert_eq!(adjust.price, 4.675), + other => panic!("expected AdjustBid payload, got {other:?}"), + } + } + + #[tokio::test] + async fn unknown_auction_returns_no_mutations() { + let response = evaluate(request("req-1", "auction-unknown")).await; + assert!(response.mutations.is_empty()); + assert!(response.metadata.is_some()); + } + + #[tokio::test] + async fn missing_bid_request_returns_no_mutations() { + let request = RtbRequest { + id: "req-1".to_string(), + ..Default::default() + }; + let response = evaluate(request).await; + assert_eq!(response.id, "req-1"); + assert!(response.mutations.is_empty()); + } +} diff --git a/rust/src/main.rs b/rust/src/main.rs index c182fe9..fd81025 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -10,12 +10,18 @@ mod bidder; mod config; mod mutation; mod proto; +#[cfg(test)] +mod sample_tests; mod service; use crate::config::{Config, API_VERSION, MODEL_VERSION}; use crate::proto::com::iabtechlab::bidstream::mutation::services::v1::rtb_extension_point_server::RtbExtensionPointServer; use crate::service::{handle_rest, RtbExtensionPointService}; +/// File descriptor set emitted by `build.rs`, served via gRPC reflection so +/// clients like grpcurl can discover the service without local proto files. +const FILE_DESCRIPTOR_SET: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/artf_descriptor.bin")); + #[tokio::main] async fn main() -> Result<(), Box> { let config = Config::from_env(); @@ -41,11 +47,16 @@ async fn main() -> Result<(), Box> { address, config.http_port ); + let reflection_service = tonic_reflection::server::Builder::configure() + .register_encoded_file_descriptor_set(FILE_DESCRIPTOR_SET) + .build_v1()?; + let max_server_connection = config.max_server_connection; let grpc_server = tokio::spawn(async move { Server::builder() .concurrency_limit_per_connection(max_server_connection as usize) .add_service(agentic_rtb_framework_service) + .add_service(reflection_service) .serve(grpc_addr) .await diff --git a/rust/src/mutation/builder.rs b/rust/src/mutation/builder.rs index 1763cf2..4237468 100644 --- a/rust/src/mutation/builder.rs +++ b/rust/src/mutation/builder.rs @@ -49,3 +49,51 @@ fn ids_payload(ids: &[&str]) -> IDsPayload { id: ids.iter().map(|id| id.to_string()).collect(), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn metadata_uses_configured_versions() { + let metadata = build_metadata(); + assert_eq!(metadata.api_version, API_VERSION); + assert_eq!(metadata.model_version, MODEL_VERSION); + } + + #[test] + fn activate_segments_adds_ids_at_user_segment_path() { + let mutation = activate_segments(&["seg-1", "seg-2"]); + assert_eq!(mutation.intent, Intent::ActivateSegments as i32); + assert_eq!(mutation.op, Operation::Add as i32); + assert_eq!(mutation.path, PATH_USER_SEGMENT); + match mutation.value { + Some(Value::Ids(ids)) => assert_eq!(ids.id, vec!["seg-1", "seg-2"]), + other => panic!("expected IDs payload, got {other:?}"), + } + } + + #[test] + fn activate_deals_adds_ids_at_given_path() { + let mutation = activate_deals("/imp/imp-1", &["deal-1"]); + assert_eq!(mutation.intent, Intent::ActivateDeals as i32); + assert_eq!(mutation.op, Operation::Add as i32); + assert_eq!(mutation.path, "/imp/imp-1"); + match mutation.value { + Some(Value::Ids(ids)) => assert_eq!(ids.id, vec!["deal-1"]), + other => panic!("expected IDs payload, got {other:?}"), + } + } + + #[test] + fn bid_shade_replaces_price_at_given_path() { + let mutation = bid_shade("/seatbid/dsp-001/bid/bid-abc", 4.675); + assert_eq!(mutation.intent, Intent::BidShade as i32); + assert_eq!(mutation.op, Operation::Replace as i32); + assert_eq!(mutation.path, "/seatbid/dsp-001/bid/bid-abc"); + match mutation.value { + Some(Value::AdjustBid(adjust)) => assert_eq!(adjust.price, 4.675), + other => panic!("expected AdjustBid payload, got {other:?}"), + } + } +} diff --git a/rust/src/sample_tests.rs b/rust/src/sample_tests.rs new file mode 100644 index 0000000..f28b746 --- /dev/null +++ b/rust/src/sample_tests.rs @@ -0,0 +1,94 @@ +//! Tests that exercise the bundled sample payloads in `samples/`. +//! +//! Each sample must decode as a schema-valid `RtbRequest` (proto3 JSON +//! mapping, unknown fields rejected) and drive the demo evaluator to the +//! mutations its scenario describes. This keeps the shared sample payloads +//! and the reference implementation from drifting apart. + +use prost_reflect::{DescriptorPool, DynamicMessage}; + +use crate::bidder::evaluate; +use crate::proto::com::iabtechlab::bidstream::mutation::v1::{mutation::Value, Intent, RtbRequest}; + +const DESCRIPTOR_BYTES: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/artf_descriptor.bin")); + +const SAMPLES: [&str; 5] = [ + "banner-basic.json", + "video-deals.json", + "bid-shading.json", + "multi-impression.json", + "native-ad.json", +]; + +fn load_sample(name: &str) -> RtbRequest { + let path = format!("{}/../samples/{}", env!("CARGO_MANIFEST_DIR"), name); + let json = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("failed to read {path}: {error}")); + + let pool = DescriptorPool::decode(DESCRIPTOR_BYTES).expect("descriptor set"); + let descriptor = pool + .get_message_by_name("com.iabtechlab.bidstream.mutation.v1.RTBRequest") + .expect("RTBRequest descriptor"); + + let mut deserializer = serde_json::Deserializer::from_str(&json); + let message = DynamicMessage::deserialize(descriptor, &mut deserializer) + .unwrap_or_else(|error| panic!("{name} is not a valid RtbRequest payload: {error}")); + deserializer.end().expect("trailing JSON content"); + + message.transcode_to().expect("transcode to RtbRequest") +} + +#[test] +fn all_samples_decode_as_rtb_requests() { + for name in SAMPLES { + let request = load_sample(name); + assert!(!request.id.is_empty(), "{name} carries an envelope id"); + assert!( + request.bid_request.is_some(), + "{name} carries a bid_request" + ); + assert!( + !request.applicable_intents.is_empty(), + "{name} declares applicable intents" + ); + } +} + +#[tokio::test] +async fn samples_drive_demo_evaluator_mutations() { + let cases = [ + ("banner-basic.json", 2), + ("video-deals.json", 2), + ("bid-shading.json", 3), + ("multi-impression.json", 4), + ("native-ad.json", 2), + ]; + for (name, expected_mutations) in cases { + let request = load_sample(name); + let envelope_id = request.id.clone(); + + let response = evaluate(request).await; + + assert_eq!(response.id, envelope_id, "{name} echoes the envelope id"); + assert_eq!( + response.mutations.len(), + expected_mutations, + "{name} produces the demo mutations for its scenario" + ); + assert!(response.metadata.is_some(), "{name} carries metadata"); + } +} + +#[tokio::test] +async fn bid_shading_sample_produces_bid_shade_mutation() { + let response = evaluate(load_sample("bid-shading.json")).await; + let shade = response + .mutations + .iter() + .find(|mutation| mutation.intent == Intent::BidShade as i32) + .expect("bid shade mutation"); + match shade.value.as_ref() { + Some(Value::AdjustBid(adjust)) => assert!(adjust.price > 0.0), + other => panic!("expected AdjustBid payload, got {other:?}"), + } +} diff --git a/samples/multi-impression.json b/samples/multi-impression.json index e6681a0..e9157b5 100644 --- a/samples/multi-impression.json +++ b/samples/multi-impression.json @@ -111,7 +111,7 @@ "devicetype": 2, "ip": "203.0.113.50", "language": "en", - "js": 1, + "js": true, "geo": { "country": "USA", "region": "TX", @@ -119,12 +119,12 @@ } }, "regs": { - "coppa": 0, - "gdpr": 0, + "coppa": false, + "gdpr": false, "us_privacy": "1YNN" }, "source": { - "fd": 1, + "fd": true, "tid": "transaction-abc-123" }, "at": 1, diff --git a/samples/native-ad.json b/samples/native-ad.json index f06420d..9fa4040 100644 --- a/samples/native-ad.json +++ b/samples/native-ad.json @@ -60,7 +60,7 @@ "w": 390, "h": 844, "pxratio": 3.0, - "js": 1, + "js": true, "language": "en", "connectiontype": 6, "ifa": "8A2E0A2B-3C4D-5E6F-7A8B-9C0D1E2F3A4B", @@ -75,7 +75,7 @@ } }, "regs": { - "coppa": 0 + "coppa": false } } }