Revision control
Copy as Markdown
/* 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
*/
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<AdPlacementRequest>,
/// 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<H: Hasher>(&self, state: &mut H) {
self.url.as_str().hash(state);
self.placements.hash(state);
self.ohttp.hash(state);
}
}
impl From<AdRequest> 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<AdPlacementRequest>,
url: Url,
ohttp: bool,
) -> Result<Self, BuildRequestError> {
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<String> = 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<AdContentCategory>,
pub count: u32,
pub placement: String,
}
#[derive(Debug, Deserialize, Hash, PartialEq, Serialize)]
pub struct AdContentCategory {
pub categories: Vec<String>,
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 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 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 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 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 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 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));
}
}