Skip to content

Commit 79a4058

Browse files
committed
refactor(ads-client): make HttpCache generic over T: Hash + Into<Request>
Move ads-specific cache key logic out of the generic HTTP cache layer. HttpCache is now HttpCache<T: Hash + Into<Request>> where T controls both hashing (for cache keys) and request construction. - Add RequestHash::new(&impl Hash) using DefaultHasher, remove From<&Request> that parsed JSON bodies and stripped context_id - Add url field to AdRequest with manual Hash impl (excludes context_id) and From<AdRequest> for Request - Make HttpCacheStore::lookup/store_with_ttl accept &RequestHash from caller - Add Environment::into_url(path) for building endpoint URLs - Derive Hash/Clone on AdPlacementRequest, AdContentCategory, IABContentTaxonomy
1 parent 11e01b2 commit 79a4058

File tree

11 files changed

+457
-478
lines changed

11 files changed

+457
-478
lines changed

components/ads-client/src/client.rs

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use std::collections::HashMap;
77
use std::time::Duration;
88

99
use crate::client::ad_response::{AdImage, AdResponse, AdResponseValue, AdSpoc, AdTile};
10-
use crate::client::config::AdsClientConfig;
10+
use crate::client::config::{AdsClientConfig, Environment};
1111
use crate::error::{RecordClickError, RecordImpressionError, ReportAdError, RequestAdsError};
1212
use crate::http_cache::{HttpCache, RequestCachePolicy};
1313
use crate::mars::MARSClient;
@@ -32,6 +32,7 @@ where
3232
{
3333
client: MARSClient<T>,
3434
context_id_component: ContextIDComponent,
35+
environment: Environment,
3536
telemetry: T,
3637
}
3738

@@ -48,6 +49,7 @@ where
4849
Box::new(DefaultContextIdCallback),
4950
);
5051
let telemetry = client_config.telemetry;
52+
let environment = client_config.environment;
5153

5254
// Configure the cache if a path is provided.
5355
// Defaults for ttl and cache size are also set if unspecified.
@@ -72,8 +74,9 @@ where
7274
}
7375
};
7476

75-
let client = MARSClient::new(client_config.environment, http_cache, telemetry.clone());
77+
let client = MARSClient::new(http_cache, telemetry.clone());
7678
let client = Self {
79+
environment,
7780
context_id_component,
7881
client,
7982
telemetry: telemetry.clone(),
@@ -82,8 +85,9 @@ where
8285
return client;
8386
}
8487

