/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ use std::collections::HashSet; use std::hash::{Hash, Hasher}; use serde::{Deserialize, Serialize}; use url::Url; use viaduct::{Headers, Request}; use super::error::BuildRequestError; #[derive(Debug, PartialEq, Serialize)] pub struct AdRequest { pub context_id: String, #[serde(skip)] pub headers: Headers, #[serde(skip)] pub ohttp: bool, pub placements: Vec, /// Skipped to exclude from the request body #[serde(skip)] pub url: Url, } /// Hash implementation intentionally excludes `context_id` as it rotates /// on client re-instantiation and should not invalidate cached responses. /// `headers` are also excluded as they are request metadata, not cache keys. impl Hash for AdRequest { fn hash(&self, state: &mut H) { self.url.as_str().hash(state); self.placements.hash(state); self.ohttp.hash(state); } } impl From for Request { fn from(ad_request: AdRequest) -> Self { let url = ad_request.url.clone(); let mut request = Request::post(url).json(&ad_request); request.headers.extend(ad_request.headers); request } } impl AdRequest { pub fn try_new( context_id: String, placements: Vec, url: Url, ohttp: bool, ) -> Result { if placements.is_empty() { return Err(BuildRequestError::EmptyConfig); }; let mut request = AdRequest { context_id, headers: Headers::new(), ohttp, placements: vec![], url, }; let mut used_placement_ids: HashSet = HashSet::new(); for ad_placement_request in placements { if used_placement_ids.contains(&ad_placement_request.placement) { return Err(BuildRequestError::DuplicatePlacementId { placement_id: ad_placement_request.placement.clone(), }); } request.placements.push(AdPlacementRequest { content: ad_placement_request .content .map(|iab_content| AdContentCategory { categories: iab_content.categories, taxonomy: iab_content.taxonomy, }), count: ad_placement_request.count, placement: ad_placement_request.placement.clone(), }); used_placement_ids.insert(ad_placement_request.placement.clone()); } Ok(request) } } #[derive(Debug, Hash, PartialEq, Serialize)] pub struct AdPlacementRequest { pub content: Option, pub count: u32, pub placement: String, } #[derive(Debug, Deserialize, Hash, PartialEq, Serialize)] pub struct AdContentCategory { pub categories: Vec, pub taxonomy: IABContentTaxonomy, } #[derive(Debug, Deserialize, Hash, PartialEq, Serialize)] pub enum IABContentTaxonomy { #[serde(rename = "IAB-1.0")] IAB1_0, #[serde(rename = "IAB-2.0")] IAB2_0, #[serde(rename = "IAB-2.1")] IAB2_1, #[serde(rename = "IAB-2.2")] IAB2_2, #[serde(rename = "IAB-3.0")] IAB3_0, } #[cfg(test)] mod tests { use crate::test_utils::TEST_CONTEXT_ID; use super::*; use serde_json::{json, to_value}; #[test] fn test_ad_placement_request_with_content_serialize() { let request = AdPlacementRequest { content: Some(AdContentCategory { categories: vec!["Technology".into(), "Programming".into()], taxonomy: IABContentTaxonomy::IAB2_1, }), count: 1, placement: "example_placement".into(), }; let serialized = to_value(&request).unwrap(); let expected_json = json!({ "placement": "example_placement", "count": 1, "content": { "taxonomy": "IAB-2.1", "categories": ["Technology", "Programming"] } }); assert_eq!(serialized, expected_json); } #[test] fn test_iab_content_taxonomy_serialize() { use serde_json::to_string; // We expect that enums map to strings like "IAB-2.2" let s = to_string(&IABContentTaxonomy::IAB1_0).unwrap(); assert_eq!(s, "\"IAB-1.0\""); let s = to_string(&IABContentTaxonomy::IAB2_0).unwrap(); assert_eq!(s, "\"IAB-2.0\""); let s = to_string(&IABContentTaxonomy::IAB2_1).unwrap(); assert_eq!(s, "\"IAB-2.1\""); let s = to_string(&IABContentTaxonomy::IAB2_2).unwrap(); assert_eq!(s, "\"IAB-2.2\""); let s = to_string(&IABContentTaxonomy::IAB3_0).unwrap(); assert_eq!(s, "\"IAB-3.0\""); } #[test] fn test_build_ad_request_happy() { let url: Url = "https://example.com/ads".parse().unwrap(); let request = AdRequest::try_new( TEST_CONTEXT_ID.to_string(), vec![ AdPlacementRequest { content: Some(AdContentCategory { categories: vec!["entertainment".to_string()], taxonomy: IABContentTaxonomy::IAB2_1, }), count: 1, placement: "example_placement_1".to_string(), }, AdPlacementRequest { content: Some(AdContentCategory { categories: vec![], taxonomy: IABContentTaxonomy::IAB2_1, }), count: 2, placement: "example_placement_2".to_string(), }, ], url.clone(), false, ) .unwrap(); let expected_request = AdRequest { context_id: TEST_CONTEXT_ID.to_string(), headers: Headers::new(), ohttp: false, placements: vec![ AdPlacementRequest { content: Some(AdContentCategory { categories: vec!["entertainment".to_string()], taxonomy: IABContentTaxonomy::IAB2_1, }), count: 1, placement: "example_placement_1".to_string(), }, AdPlacementRequest { content: Some(AdContentCategory { categories: vec![], taxonomy: IABContentTaxonomy::IAB2_1, }), count: 2, placement: "example_placement_2".to_string(), }, ], url, }; assert_eq!(request, expected_request); } #[test] fn test_build_ad_request_fails_on_duplicate_placement_id() { let url: Url = "https://example.com/ads".parse().unwrap(); let request = AdRequest::try_new( TEST_CONTEXT_ID.to_string(), vec![ AdPlacementRequest { content: Some(AdContentCategory { categories: vec!["entertainment".to_string()], taxonomy: IABContentTaxonomy::IAB2_1, }), count: 1, placement: "example_placement_1".to_string(), }, AdPlacementRequest { content: Some(AdContentCategory { categories: vec![], taxonomy: IABContentTaxonomy::IAB3_0, }), count: 1, placement: "example_placement_1".to_string(), }, ], url, false, ); assert!(request.is_err()); } #[test] fn test_build_ad_request_fails_on_empty_request() { let url: Url = "https://example.com/ads".parse().unwrap(); let request = AdRequest::try_new(TEST_CONTEXT_ID.to_string(), vec![], url, false); assert!(request.is_err()); } #[test] fn test_context_id_ignored_in_hash() { use crate::http_cache::RequestHash; let url: Url = "https://example.com/ads".parse().unwrap(); let make_placements = || { vec![AdPlacementRequest { content: None, count: 1, placement: "tile_1".to_string(), }] }; let context_id_a = "aaaa-bbbb-cccc".to_string(); let context_id_b = "dddd-eeee-ffff".to_string(); let req1 = AdRequest::try_new(context_id_a, make_placements(), url.clone(), false).unwrap(); let req2 = AdRequest::try_new(context_id_b, make_placements(), url, false).unwrap(); assert_eq!(RequestHash::new(&req1), RequestHash::new(&req2)); } #[test] fn test_different_placements_produce_different_hash() { use crate::http_cache::RequestHash; let url: Url = "https://example.com/ads".parse().unwrap(); let req1 = AdRequest::try_new( "same-id".to_string(), vec![AdPlacementRequest { content: None, count: 1, placement: "tile_1".to_string(), }], url.clone(), false, ) .unwrap(); let req2 = AdRequest::try_new( "same-id".to_string(), vec![AdPlacementRequest { content: None, count: 3, placement: "tile_2".to_string(), }], url, false, ) .unwrap(); assert_ne!(RequestHash::new(&req1), RequestHash::new(&req2)); } #[test] fn test_ohttp_flag_produces_different_hash() { use crate::http_cache::RequestHash; let url: Url = "https://example.com/ads".parse().unwrap(); let make_placements = || { vec![AdPlacementRequest { content: None, count: 1, placement: "tile_1".to_string(), }] }; let req_direct = AdRequest::try_new("same-id".to_string(), make_placements(), url.clone(), false) .unwrap(); let req_ohttp = AdRequest::try_new("same-id".to_string(), make_placements(), url, true).unwrap(); assert_ne!(RequestHash::new(&req_direct), RequestHash::new(&req_ohttp)); } }