ads_client/
lib.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2* License, v. 2.0. If a copy of the MPL was not distributed with this
3* file, You can obtain one at http://mozilla.org/MPL/2.0/.
4*/
5
6use 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/// Top-level API for the mac component
30#[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>, //TODO: implement caching
104    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(); //TODO: HashMap is a placeholder.
112        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, // Placement_id should be treated as unique, so count is always 1
192                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        // Adding an extra placement config
461        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        // Adding an extra placement config
498        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        // Adding an extra placement config
530        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        // Adding an extra placement in response to match extra config
545        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}