85-
let client = MARSClient::new(client_config.environment, None, telemetry.clone());
88+
let client = MARSClient::new(None, telemetry.clone());
8689
let client = Self {
90+
environment,
8791
context_id_component,
8892
client,
8993
telemetry: telemetry.clone(),
@@ -101,10 +105,10 @@ where
101105
A: AdResponseValue,
102106
{
103107
let context_id = self.get_context_id()?;
104-
let ad_request = AdRequest::build(context_id, ad_placement_requests)?;
108+
let url = self.environment.into_url("ads");
109+
let ad_request = AdRequest::try_new(context_id, ad_placement_requests, url)?;
105110
let cache_policy = options.unwrap_or_default();
106-
let (mut response, request_hash) =
107-
self.client.fetch_ads::<A>(&ad_request, &cache_policy)?;
111+
let (mut response, request_hash) = self.client.fetch_ads::<A>(ad_request, &cache_policy)?;
108112
response.add_request_hash_to_callbacks(&request_hash);
109113
Ok(response)
110114
}
@@ -222,7 +226,6 @@ pub enum ClientOperationEvent {
222226
#[cfg(test)]
223227
mod tests {
224228
use crate::{
225-
client::config::Environment,
226229
ffi::telemetry::MozAdsTelemetryWrapper,
227230
test_utils::{
228231
get_example_happy_image_response, get_example_happy_spoc_response,
@@ -242,6 +245,7 @@ mod tests {
242245
Box::new(DefaultContextIdCallback),
243246
);
244247
AdsClient {
248+
environment: Environment::Test,
245249
context_id_component,
246250
client,
247251
telemetry: MozAdsTelemetryWrapper::noop(),
@@ -272,7 +276,7 @@ mod tests {
272276
.create();
273277

274278
let telemetry = MozAdsTelemetryWrapper::noop();
275-
let mars_client = MARSClient::new(Environment::Test, None, telemetry);
279+
let mars_client = MARSClient::new(None, telemetry);
276280
let ads_client = new_with_mars_client(mars_client);
277281

278282
let ad_placement_requests = make_happy_placement_requests();
@@ -294,7 +298,7 @@ mod tests {
294298
.create();
295299

296300
let telemetry = MozAdsTelemetryWrapper::noop();
297-
let mars_client = MARSClient::new(Environment::Test, None, telemetry);
301+
let mars_client = MARSClient::new(None, telemetry);
298302
let ads_client = new_with_mars_client(mars_client);
299303

300304
let ad_placement_requests = make_happy_placement_requests();
@@ -316,7 +320,7 @@ mod tests {
316320
.create();
317321

318322
let telemetry = MozAdsTelemetryWrapper::noop();
319-
let mars_client = MARSClient::new(Environment::Test, None, telemetry.clone());
323+
let mars_client = MARSClient::new(None, telemetry.clone());
320324
let ads_client = new_with_mars_client(mars_client);
321325

322326
let ad_placement_requests = make_happy_placement_requests();
@@ -334,7 +338,7 @@ mod tests {
334338
.build()
335339
.unwrap();
336340
let telemetry = MozAdsTelemetryWrapper::noop();
337-
let mars_client = MARSClient::new(Environment::Test, Some(cache), telemetry.clone());
341+
let mars_client = MARSClient::new(Some(cache), telemetry.clone());
338342
let ads_client = new_with_mars_client(mars_client);
339343

340344
let response = get_example_happy_image_response();

components/ads-client/src/client/ad_request.rs

Lines changed: 90 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,51 @@
44
*/
55

66
use std::collections::HashSet;
7+
use std::hash::{Hash, Hasher};
78

89
use serde::{Deserialize, Serialize};
10+
use url::Url;
11+
use viaduct::Request;
912

1013
use crate::error::BuildRequestError;
1114

1215
#[derive(Debug, PartialEq, Serialize)]
1316
pub struct AdRequest {
1417
pub context_id: String,
1518
pub placements: Vec<AdPlacementRequest>,
19+
/// Skipped to exclude from the request body
20+
#[serde(skip)]
21+
pub url: Url,
22+
}
23+
24+
/// Hash implementation intentionally excludes `context_id` as it rotates
25+
/// on client re-instantiation and should not invalidate cached responses.
26+
impl Hash for AdRequest {
27+
fn hash<H: Hasher>(&self, state: &mut H) {
28+
self.url.as_str().hash(state);
29+
self.placements.hash(state);
30+
}
31+
}
32+
33+
impl From<AdRequest> for Request {
34+
fn from(ad_request: AdRequest) -> Self {
35+
let url = ad_request.url.clone();
36+
Request::post(url).json(&ad_request)
37+
}
1638
}
1739

1840
impl AdRequest {
19-
pub fn build(
41+
pub fn try_new(
2042
context_id: String,
2143
placements: Vec<AdPlacementRequest>,
44+
url: Url,
2245
) -> Result<Self, BuildRequestError> {
2346
if placements.is_empty() {
2447
return Err(BuildRequestError::EmptyConfig);
2548
};
2649

2750
let mut request = AdRequest {
51+
url,
2852
placements: vec![],
2953
context_id,
3054
};
@@ -56,20 +80,20 @@ impl AdRequest {
5680
}
5781
}
5882

59-
#[derive(Debug, PartialEq, Serialize)]
83+
#[derive(Debug, Hash, PartialEq, Serialize)]
6084
pub struct AdPlacementRequest {
6185
pub placement: String,
6286
pub count: u32,
6387
pub content: Option<AdContentCategory>,
6488
}
6589

66-
#[derive(Debug, Deserialize, PartialEq, Serialize)]
90+
#[derive(Debug, Deserialize, Hash, PartialEq, Serialize)]
6791
pub struct AdContentCategory {
6892
pub taxonomy: IABContentTaxonomy,
6993
pub categories: Vec<String>,
7094
}
7195

72-
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)]
96+
#[derive(Debug, Deserialize, Hash, PartialEq, Serialize)]
7397
pub enum IABContentTaxonomy {
7498
#[serde(rename = "IAB-1.0")]
7599
IAB1_0,
@@ -142,7 +166,8 @@ mod tests {
142166

143167
#[test]
144168
fn test_build_ad_request_happy() {
145-
let request = AdRequest::build(
169+
let url: Url = "https://example.com/ads".parse().unwrap();
170+
let request = AdRequest::try_new(
146171
TEST_CONTEXT_ID.to_string(),
147172
vec![
148173
AdPlacementRequest {
@@ -162,10 +187,12 @@ mod tests {
162187
}),
163188
},
164189
],
190+
url.clone(),
165191
)
166192
.unwrap();
167193

168194
let expected_request = AdRequest {
195+
url,
169196
context_id: TEST_CONTEXT_ID.to_string(),
170197
placements: vec![
171198
AdPlacementRequest {
@@ -192,7 +219,8 @@ mod tests {
192219

193220
#[test]
194221
fn test_build_ad_request_fails_on_duplicate_placement_id() {
195-
let request = AdRequest::build(
222+
let url: Url = "https://example.com/ads".parse().unwrap();
223+
let request = AdRequest::try_new(
196224
TEST_CONTEXT_ID.to_string(),
197225
vec![
198226
AdPlacementRequest {
@@ -212,13 +240,68 @@ mod tests {
212240
}),
213241
},
214242
],
243+
url,
215244
);
216245
assert!(request.is_err());
217246
}
218247

219248
#[test]
220249
fn test_build_ad_request_fails_on_empty_request() {
221-
let request = AdRequest::build(TEST_CONTEXT_ID.to_string(), vec![]);
250+
let url: Url = "https://example.com/ads".parse().unwrap();
251+
let request = AdRequest::try_new(TEST_CONTEXT_ID.to_string(), vec![], url);
222252
assert!(request.is_err());
223253
}
254+
255+
#[test]
256+
fn test_context_id_ignored_in_hash() {
257+
use crate::http_cache::RequestHash;
258+
259+
let url: Url = "https://example.com/ads".parse().unwrap();
260+
let make_placements = || {
261+
vec![AdPlacementRequest {
262+
placement: "tile_1".to_string(),
263+
count: 1,
264+
content: None,
265+
}]
266+
};
267+
268+
let context_id_a = "aaaa-bbbb-cccc".to_string();
269+
let context_id_b = "dddd-eeee-ffff".to_string();
270+
271+
let req1 = AdRequest::try_new(context_id_a, make_placements(), url.clone()).unwrap();
272+
let req2 = AdRequest::try_new(context_id_b, make_placements(), url).unwrap();
273+
274+
assert_eq!(RequestHash::new(&req1), RequestHash::new(&req2));
275+
}
276+
277+
#[test]
278+
fn test_different_placements_produce_different_hash() {
279+
use crate::http_cache::RequestHash;
280+
281+
let url: Url = "https://example.com/ads".parse().unwrap();
282+
283+
let req1 = AdRequest::try_new(
284+
"same-id".to_string(),
285+
vec![AdPlacementRequest {
286+
placement: "tile_1".to_string(),
287+
count: 1,
288+
content: None,
289+
}],
290+
url.clone(),
291+
)
292+
.unwrap();
293+
294+
let req2 = AdRequest::try_new(
295+
"same-id".to_string(),
296+
vec![AdPlacementRequest {
297+
placement: "tile_2".to_string(),
298+
count: 3,
299+
content: None,
300+
}],
301+
url,
302+
)
303+
.unwrap();
304+
305+
assert_ne!(RequestHash::new(&req1), RequestHash::new(&req2));
306+
}
224307
}

components/ads-client/src/client/config.rs

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,25 @@ pub enum Environment {
3333
}
3434

3535
impl Environment {
36-
pub fn into_mars_url(self) -> Url {
36+
fn base_url(self) -> Url {
3737
match self {
3838
Environment::Prod => MARS_API_ENDPOINT_PROD.clone(),
3939
Environment::Staging => MARS_API_ENDPOINT_STAGING.clone(),
4040
#[cfg(test)]
4141
Environment::Test => Url::parse(&mockito::server_url()).unwrap(),
4242
}
4343
}
44+
45+
pub fn into_url(self, path: &str) -> Url {
46+
let mut base = self.base_url();
47+
// Ensure the path has a trailing slash so that `join` appends
48+
// rather than replacing the last segment.
49+
if !base.path().ends_with('/') {
50+
base.set_path(&format!("{}/", base.path()));
51+
}
52+
base.join(path)
53+
.expect("joining a path to a valid base URL must succeed")
54+
}
4455
}
4556

4657
#[derive(Clone, Debug)]
@@ -58,15 +69,12 @@ mod tests {
5869

5970
#[test]
6071
fn prod_endpoint_parses_and_is_expected() {
61-
let url = Environment::Prod.into_mars_url();
72+
let url = Environment::Prod.into_url("ads");
6273

63-
assert_eq!(url.as_str(), "https://ads.mozilla.org/v1/");
74+
assert_eq!(url.as_str(), "https://ads.mozilla.org/v1/ads");
6475

6576
assert_eq!(url.scheme(), "https");
6677
assert_eq!(url.host(), Some(Host::Domain("ads.mozilla.org")));
67-
assert_eq!(url.path(), "/v1/");
68-
69-
let url2 = Environment::Prod.into_mars_url();
70-
assert!(url == url2);
78+
assert_eq!(url.path(), "/v1/ads");
7179
}
7280
}

0 commit comments

Comments
 (0)