1use std::collections::{HashMap, HashSet};
7
8use error::ApiResult;
9use error::{
10 BuildPlacementsError, BuildRequestError, ComponentError, RecordClickError,
11 RecordImpressionError, ReportAdError, RequestAdsError,
12};
13use error_support::handle_error;
14use instrument::TrackError;
15use mars::{DefaultMARSClient, MARSClient};
16use models::{AdContentCategory, AdRequest, AdResponse, IABContentTaxonomy, MozAd};
17use parking_lot::Mutex;
18use uuid::Uuid;
19
20mod error;
21mod instrument;
22mod mars;
23mod models;
24#[cfg(test)]
25mod test_utils;
26
27uniffi::setup_scaffolding!("adsclient");
28
29#[derive(uniffi::Object)]
31pub struct MozAdsClient {
32 inner: Mutex<MozAdsClientInner>,
33}
34
35impl Default for MozAdsClient {
36 fn default() -> Self {
37 Self {
38 inner: Mutex::new(MozAdsClientInner::new()),
39 }
40 }
41}
42
43#[uniffi::export]
44impl MozAdsClient {
45 #[uniffi::constructor]
46 pub fn new() -> Self {
47 Self::default()
48 }
49
50 #[handle_error(ComponentError)]
51 pub fn request_ads(
52 &self,
53 moz_ad_configs: Vec<MozAdsPlacementConfig>,
54 ) -> ApiResult<HashMap<String, MozAdsPlacement>> {
55 let inner = self.inner.lock();
56 let placements = inner
57 .request_ads(&moz_ad_configs)
58 .map_err(ComponentError::RequestAds)?;
59 Ok(placements)
60 }
61
62 #[handle_error(ComponentError)]
63 pub fn record_impression(&self, placement: MozAdsPlacement) -> ApiResult<()> {
64 let inner = self.inner.lock();
65 inner
66 .record_impression(&placement)
67 .map_err(ComponentError::RecordImpression)
68 .emit_telemetry_if_error()
69 }
70
71 #[handle_error(ComponentError)]
72 pub fn record_click(&self, placement: MozAdsPlacement) -> ApiResult<()> {
73 let inner = self.inner.lock();
74 inner
75 .record_click(&placement)
76 .map_err(ComponentError::RecordClick)
77 .emit_telemetry_if_error()
78 }
79
80 #[handle_error(ComponentError)]
81 pub fn report_ad(&self, placement: MozAdsPlacement) -> ApiResult<()> {
82 let inner = self.inner.lock();
83 inner
84 .report_ad(&placement)
85 .map_err(ComponentError::ReportAd)
86 .emit_telemetry_if_error()
87 }
88
89 pub fn cycle_context_id(&self) -> ApiResult<String> {
90 let mut inner = self.inner.lock();
91 let previous_context_id = inner.cycle_context_id();
92 Ok(previous_context_id)
93 }
94
95 pub fn clear_cache(&self) -> ApiResult<()> {
96 let mut inner = self.inner.lock();
97 inner.clear_cache();
98 Ok(())
99 }
100}
101
102pub struct MozAdsClientInner {
103 ads_cache: HashMap<String, MozAdsPlacement>, client: Box<dyn MARSClient>,
105}
106
107impl MozAdsClientInner {
108 fn new() -> Self {
109 let context_id = Uuid::new_v4().to_string();
110 let client = Box::new(DefaultMARSClient::new(context_id));
111 let ads_cache = HashMap::new(); Self { ads_cache, client }
113 }
114
115 fn clear_cache(&mut self) {
116 self.ads_cache.clear();
117 }
118
119 fn request_ads(
120 &self,
121 moz_ad_configs: &Vec<MozAdsPlacementConfig>,
122 ) -> Result<HashMap<String, MozAdsPlacement>, RequestAdsError> {
123 let ad_request = self.build_request_from_placement_configs(moz_ad_configs)?;
124 let response = self.client.fetch_ads(&ad_request)?;
125 let placements = self.build_placements(moz_ad_configs, response)?;
126 Ok(placements)
127 }
128
129 fn record_impression(&self, placement: &MozAdsPlacement) -> Result<(), RecordImpressionError> {
130 let impression_callback = placement
131 .content
132 .callbacks
133 .as_ref()
134 .and_then(|callbacks| callbacks.impression.clone());
135
136 self.client.record_impression(impression_callback)?;
137 Ok(())
138 }
139
140 fn record_click(&self, placement: &MozAdsPlacement) -> Result<(), RecordClickError> {
141 let click_callback = placement
142 .content
143 .callbacks
144 .as_ref()
145 .and_then(|callbacks| callbacks.click.clone());
146
147 self.client.record_click(click_callback)?;
148 Ok(())
149 }
150
151 fn report_ad(&self, placement: &MozAdsPlacement) -> Result<(), ReportAdError> {
152 let report_ad_callback = placement
153 .content
154 .callbacks
155 .as_ref()
156 .and_then(|callbacks| callbacks.report.clone());
157
158 self.client.report_ad(report_ad_callback)?;
159 Ok(())
160 }
161
162 fn cycle_context_id(&mut self) -> String {
163 self.client.cycle_context_id()
164 }
165
166 fn build_request_from_placement_configs(
167 &self,
168 moz_ad_configs: &Vec<MozAdsPlacementConfig>,
169 ) -> Result<AdRequest, BuildRequestError> {
170 if moz_ad_configs.is_empty() {
171 return Err(BuildRequestError::EmptyConfig);
172 }
173
174 let context_id = self.client.get_context_id().to_string();
175 let mut request = AdRequest {
176 placements: vec![],
177 context_id,
178 };
179
180 let mut used_placement_ids: HashSet<&String> = HashSet::new();
181
182 for config in moz_ad_configs {
183 if used_placement_ids.contains(&config.placement_id) {
184 return Err(BuildRequestError::DuplicatePlacementId {
185 placement_id: config.placement_id.clone(),
186 });
187 }
188
189 request.placements.push(models::AdPlacementRequest {
190 placement: config.placement_id.clone(),
191 count: 1, content: config
193 .iab_content
194 .clone()
195 .map(|iab_content| AdContentCategory {
196 categories: iab_content.category_ids,
197 taxonomy: iab_content.taxonomy,
198 }),
199 });
200
201 used_placement_ids.insert(&config.placement_id);
202 }
203
204 Ok(request)
205 }
206
207 fn build_placements(
208 &self,
209 placement_configs: &Vec<MozAdsPlacementConfig>,
210 mut mars_response: AdResponse,
211 ) -> Result<HashMap<String, MozAdsPlacement>, BuildPlacementsError> {
212 let mut moz_ad_placements: HashMap<String, MozAdsPlacement> = HashMap::new();
213
214 for config in placement_configs {
215 let placement_content = mars_response.data.get_mut(&config.placement_id);
216
217 match placement_content {
218 Some(v) => {
219 let ad_content = v.pop();
220 match ad_content {
221 Some(c) => {
222 let is_updated = moz_ad_placements.insert(
223 config.placement_id.clone(),
224 MozAdsPlacement {
225 content: c,
226 placement_config: config.clone(),
227 },
228 );
229 if let Some(v) = is_updated {
230 return Err(BuildPlacementsError::DuplicatePlacementId {
231 placement_id: v.placement_config.placement_id,
232 });
233 }
234 }
235 None => continue,
236 }
237 }
238 None => continue,
239 }
240 }
241
242 Ok(moz_ad_placements)
243 }
244}
245
246#[derive(Debug, Clone, PartialEq, uniffi::Record)]
247pub struct IABContent {
248 pub taxonomy: IABContentTaxonomy,
249 pub category_ids: Vec<String>,
250}
251
252#[derive(Debug, Clone, PartialEq, uniffi::Record)]
253pub struct MozAdsSize {
254 pub width: u16,
255 pub height: u16,
256}
257
258#[derive(Debug, Clone, PartialEq, uniffi::Record)]
259pub struct MozAdsPlacementConfig {
260 pub placement_id: String,
261 pub fixed_size: Option<MozAdsSize>,
262 pub iab_content: Option<IABContent>,
263}
264
265#[derive(Debug, PartialEq, uniffi::Record)]
266pub struct MozAdsPlacement {
267 pub placement_config: MozAdsPlacementConfig,
268 pub content: MozAd,
269}
270
271#[cfg(test)]
272mod tests {
273
274 use parking_lot::lock_api::Mutex;
275
276 use crate::{
277 mars::MockMARSClient,
278 models::{AdCallbacks, AdContentCategory, AdPlacementRequest},
279 test_utils::{
280 get_example_happy_ad_response, get_example_happy_placement_config,
281 get_example_happy_placements,
282 },
283 };
284
285 use super::*;
286
287 #[test]
288 fn test_build_ad_request_happy() {
289 let mut mock = MockMARSClient::new();
290 mock.expect_get_context_id()
291 .return_const("mock-context-id".to_string());
292
293 let inner_component = MozAdsClientInner {
294 ads_cache: HashMap::new(),
295 client: Box::new(mock),
296 };
297
298 let configs: Vec<MozAdsPlacementConfig> = vec![
299 MozAdsPlacementConfig {
300 placement_id: "example_placement_1".to_string(),
301 iab_content: Some(IABContent {
302 taxonomy: IABContentTaxonomy::IAB2_1,
303 category_ids: vec!["entertainment".to_string()],
304 }),
305 fixed_size: None,
306 },
307 MozAdsPlacementConfig {
308 placement_id: "example_placement_2".to_string(),
309 iab_content: Some(IABContent {
310 taxonomy: IABContentTaxonomy::IAB3_0,
311 category_ids: vec![],
312 }),
313 fixed_size: None,
314 },
315 MozAdsPlacementConfig {
316 placement_id: "example_placement_3".to_string(),
317 iab_content: Some(IABContent {
318 taxonomy: IABContentTaxonomy::IAB2_1,
319 category_ids: vec![],
320 }),
321 fixed_size: Some(MozAdsSize {
322 width: 200,
323 height: 200,
324 }),
325 },
326 ];
327 let request = inner_component
328 .build_request_from_placement_configs(&configs)
329 .unwrap();
330 let context_id = inner_component.client.get_context_id().to_string();
331
332 let expected_request = AdRequest {
333 context_id,
334 placements: vec![
335 AdPlacementRequest {
336 placement: "example_placement_1".to_string(),
337 content: Some(AdContentCategory {
338 taxonomy: IABContentTaxonomy::IAB2_1,
339 categories: vec!["entertainment".to_string()],
340 }),
341 count: 1,
342 },
343 AdPlacementRequest {
344 placement: "example_placement_2".to_string(),
345 content: Some(AdContentCategory {
346 taxonomy: IABContentTaxonomy::IAB3_0,
347 categories: vec![],
348 }),
349 count: 1,
350 },
351 AdPlacementRequest {
352 placement: "example_placement_3".to_string(),
353 content: Some(AdContentCategory {
354 taxonomy: IABContentTaxonomy::IAB2_1,
355 categories: vec![],
356 }),
357 count: 1,
358 },
359 ],
360 };
361
362 assert_eq!(request, expected_request);
363 }
364
365 #[test]
366 fn test_build_ad_request_fails_on_duplicate_placement_id() {
367 let mut mock = MockMARSClient::new();
368 mock.expect_get_context_id()
369 .return_const("mock-context-id".to_string());
370
371 let inner_component = MozAdsClientInner {
372 ads_cache: HashMap::new(),
373 client: Box::new(mock),
374 };
375
376 let configs: Vec<MozAdsPlacementConfig> = vec![
377 MozAdsPlacementConfig {
378 placement_id: "example_placement_1".to_string(),
379 iab_content: Some(IABContent {
380 taxonomy: IABContentTaxonomy::IAB2_1,
381 category_ids: vec!["entertainment".to_string()],
382 }),
383 fixed_size: None,
384 },
385 MozAdsPlacementConfig {
386 placement_id: "example_placement_2".to_string(),
387 iab_content: Some(IABContent {
388 taxonomy: IABContentTaxonomy::IAB3_0,
389 category_ids: vec![],
390 }),
391 fixed_size: None,
392 },
393 MozAdsPlacementConfig {
394 placement_id: "example_placement_2".to_string(),
395 iab_content: Some(IABContent {
396 taxonomy: IABContentTaxonomy::IAB2_1,
397 category_ids: vec![],
398 }),
399 fixed_size: Some(MozAdsSize {
400 width: 200,
401 height: 200,
402 }),
403 },
404 ];
405 let request = inner_component.build_request_from_placement_configs(&configs);
406
407 assert!(request.is_err());
408 }
409
410 #[test]
411 fn test_build_ad_request_fails_on_empty_configs() {
412 let mut mock = MockMARSClient::new();
413 mock.expect_get_context_id()
414 .return_const("mock-context-id".to_string());
415
416 let inner_component = MozAdsClientInner {
417 ads_cache: HashMap::new(),
418 client: Box::new(mock),
419 };
420
421 let configs: Vec<MozAdsPlacementConfig> = vec![];
422 let request = inner_component.build_request_from_placement_configs(&configs);
423
424 assert!(request.is_err());
425 }
426
427 #[test]
428 fn test_build_placements_happy() {
429 let mut mock = MockMARSClient::new();
430 mock.expect_get_context_id()
431 .return_const("mock-context-id".to_string());
432
433 let inner_component = MozAdsClientInner {
434 ads_cache: HashMap::new(),
435 client: Box::new(mock),
436 };
437
438 let placements = inner_component
439 .build_placements(
440 &get_example_happy_placement_config(),
441 get_example_happy_ad_response(),
442 )
443 .unwrap();
444
445 assert_eq!(placements, get_example_happy_placements());
446 }
447
448 #[test]
449 fn test_build_placements_with_empty_placement_in_response() {
450 let mut mock = MockMARSClient::new();
451 mock.expect_get_context_id()
452 .return_const("mock-context-id".to_string());
453
454 let inner_component = MozAdsClientInner {
455 ads_cache: HashMap::new(),
456 client: Box::new(mock),
457 };
458
459 let mut configs = get_example_happy_placement_config();
460 configs.push(MozAdsPlacementConfig {
462 placement_id: "example_placement_3".to_string(),
463 iab_content: Some(IABContent {
464 taxonomy: IABContentTaxonomy::IAB2_1,
465 category_ids: vec![],
466 }),
467 fixed_size: Some(MozAdsSize {
468 width: 200,
469 height: 200,
470 }),
471 });
472
473 let mut api_resp = get_example_happy_ad_response();
474 api_resp
475 .data
476 .insert("example_placement_3".to_string(), vec![]);
477
478 let placements = inner_component
479 .build_placements(&configs, api_resp)
480 .unwrap();
481
482 assert_eq!(placements, get_example_happy_placements());
483 }
484
485 #[test]
486 fn test_request_ads_with_missing_callback_in_response() {
487 let mut mock = MockMARSClient::new();
488 mock.expect_get_context_id()
489 .return_const("mock-context-id".to_string());
490
491 let inner_component = MozAdsClientInner {
492 ads_cache: HashMap::new(),
493 client: Box::new(mock),
494 };
495
496 let mut configs = get_example_happy_placement_config();
497 configs.push(MozAdsPlacementConfig {
499 placement_id: "example_placement_3".to_string(),
500 iab_content: Some(IABContent {
501 taxonomy: IABContentTaxonomy::IAB2_1,
502 category_ids: vec![],
503 }),
504 fixed_size: Some(MozAdsSize {
505 width: 200,
506 height: 200,
507 }),
508 });
509
510 let placements = inner_component
511 .build_placements(&configs, get_example_happy_ad_response())
512 .unwrap();
513
514 assert_eq!(placements, get_example_happy_placements());
515 }
516
517 #[test]
518 fn test_build_placements_fails_with_duplicate_placement() {
519 let mut mock = MockMARSClient::new();
520 mock.expect_get_context_id()
521 .return_const("mock-context-id".to_string());
522
523 let inner_component = MozAdsClientInner {
524 ads_cache: HashMap::new(),
525 client: Box::new(mock),
526 };
527
528 let mut configs = get_example_happy_placement_config();
529 configs.push(MozAdsPlacementConfig {
531 placement_id: "example_placement_2".to_string(),
532 iab_content: Some(IABContent {
533 taxonomy: IABContentTaxonomy::IAB2_1,
534 category_ids: vec![],
535 }),
536 fixed_size: Some(MozAdsSize {
537 width: 200,
538 height: 200,
539 }),
540 });
541
542 let mut api_resp = get_example_happy_ad_response();
543
544 api_resp
546 .data
547 .get_mut("example_placement_2")
548 .unwrap()
549 .push(MozAd {
550 url: Some("https://ads.fakeexample.org/example_ad_2_2".to_string()),
551 image_url: Some("https://ads.fakeexample.org/example_image_2_2".to_string()),
552 format: Some("skyscraper".to_string()),
553 block_key: None,
554 alt_text: Some("An ad for a pet dragon".to_string()),
555 callbacks: Some(AdCallbacks {
556 click: Some("https://ads.fakeexample.org/click/example_ad_2_2".to_string()),
557 impression: Some(
558 "https://ads.fakeexample.org/impression/example_ad_2_2".to_string(),
559 ),
560 report: Some("https://ads.fakeexample.org/report/example_ad_2_2".to_string()),
561 }),
562 });
563
564 let placements = inner_component.build_placements(&configs, api_resp);
565
566 assert!(placements.is_err());
567 }
568
569 #[test]
570 fn test_request_ads_happy() {
571 let mut mock = MockMARSClient::new();
572 mock.expect_fetch_ads()
573 .returning(|_req| Ok(get_example_happy_ad_response()));
574 mock.expect_get_context_id()
575 .return_const("mock-context-id".to_string());
576
577 mock.expect_get_mars_endpoint()
578 .return_const("https://mock.endpoint/ads".to_string());
579
580 let component = MozAdsClient {
581 inner: Mutex::new(MozAdsClientInner {
582 ads_cache: HashMap::new(),
583 client: Box::new(mock),
584 }),
585 };
586
587 let configs = get_example_happy_placement_config();
588
589 let result = component.request_ads(configs);
590
591 assert!(result.is_ok());
592 }
593
594 #[test]
595 fn test_cycle_context_id() {
596 let component = MozAdsClient::new();
597 let old_id = component.cycle_context_id().unwrap();
598 let new_id = component.cycle_context_id().unwrap();
599 assert_ne!(old_id, new_id);
600 }
601
602 #[test]
603 fn test_clear_cache_does_not_panic() {
604 let component = MozAdsClient::new();
605 assert!(component.clear_cache().is_ok());
606 }
607}