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
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
use crate::http_cache::RequestHash;
use crate::telemetry::Telemetry;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use url::Url;
#[derive(Debug, PartialEq, Serialize)]
pub struct AdResponse<A: AdResponseValue> {
pub data: HashMap<String, Vec<A>>,
}
impl<A: AdResponseValue> AdResponse<A> {
pub fn parse<T: Telemetry>(
data: serde_json::Value,
telemetry: &T,
) -> Result<AdResponse<A>, serde_json::Error> {
let raw: HashMap<String, serde_json::Value> = serde_json::from_value(data)?;
let mut result = HashMap::new();
for (key, value) in raw {
if let serde_json::Value::Array(arr) = value {
let mut ads: Vec<A> = vec![];
for item in arr {
match serde_json::from_value::<A>(item.clone()) {
Ok(ad) => ads.push(ad),
Err(e) => {
telemetry.record(&e);
}
}
}
if !ads.is_empty() {
result.insert(key, ads);
}
}
}
Ok(AdResponse { data: result })
}
pub fn enrich_callbacks(&mut self, request_hash: &RequestHash) {
let hash_str = request_hash.to_string();
for (placement_id, ads) in self.data.iter_mut() {
for (position, ad) in ads.iter_mut().enumerate() {
let callbacks = ad.callbacks_mut();
callbacks
.click
.query_pairs_mut()
.append_pair("request_hash", &hash_str);
callbacks
.impression
.query_pairs_mut()
.append_pair("request_hash", &hash_str);
if let Some(report_url) = callbacks.report.as_mut() {
report_url
.query_pairs_mut()
.append_pair("placement_id", placement_id)
.append_pair("position", &position.to_string());
}
}
}
}
pub fn take_first(self) -> HashMap<String, A> {
self.data
.into_iter()
.filter_map(|(k, mut v)| {
if v.is_empty() {
None
} else {
Some((k, v.remove(0)))
}
})
.collect()
}
}
// TODO: Remove this allow(dead_code) when cache invalidation is re-enabled behind Nimbus experiment
#[allow(dead_code)]
pub fn pop_request_hash_from_url(url: &mut Url) -> Option<RequestHash> {
let mut request_hash = None;
let mut query = url::form_urlencoded::Serializer::new(String::new());
for (key, value) in url.query_pairs() {
if key == "request_hash" {
request_hash = Some(RequestHash::from(value.as_ref()));
} else {
query.append_pair(&key, &value);
}
}
let query_string = query.finish();
if query_string.is_empty() {
url.set_query(None);
} else {
url.set_query(Some(&query_string));
}
request_hash
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct AdImage {
pub alt_text: Option<String>,
pub block_key: String,
pub callbacks: AdCallbacks,
pub format: String,
pub image_url: String,
pub url: String,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct AdSpoc {
pub block_key: String,
pub callbacks: AdCallbacks,
pub caps: SpocFrequencyCaps,
pub domain: String,
pub excerpt: String,
pub format: String,
pub image_url: String,
pub ranking: SpocRanking,
pub sponsor: String,
pub sponsored_by_override: Option<String>,
pub title: String,
pub url: String,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct AdTile {
pub block_key: String,
pub callbacks: AdCallbacks,
pub format: String,
pub image_url: String,
pub name: String,
pub url: String,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct SpocFrequencyCaps {
pub cap_key: String,
pub day: u32,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct SpocRanking {
pub priority: u32,
pub personalization_models: Option<HashMap<String, u32>>,
pub item_score: f64,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct AdCallbacks {
pub click: Url,
pub impression: Url,
pub report: Option<Url>,
}
pub trait AdResponseValue: DeserializeOwned {
fn callbacks_mut(&mut self) -> &mut AdCallbacks;
}
impl AdResponseValue for AdImage {
fn callbacks_mut(&mut self) -> &mut AdCallbacks {
&mut self.callbacks
}
}
impl AdResponseValue for AdSpoc {
fn callbacks_mut(&mut self) -> &mut AdCallbacks {
&mut self.callbacks
}
}
impl AdResponseValue for AdTile {
fn callbacks_mut(&mut self) -> &mut AdCallbacks {
&mut self.callbacks
}
}
#[cfg(test)]
mod tests {
use crate::ffi::telemetry::MozAdsTelemetryWrapper;
use super::*;
use serde_json::{from_str, json};
#[test]
fn test_moz_ad_full() {
let response_full = json!({
"alt_text": "An ad for an anvil",
"block_key": "abc123",
"callbacks": {
},
"format": "Leaderboard",
})
.to_string();
let full: AdImage = from_str(&response_full).unwrap();
assert_eq!(
full,
AdImage {
alt_text: Some("An ad for an anvil".into()),
block_key: "abc123".into(),
callbacks: AdCallbacks {
click: Url::parse("https://buyanvilseveryday.test/click").unwrap(),
impression: Url::parse("https://buyanvilseveryday.test/impression").unwrap(),
report: Some(Url::parse("https://buyanvilseveryday.test/report").unwrap()),
},
format: "Leaderboard".into(),
}
);
}
#[test]
fn test_moz_ad_response_partial() {
let response_partial = json!({
"alt_text": null,
"block_key": "abc123",
"callbacks": {
"report": null
},
"format": "Leaderboard",
})
.to_string();
let partial: AdImage = from_str(&response_partial).unwrap();
assert_eq!(
partial,
AdImage {
alt_text: None,
block_key: "abc123".into(),
callbacks: AdCallbacks {
click: Url::parse("https://example.test/click").unwrap(),
impression: Url::parse("https://example.test/impression").unwrap(),
report: None,
},
format: "Leaderboard".into(),
image_url: "https://example.test/image.png".into(),
url: "https://example.test/item".into(),
}
);
}
#[test]
fn test_ad_response_serialization() {
let raw_ad_response = json!({
"missing_click_url": [
{
"block_key": "abc123",
"format": "billboard",
"alt_text": "An ad for a puppy",
"callbacks": {
}
}
],
"incorrect_click_url": [
{
"block_key": "abc123",
"format": "billboard",
"alt_text": "An ad for a puppy",
"callbacks": {
"click": "incorrect-click-url",
}
}
],
"missing_impression_url": [
{
"block_key": "abc123",
"format": "billboard",
"alt_text": "An ad for a puppy",
"callbacks": {
}
}
],
"incorrect_impression_url": [
{
"block_key": "abc123",
"format": "skyscraper",
"alt_text": "An ad for a pet duck",
"callbacks": {
"impression": "incorrect-impression-url",
}
}
],
"valid_ad": [
{
"block_key": "abc123",
"format": "skyscraper",
"alt_text": "An ad for a pet duck",
"callbacks": {
}
}
]
});
let parsed =
AdResponse::<AdImage>::parse(raw_ad_response, &MozAdsTelemetryWrapper::noop()).unwrap();
let expected = AdResponse {
data: HashMap::from([(
"valid_ad".to_string(),
vec![AdImage {
format: "skyscraper".to_string(),
block_key: "abc123".into(),
alt_text: Some("An ad for a pet duck".to_string()),
callbacks: AdCallbacks {
.unwrap(),
impression: Url::parse(
)
.unwrap(),
report: Some(
),
},
}],
)]),
};
assert_eq!(parsed, expected);
}
#[test]
fn test_empty_ad_response_serialization() {
let raw_ad_response = json!({
"example_placement_1": [],
"example_placement_2": []
});
let parsed =
AdResponse::<AdImage>::parse(raw_ad_response, &MozAdsTelemetryWrapper::noop()).unwrap();
let expected = AdResponse {
data: HashMap::from([]),
};
assert_eq!(parsed, expected);
}
#[test]
fn test_take_first() {
let mut response = AdResponse {
data: HashMap::new(),
};
response.data.insert(
"placement_1".to_string(),
vec![
AdImage {
alt_text: Some("First ad".to_string()),
block_key: "key1".to_string(),
callbacks: AdCallbacks {
click: Url::parse("https://example.com/click1").unwrap(),
impression: Url::parse("https://example.com/impression1").unwrap(),
report: None,
},
format: "billboard".to_string(),
image_url: "https://example.com/image1.png".to_string(),
url: "https://example.com/ad1".to_string(),
},
AdImage {
alt_text: Some("Second ad".to_string()),
block_key: "key2".to_string(),
callbacks: AdCallbacks {
click: Url::parse("https://example.com/click2").unwrap(),
impression: Url::parse("https://example.com/impression2").unwrap(),
report: None,
},
format: "billboard".to_string(),
image_url: "https://example.com/image2.png".to_string(),
url: "https://example.com/ad2".to_string(),
},
],
);
response.data.insert(
"placement_2".to_string(),
vec![AdImage {
alt_text: Some("Third ad".to_string()),
block_key: "key3".to_string(),
callbacks: AdCallbacks {
click: Url::parse("https://example.com/click3").unwrap(),
impression: Url::parse("https://example.com/impression3").unwrap(),
report: None,
},
format: "skyscraper".to_string(),
image_url: "https://example.com/image3.png".to_string(),
url: "https://example.com/ad3".to_string(),
}],
);
response.data.insert("placement_3".to_string(), vec![]);
let result = response.take_first();
assert_eq!(result.len(), 2);
assert!(result.contains_key("placement_1"));
assert!(result.contains_key("placement_2"));
assert!(!result.contains_key("placement_3"));
let first_ad = result.get("placement_1").unwrap();
assert_eq!(first_ad.alt_text, Some("First ad".to_string()));
assert_eq!(first_ad.block_key, "key1");
let second_ad = result.get("placement_2").unwrap();
assert_eq!(second_ad.alt_text, Some("Third ad".to_string()));
assert_eq!(second_ad.block_key, "key3");
}
#[test]
fn test_enrich_callbacks() {
let mut response = AdResponse {
data: HashMap::from([(
"mock_tile_1".to_string(),
vec![
AdImage {
alt_text: None,
block_key: "key1".into(),
callbacks: AdCallbacks {
click: Url::parse("https://example.com/click1").unwrap(),
impression: Url::parse("https://example.com/impression1").unwrap(),
report: Some(Url::parse("https://example.com/report").unwrap()),
},
format: "billboard".to_string(),
image_url: "https://example.com/image1.png".to_string(),
url: "https://example.com/ad1".to_string(),
},
AdImage {
alt_text: None,
block_key: "key2".into(),
callbacks: AdCallbacks {
click: Url::parse("https://example.com/click2").unwrap(),
impression: Url::parse("https://example.com/impression2").unwrap(),
report: Some(Url::parse("https://example.com/report").unwrap()),
},
format: "billboard".to_string(),
image_url: "https://example.com/image2.png".to_string(),
url: "https://example.com/ad2".to_string(),
},
],
)]),
};
let request_hash = RequestHash::from("abc123def456");
response.enrich_callbacks(&request_hash);
let ads = &response.data["mock_tile_1"];
// request_hash added to click and impression
assert!(ads[0]
.callbacks
.click
.query()
.unwrap_or("")
.contains("request_hash=abc123def456"));
assert!(ads[0]
.callbacks
.impression
.query()
.unwrap_or("")
.contains("request_hash=abc123def456"));
// placement info added to report
let report_0 = ads[0]
.callbacks
.report
.as_ref()
.unwrap()
.query()
.unwrap_or("");
assert!(report_0.contains("placement_id=mock_tile_1"));
assert!(report_0.contains("position=0"));
let report_1 = ads[1]
.callbacks
.report
.as_ref()
.unwrap()
.query()
.unwrap_or("");
assert!(report_1.contains("placement_id=mock_tile_1"));
assert!(report_1.contains("position=1"));
}
#[test]
fn test_enrich_callbacks_skips_ads_without_report_url() {
let mut response = AdResponse {
data: HashMap::from([(
"mock_tile_1".to_string(),
vec![AdImage {
alt_text: None,
block_key: "key1".into(),
callbacks: AdCallbacks {
click: Url::parse("https://example.com/click").unwrap(),
impression: Url::parse("https://example.com/impression").unwrap(),
report: None,
},
format: "billboard".to_string(),
image_url: "https://example.com/image.png".to_string(),
url: "https://example.com/ad".to_string(),
}],
)]),
};
// Should not panic
let request_hash = RequestHash::from("abc123def456");
response.enrich_callbacks(&request_hash);
let ad = &response.data["mock_tile_1"][0];
assert!(ad.callbacks.report.is_none());
// click/impression still get request_hash
assert!(ad
.callbacks
.click
.query()
.unwrap_or("")
.contains("request_hash=abc123def456"));
assert!(ad
.callbacks
.impression
.query()
.unwrap_or("")
.contains("request_hash=abc123def456"));
}
#[test]
fn test_pop_request_hash_from_url() {
let mut url_with_hash =
.unwrap();
let extracted = pop_request_hash_from_url(&mut url_with_hash);
assert_eq!(extracted, Some(RequestHash::from("abc123def456")));
assert_eq!(url_with_hash.query(), Some("other=param"));
let mut url_without_hash = Url::parse("https://example.com/callback?other=param").unwrap();
let extracted_none = pop_request_hash_from_url(&mut url_without_hash);
assert_eq!(extracted_none, None);
assert_eq!(url_without_hash.query(), Some("other=param"));
let mut url_no_query = Url::parse("https://example.com/callback").unwrap();
let extracted_empty = pop_request_hash_from_url(&mut url_no_query);
assert_eq!(extracted_empty, None);
assert_eq!(url_no_query.query(), None);
}
}