ads_client/
models.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;
7
8use serde::{Deserialize, Deserializer, Serialize};
9
10fn empty_string_as_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
11where
12    D: Deserializer<'de>,
13{
14    let opt = Option::<String>::deserialize(deserializer)?;
15    Ok(match opt {
16        Some(s) if s.trim().is_empty() => None,
17        other => other,
18    })
19}
20
21#[derive(Debug, Serialize, Deserialize, PartialEq, uniffi::Record)]
22pub struct AdPlacementRequest {
23    pub placement: String,
24    pub count: u32,
25    pub content: Option<AdContentCategory>,
26}
27
28#[derive(Debug, Serialize, PartialEq, Deserialize, uniffi::Enum)]
29pub enum IABAdUnitFormat {
30    Billboard,
31    SmartphoneBanner300,
32    SmartphoneBanner320,
33    Leaderboard,
34    SuperLeaderboardPushdown,
35    Portrait,
36    Skyscraper,
37    MediumRectangle,
38    TwentyBySixty,
39    MobilePhoneInterstitial640,
40    MobilePhoneInterstitial750,
41    MobilePhoneInterstitial1080,
42    FeaturePhoneSmallBanner,
43    FeaturePhoneMediumBanner,
44    FeaturePhoneLargeBanner,
45}
46
47#[derive(Clone, Copy, Debug, Serialize, PartialEq, Deserialize, uniffi::Enum)]
48pub enum IABContentTaxonomy {
49    #[serde(rename = "IAB-1.0")]
50    IAB1_0,
51
52    #[serde(rename = "IAB-2.0")]
53    IAB2_0,
54
55    #[serde(rename = "IAB-2.1")]
56    IAB2_1,
57
58    #[serde(rename = "IAB-2.2")]
59    IAB2_2,
60
61    #[serde(rename = "IAB-3.0")]
62    IAB3_0,
63}
64
65#[derive(Debug, Serialize, Deserialize, PartialEq, uniffi::Record)]
66pub struct AdContentCategory {
67    pub taxonomy: IABContentTaxonomy,
68    pub categories: Vec<String>,
69}
70
71#[derive(Debug, Serialize, Deserialize, PartialEq, uniffi::Record)]
72pub struct AdRequest {
73    pub context_id: String,
74    pub placements: Vec<AdPlacementRequest>,
75}
76
77#[derive(Debug, PartialEq, Serialize, Deserialize, uniffi::Record)]
78pub struct AdCallbacks {
79    #[serde(default, deserialize_with = "empty_string_as_none")]
80    pub click: Option<String>,
81    #[serde(default, deserialize_with = "empty_string_as_none")]
82    pub impression: Option<String>,
83    #[serde(default, deserialize_with = "empty_string_as_none")]
84    pub report: Option<String>,
85}
86
87#[derive(Debug, PartialEq, Serialize, Deserialize, uniffi::Record)]
88pub struct MozAd {
89    pub alt_text: Option<String>,
90    pub block_key: Option<String>,
91    pub callbacks: Option<AdCallbacks>,
92    pub format: Option<String>,
93    pub image_url: Option<String>, //TODO: Consider if we want to load the image locally
94    pub url: Option<String>,
95}
96
97#[derive(Debug, PartialEq, Serialize, Deserialize, uniffi::Record)]
98pub struct AdResponse {
99    #[serde(flatten)]
100    pub data: HashMap<String, Vec<MozAd>>,
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use serde_json::{from_str, json, to_value};
107
108    #[test]
109    fn test_ad_placement_request_with_content_serialize() {
110        let request = AdPlacementRequest {
111            placement: "example_placement".into(),
112            count: 1,
113            content: Some(AdContentCategory {
114                taxonomy: IABContentTaxonomy::IAB2_1,
115                categories: vec!["Technology".into(), "Programming".into()],
116            }),
117        };
118
119        let serialized = to_value(&request).unwrap();
120
121        let expected_json = json!({
122            "placement": "example_placement",
123            "count": 1,
124            "content": {
125                "taxonomy": "IAB-2.1",
126                "categories": ["Technology", "Programming"]
127            }
128        });
129
130        assert_eq!(serialized, expected_json);
131    }
132
133    #[test]
134    fn test_iab_content_taxonomy_serialize() {
135        use serde_json::to_string;
136
137        // We expect that enums map to strings like "IAB-2.2"
138        let s = to_string(&IABContentTaxonomy::IAB1_0).unwrap();
139        assert_eq!(s, "\"IAB-1.0\"");
140
141        let s = to_string(&IABContentTaxonomy::IAB2_0).unwrap();
142        assert_eq!(s, "\"IAB-2.0\"");
143
144        let s = to_string(&IABContentTaxonomy::IAB2_1).unwrap();
145        assert_eq!(s, "\"IAB-2.1\"");
146
147        let s = to_string(&IABContentTaxonomy::IAB2_2).unwrap();
148        assert_eq!(s, "\"IAB-2.2\"");
149
150        let s = to_string(&IABContentTaxonomy::IAB3_0).unwrap();
151        assert_eq!(s, "\"IAB-3.0\"");
152    }
153
154    #[test]
155    fn test_ad_callbacks_empty_and_missing_to_none() {
156        // empty strings, whitespace, or missing fields should become None.
157        let j = json!({
158            "click": "",
159            "impression": "   ",
160            // "report" omitted
161        })
162        .to_string();
163
164        let got: AdCallbacks = from_str(&j).unwrap();
165        assert_eq!(
166            got,
167            AdCallbacks {
168                click: None,
169                impression: None,
170                report: None
171            }
172        );
173    }
174
175    #[test]
176    fn test_moz_ad_full() {
177        let response_full = json!({
178            "alt_text": "An ad for an anvil",
179            "block_key": "abc123",
180            "callbacks": {
181                "click": "https://buyanvilseveryday.test/click",
182                "impression": "https://buyanvilseveryday.test/impression",
183                "report": "https://buyanvilseveryday.test/report"
184            },
185            "format": "Leaderboard",
186            "image_url": "https://buyanvilseveryday.test/img.png",
187            "url": "https://buyanvilseveryday.test"
188        })
189        .to_string();
190
191        let full: MozAd = from_str(&response_full).unwrap();
192        assert_eq!(
193            full,
194            MozAd {
195                alt_text: Some("An ad for an anvil".into()),
196                block_key: Some("abc123".into()),
197                callbacks: Some(AdCallbacks {
198                    click: Some("https://buyanvilseveryday.test/click".into()),
199                    impression: Some("https://buyanvilseveryday.test/impression".into()),
200                    report: Some("https://buyanvilseveryday.test/report".into()),
201                }),
202                format: Some("Leaderboard".into()),
203                image_url: Some("https://buyanvilseveryday.test/img.png".into()),
204                url: Some("https://buyanvilseveryday.test".into()),
205            }
206        );
207    }
208
209    #[test]
210    fn test_moz_ad_response_partial() {
211        let response_partial = json!({
212            "alt_text": null,
213            "callbacks": {
214                "click": "",
215                "impression": "   ",
216                "report": null
217            }
218        })
219        .to_string();
220
221        let partial: MozAd = from_str(&response_partial).unwrap();
222        assert_eq!(
223            partial,
224            MozAd {
225                alt_text: None,
226                block_key: None,
227                callbacks: Some(AdCallbacks {
228                    click: None,
229                    impression: None,
230                    report: None,
231                }),
232                format: None,
233                image_url: None,
234                url: None,
235            }
236        );
237    }
238
239    #[test]
240    fn test_ad_response_serialization() {
241        let raw_ad_response = json!({
242            "example_placement_1": [
243                {
244                    "url": "https://ads.fakeexample.org/example_ad_1",
245                    "image_url": "https://ads.fakeexample.org/example_image_1",
246                    "format": "billboard",
247                    "alt_text": "An ad for a puppy",
248                    "callbacks": {
249                        "click": "https://ads.fakeexample.org/click/example_ad_1",
250                        // impression is intentionally missing
251                        "report": "https://ads.fakeexample.org/report/example_ad_1"
252                    }
253                }
254            ],
255            "example_placement_2": [
256                {
257                    "url": "https://ads.fakeexample.org/example_ad_2",
258                    "image_url": "https://ads.fakeexample.org/example_image_2",
259                    "format": "skyscraper",
260                    "alt_text": "An ad for a pet duck",
261                    "callbacks": {
262                        "click": "https://ads.fakeexample.org/click/example_ad_2",
263                        "impression": "https://ads.fakeexample.org/impression/example_ad_2",
264                        "report": "https://ads.fakeexample.org/report/example_ad_2"
265                    }
266                }
267            ]
268        })
269        .to_string();
270
271        let parsed: AdResponse = from_str(&raw_ad_response).unwrap();
272
273        let expected = AdResponse {
274            data: HashMap::from([
275                (
276                    "example_placement_1".to_string(),
277                    vec![MozAd {
278                        url: Some("https://ads.fakeexample.org/example_ad_1".to_string()),
279                        image_url: Some("https://ads.fakeexample.org/example_image_1".to_string()),
280                        format: Some("billboard".to_string()),
281                        block_key: None,
282                        alt_text: Some("An ad for a puppy".to_string()),
283                        callbacks: Some(AdCallbacks {
284                            click: Some(
285                                "https://ads.fakeexample.org/click/example_ad_1".to_string(),
286                            ),
287                            impression: None, // Missing impression callback URL
288                            report: Some(
289                                "https://ads.fakeexample.org/report/example_ad_1".to_string(),
290                            ),
291                        }),
292                    }],
293                ),
294                (
295                    "example_placement_2".to_string(),
296                    vec![MozAd {
297                        url: Some("https://ads.fakeexample.org/example_ad_2".to_string()),
298                        image_url: Some("https://ads.fakeexample.org/example_image_2".to_string()),
299                        format: Some("skyscraper".to_string()),
300                        block_key: None,
301                        alt_text: Some("An ad for a pet duck".to_string()),
302                        callbacks: Some(AdCallbacks {
303                            click: Some(
304                                "https://ads.fakeexample.org/click/example_ad_2".to_string(),
305                            ),
306                            impression: Some(
307                                "https://ads.fakeexample.org/impression/example_ad_2".to_string(),
308                            ),
309                            report: Some(
310                                "https://ads.fakeexample.org/report/example_ad_2".to_string(),
311                            ),
312                        }),
313                    }],
314                ),
315            ]),
316        };
317
318        assert_eq!(parsed, expected);
319    }
320
321    #[test]
322    fn test_empty_ad_response_serialization() {
323        let raw_ad_response = json!({
324            "example_placement_1": [],
325            "example_placement_2": []
326        })
327        .to_string();
328
329        let parsed: AdResponse = from_str(&raw_ad_response).unwrap();
330
331        let expected = AdResponse {
332            data: HashMap::from([
333                ("example_placement_1".to_string(), vec![]),
334                ("example_placement_2".to_string(), vec![]),
335            ]),
336        };
337
338        assert_eq!(parsed, expected);
339    }
340}