diff --git a/.github/workflows/ads-client-tests.yaml b/.github/workflows/ads-client-tests.yaml index b6bfb8fa64..78cb65a45f 100644 --- a/.github/workflows/ads-client-tests.yaml +++ b/.github/workflows/ads-client-tests.yaml @@ -24,5 +24,21 @@ jobs: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y source $HOME/.cargo/env rustup toolchain install + - name: Build NSS + env: + NSS_DIR: ${{ github.workspace }}/libs/desktop/linux-x86-64/nss + NSS_STATIC: 1 + run: | + sudo apt-get update + sudo apt-get install -y ninja-build zlib1g-dev tclsh python3 + python3 -m venv venv + source venv/bin/activate + python3 -m pip install --upgrade pip setuptools six + git clone https://chromium.googlesource.com/external/gyp.git tools/gyp + cd tools/gyp && pip install . && cd ../.. + ./libs/verify-desktop-environment.sh - name: Run ads-client integration tests against MARS staging + env: + NSS_DIR: ${{ github.workspace }}/libs/desktop/linux-x86-64/nss + NSS_STATIC: 1 run: cargo test -p ads-client-integration-tests -- --ignored diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c8b8e2eee..ccceb446a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ ## ✨ What's New ✨ +### Ads Client +- Add per-request OHTTP support via the `ohttp` flag on `MozAdsRequestOptions` and `MozAdsCallbackOptions`. When enabled, individual MARS requests are routed through viaduct's OHTTP layer with automatic preflight for geo/user-agent override headers. + ### Logins - New `allow_empty_passwords` feature flag to allow storing logins with empty passwords. This feature is intended to be enabled on desktop during the migration. - Add `ignore_form_action_origin_validation_errors` feature flag that allows logins with non-URL `form_action_origin` values (e.g. "email", "UserCode") to be imported without error. URL normalization for valid URLs is still applied. diff --git a/components/ads-client/Cargo.toml b/components/ads-client/Cargo.toml index 67d93c1ed5..87390f52e6 100644 --- a/components/ads-client/Cargo.toml +++ b/components/ads-client/Cargo.toml @@ -27,7 +27,7 @@ once_cell = "1.5" uniffi = { version = "0.31" } url = { version = "2", features = ["serde"] } uuid = { version = "1.3", features = ["v4"] } -viaduct = { path = "../viaduct" } +viaduct = { path = "../viaduct", features = ["ohttp"] } sql-support = { path = "../support/sql" } [dev-dependencies] diff --git a/components/ads-client/docs/usage-javascript.md b/components/ads-client/docs/usage-javascript.md index af36008d0c..aacca4b0c9 100644 --- a/components/ads-client/docs/usage-javascript.md +++ b/components/ads-client/docs/usage-javascript.md @@ -37,9 +37,9 @@ const client = MozAdsClientBuilder() | Method | Return Type | Description | | ----------------------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `clearCache()` | `void` | Clears the client's HTTP cache. Throws on failure. | -| `recordClick(clickUrl)` | `void` | Records a click using the provided callback URL (typically from `ad.callbacks.click`). | -| `recordImpression(impressionUrl)` | `void` | Records an impression using the provided callback URL (typically from `ad.callbacks.impression`). | -| `reportAd(reportUrl)` | `void` | Reports an ad using the provided callback URL (typically from `ad.callbacks.report`). | +| `recordClick(clickUrl, options?)` | `void` | Records a click using the provided callback URL (typically from `ad.callbacks.click`). Optional `MozAdsCallbackOptions` can enable OHTTP. | +| `recordImpression(impressionUrl, options?)` | `void` | Records an impression using the provided callback URL (typically from `ad.callbacks.impression`). Optional `MozAdsCallbackOptions` can enable OHTTP. | +| `reportAd(reportUrl, reason, options?)` | `void` | Reports an ad using the provided callback URL (typically from `ad.callbacks.report`). Optional `MozAdsCallbackOptions` can enable OHTTP. | | `requestImageAds(mozAdRequests, options?)` | `Object.` | Requests one image ad per placement. Optional `MozAdsRequestOptions` can adjust caching behavior. Returns an object keyed by `placementId`. | | `requestSpocAds(mozAdRequests, options?)` | `Object.>` | Requests spoc ads per placement. Each placement request specifies its own count. Optional `MozAdsRequestOptions` can adjust caching behavior. Returns an object keyed by `placementId`. | | `requestTileAds(mozAdRequests, options?)` | `Object.` | Requests one tile ad per placement. Optional `MozAdsRequestOptions` can adjust caching behavior. Returns an object keyed by `placementId`. | @@ -246,12 +246,48 @@ Options passed when making a single ad request. /** * @typedef {Object} MozAdsRequestOptions * @property {MozAdsRequestCachePolicy|null} cachePolicy - Per-request caching policy. + * @property {boolean} ohttp - Whether to route this request through OHTTP (default: false). */ ``` | Field | Type | Description | | -------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------- | | `cachePolicy` | `MozAdsRequestCachePolicy \| null` | Per-request caching policy. If `null`, uses the client's default TTL with a `CacheFirst` mode. | +| `ohttp` | `boolean` | Whether to route this request through OHTTP. Defaults to `false`. | + +--- + +## `MozAdsCallbackOptions` + +Options passed when making callback requests (click, impression, report). + +```javascript +/** + * @typedef {Object} MozAdsCallbackOptions + * @property {boolean} ohttp - Whether to route this callback through OHTTP (default: false). + */ +``` + +| Field | Type | Description | +| ------- | --------- | ------------------------------------------------------------------ | +| `ohttp` | `boolean` | Whether to route this callback through OHTTP. Defaults to `false`. | + +#### OHTTP Usage Example + +```javascript +// Request ads over OHTTP +const ads = client.requestTileAds(placements, { + ohttp: true +}); + +// Record a click over OHTTP +client.recordClick(ad.callbacks.click, { ohttp: true }); + +// Record an impression over OHTTP +client.recordImpression(ad.callbacks.impression, { ohttp: true }); +``` + +> **Note:** OHTTP must be configured at the viaduct level before use. When `ohttp` is `true`, the client automatically performs a preflight request to obtain geo-location and user-agent headers, which are injected into the MARS request. --- diff --git a/components/ads-client/docs/usage-kotlin.md b/components/ads-client/docs/usage-kotlin.md index 4ebaa7b6d4..9db60c6410 100644 --- a/components/ads-client/docs/usage-kotlin.md +++ b/components/ads-client/docs/usage-kotlin.md @@ -34,9 +34,9 @@ val client = MozAdsClientBuilder() | Method | Return Type | Description | | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `clearCache()` | `Unit` | Clears the client's HTTP cache. Throws on failure. | -| `recordClick(clickUrl: String)` | `Unit` | Records a click using the provided callback URL (typically from `ad.callbacks.click`). | -| `recordImpression(impressionUrl: String)` | `Unit` | Records an impression using the provided callback URL (typically from `ad.callbacks.impression`). | -| `reportAd(reportUrl: String)` | `Unit` | Reports an ad using the provided callback URL (typically from `ad.callbacks.report`). | +| `recordClick(clickUrl: String, options: MozAdsCallbackOptions?)` | `Unit` | Records a click using the provided callback URL (typically from `ad.callbacks.click`). | +| `recordImpression(impressionUrl: String, options: MozAdsCallbackOptions?)` | `Unit` | Records an impression using the provided callback URL (typically from `ad.callbacks.impression`). | +| `reportAd(reportUrl: String, reason: MozAdsReportReason, options: MozAdsCallbackOptions?)` | `Unit` | Reports an ad using the provided callback URL (typically from `ad.callbacks.report`). | | `requestImageAds(mozAdRequests: List, options: MozAdsRequestOptions?)` | `Map` | Requests one image ad per placement. Optional `MozAdsRequestOptions` can adjust caching behavior. Returns a map keyed by `placementId`. | | `requestSpocAds(mozAdRequests: List, options: MozAdsRequestOptions?)` | `Map>` | Requests spoc ads per placement. Each placement request specifies its own count. Optional `MozAdsRequestOptions` can adjust caching behavior. Returns a map keyed by `placementId`. | | `requestTileAds(mozAdRequests: List, options: MozAdsRequestOptions?)` | `Map` | Requests one tile ad per placement. Optional `MozAdsRequestOptions` can adjust caching behavior. Returns a map keyed by `placementId`. | @@ -219,13 +219,46 @@ Options passed when making a single ad request. ```kotlin data class MozAdsRequestOptions( - val cachePolicy: MozAdsRequestCachePolicy? + val cachePolicy: MozAdsRequestCachePolicy?, + val ohttp: Boolean = false ) ``` | Field | Type | Description | | -------------- | ---------------------------- | ---------------------------------------------------------------------------------------------- | | `cachePolicy` | `MozAdsRequestCachePolicy?` | Per-request caching policy. If `null`, uses the client's default TTL with a `CacheFirst` mode. | +| `ohttp` | `Boolean` | Whether to route this request through OHTTP. Defaults to `false`. | + +--- + +## `MozAdsCallbackOptions` + +Options passed when making callback requests (click, impression, report). + +```kotlin +data class MozAdsCallbackOptions( + val ohttp: Boolean = false +) +``` + +| Field | Type | Description | +| ------- | --------- | ------------------------------------------------------------------ | +| `ohttp` | `Boolean` | Whether to route this callback through OHTTP. Defaults to `false`. | + +#### OHTTP Usage Example + +```kotlin +// Request ads over OHTTP +val ads = client.requestTileAds(placements, MozAdsRequestOptions(ohttp = true)) + +// Record a click over OHTTP +client.recordClick(ad.callbacks.click, MozAdsCallbackOptions(ohttp = true)) + +// Record an impression over OHTTP +client.recordImpression(ad.callbacks.impression, MozAdsCallbackOptions(ohttp = true)) +``` + +> **Note:** OHTTP must be configured at the viaduct level before use. When `ohttp` is `true`, the client automatically performs a preflight request to obtain geo-location and user-agent headers, which are injected into the MARS request. --- diff --git a/components/ads-client/docs/usage-swift.md b/components/ads-client/docs/usage-swift.md index 840673c5b1..d3ac5741dc 100644 --- a/components/ads-client/docs/usage-swift.md +++ b/components/ads-client/docs/usage-swift.md @@ -34,9 +34,9 @@ let client = MozAdsClientBuilder() | Method | Return Type | Description | | ---------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `clearCache()` | `Void` | Clears the client's HTTP cache. Throws on failure. | -| `recordClick(clickUrl: String)` | `Void` | Records a click using the provided callback URL (typically from `ad.callbacks.click`). | -| `recordImpression(impressionUrl: String)` | `Void` | Records an impression using the provided callback URL (typically from `ad.callbacks.impression`). | -| `reportAd(reportUrl: String)` | `Void` | Reports an ad using the provided callback URL (typically from `ad.callbacks.report`). | +| `recordClick(clickUrl: String, options: MozAdsCallbackOptions?)` | `Void` | Records a click using the provided callback URL (typically from `ad.callbacks.click`). | +| `recordImpression(impressionUrl: String, options: MozAdsCallbackOptions?)` | `Void` | Records an impression using the provided callback URL (typically from `ad.callbacks.impression`). | +| `reportAd(reportUrl: String, reason: MozAdsReportReason, options: MozAdsCallbackOptions?)` | `Void` | Reports an ad using the provided callback URL (typically from `ad.callbacks.report`). | | `requestImageAds(mozAdRequests: [MozAdsPlacementRequest], options: MozAdsRequestOptions?)` | `[String: MozAdsImage]` | Requests one image ad per placement. Optional `MozAdsRequestOptions` can adjust caching behavior. Returns a dictionary keyed by `placementId`. | | `requestSpocAds(mozAdRequests: [MozAdsPlacementRequestWithCount], options: MozAdsRequestOptions?)` | `[String: [MozAdsSpoc]]` | Requests spoc ads per placement. Each placement request specifies its own count. Optional `MozAdsRequestOptions` can adjust caching behavior. Returns a dictionary keyed by `placementId`. | | `requestTileAds(mozAdRequests: [MozAdsPlacementRequest], options: MozAdsRequestOptions?)` | `[String: MozAdsTile]` | Requests one tile ad per placement. Optional `MozAdsRequestOptions` can adjust caching behavior. Returns a dictionary keyed by `placementId`. | @@ -220,12 +220,45 @@ Options passed when making a single ad request. ```swift struct MozAdsRequestOptions { let cachePolicy: MozAdsRequestCachePolicy? + let ohttp: Bool // default: false } ``` | Field | Type | Description | | -------------- | ----------------------------- | --------------------------------------------------------------------------------------------- | | `cachePolicy` | `MozAdsRequestCachePolicy?` | Per-request caching policy. If `nil`, uses the client's default TTL with a `cacheFirst` mode. | +| `ohttp` | `Bool` | Whether to route this request through OHTTP. Defaults to `false`. | + +--- + +## `MozAdsCallbackOptions` + +Options passed when making callback requests (click, impression, report). + +```swift +struct MozAdsCallbackOptions { + let ohttp: Bool // default: false +} +``` + +| Field | Type | Description | +| ------- | ------ | ------------------------------------------------------------------ | +| `ohttp` | `Bool` | Whether to route this callback through OHTTP. Defaults to `false`. | + +#### OHTTP Usage Example + +```swift +// Request ads over OHTTP +let ads = try client.requestTileAds(mozAdRequests: placements, options: MozAdsRequestOptions(ohttp: true)) + +// Record a click over OHTTP +try client.recordClick(clickUrl: ad.callbacks.click, options: MozAdsCallbackOptions(ohttp: true)) + +// Record an impression over OHTTP +try client.recordImpression(impressionUrl: ad.callbacks.impression, options: MozAdsCallbackOptions(ohttp: true)) +``` + +> **Note:** OHTTP must be configured at the viaduct level before use. When `ohttp` is `true`, the client automatically performs a preflight request to obtain geo-location and user-agent headers, which are injected into the MARS request. --- diff --git a/components/ads-client/integration-tests/Cargo.toml b/components/ads-client/integration-tests/Cargo.toml index d78ce2c8e1..613dd64772 100644 --- a/components/ads-client/integration-tests/Cargo.toml +++ b/components/ads-client/integration-tests/Cargo.toml @@ -12,6 +12,6 @@ ads-client = { path = ".." } serde_json = "1" url = "2" mockito = { version = "0.31", default-features = false } -viaduct = { path = "../../../components/viaduct" } +viaduct = { path = "../../../components/viaduct", features = ["ohttp"] } viaduct-dev = { path = "../../../components/support/viaduct-dev" } viaduct-hyper = { path = "../../../components/support/viaduct-hyper" } diff --git a/components/ads-client/integration-tests/tests/http_cache.rs b/components/ads-client/integration-tests/tests/http_cache.rs index 5b0a2b2024..8651c07712 100644 --- a/components/ads-client/integration-tests/tests/http_cache.rs +++ b/components/ads-client/integration-tests/tests/http_cache.rs @@ -32,7 +32,7 @@ impl From for Request { fn test_cache_works_using_real_timeouts() { viaduct_dev::init_backend_dev(); - let cache = HttpCache::::builder("integration_tests.db") + let cache = HttpCache::builder("integration_tests.db") .default_ttl(Duration::from_secs(60)) .max_size(ByteSize::mib(1)) .build() diff --git a/components/ads-client/integration-tests/tests/mars.rs b/components/ads-client/integration-tests/tests/mars.rs index 734a9b32b1..7abf83282b 100644 --- a/components/ads-client/integration-tests/tests/mars.rs +++ b/components/ads-client/integration-tests/tests/mars.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use ads_client::{ MozAdsClientBuilder, MozAdsEnvironment, MozAdsPlacementRequest, - MozAdsPlacementRequestWithCount, MozAdsReportReason, + MozAdsPlacementRequestWithCount, MozAdsReportReason, MozAdsRequestOptions, }; fn init_backend() { @@ -104,7 +104,7 @@ fn test_record_impression() { .get("mock_tile_1") .expect("mock_tile_1 placement should be present"); - let result = client.record_impression(ad.callbacks.impression.to_string()); + let result = client.record_impression(ad.callbacks.impression.to_string(), None); assert!( result.is_ok(), "record_impression failed: {:?}", @@ -132,7 +132,7 @@ fn test_record_click() { .get("mock_tile_1") .expect("mock_tile_1 placement should be present"); - let result = client.record_click(ad.callbacks.click.to_string()); + let result = client.record_click(ad.callbacks.click.to_string(), None); assert!(result.is_ok(), "record_click failed: {:?}", result.err()); } @@ -168,6 +168,43 @@ fn test_report_ad() { assert_eq!(placement_id_count, 1, "expected exactly one placement_id"); assert_eq!(position_count, 1, "expected exactly one position"); - let result = client.report_ad(report_url.to_string(), MozAdsReportReason::NotInterested); + let result = client.report_ad( + report_url.to_string(), + MozAdsReportReason::NotInterested, + None, + ); assert!(result.is_ok(), "report_ad failed: {:?}", result.err()); } + +#[test] +#[ignore = "integration test: run manually with -- --ignored"] +fn test_contract_tile_ohttp_prod() { + init_backend(); + viaduct::ohttp::configure_ohttp_channel( + "ads-client".to_string(), + viaduct::ohttp::OhttpConfig { + relay_url: "https://mozilla-ohttp.fastly-edge.com/".to_string(), + gateway_host: "prod.ohttp-gateway.prod.webservices.mozgcp.net".to_string(), + }, + ) + .expect("OHTTP channel configuration should succeed"); + + let client = prod_client(); + + let placements = client + .request_tile_ads( + vec![MozAdsPlacementRequest { + iab_content: None, + placement_id: "mock_tile_1".to_string(), + }], + Some(MozAdsRequestOptions { + ohttp: true, + ..Default::default() + }), + ) + .expect("tile ad request over OHTTP should succeed"); + assert!( + placements.contains_key("mock_tile_1"), + "OHTTP response should contain mock_tile_1" + ); +} diff --git a/components/ads-client/src/client.rs b/components/ads-client/src/client.rs index 0756b9c6fd..b1679cd12b 100644 --- a/components/ads-client/src/client.rs +++ b/components/ads-client/src/client.rs @@ -6,39 +6,20 @@ use std::collections::HashMap; use std::time::Duration; -use crate::client::ad_response::{AdImage, AdResponse, AdResponseValue, AdSpoc, AdTile}; -use crate::client::config::{AdsClientConfig, Environment}; -use crate::error::{RecordClickError, RecordImpressionError, ReportAdError, RequestAdsError}; - -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum ReportReason { - Inappropriate, - NotInterested, - SeenTooManyTimes, -} - -impl ReportReason { - pub fn as_str(&self) -> &'static str { - match self { - ReportReason::Inappropriate => "inappropriate", - ReportReason::NotInterested => "not_interested", - ReportReason::SeenTooManyTimes => "seen_too_many_times", - } - } -} -use crate::http_cache::{CachePolicy, HttpCache}; -use crate::mars::MARSClient; +use crate::http_cache::{ByteSize, CachePolicy, HttpCache, HttpCacheError}; +use crate::mars::ad_request::AdPlacementRequest; +use crate::mars::ad_response::{AdImage, AdResponse, AdResponseValue, AdSpoc, AdTile}; +use crate::mars::error::{RecordClickError, RecordImpressionError, ReportAdError}; +use crate::mars::{MARSClient, ReportReason}; use crate::telemetry::Telemetry; -use ad_request::{AdPlacementRequest, AdRequest}; +use config::AdsClientConfig; use context_id::{ContextIDComponent, DefaultContextIdCallback}; +use error::RequestAdsError; use url::Url; use uuid::Uuid; -use crate::http_cache::{ByteSize, HttpCacheError}; - -pub mod ad_request; -pub mod ad_response; pub mod config; +pub mod error; const DEFAULT_TTL_SECONDS: u64 = 300; const DEFAULT_MAX_CACHE_SIZE_MIB: u64 = 10; @@ -50,7 +31,6 @@ where { client: MARSClient, context_id_component: ContextIDComponent, - environment: Environment, rotation_days: u8, telemetry: T, } @@ -71,9 +51,7 @@ where let environment = client_config.environment; let rotation_days = client_config.rotation_days.unwrap_or(DEFAULT_ROTATION_DAYS); - // Configure the cache if a path is provided. - // Defaults for ttl and cache size are also set if unspecified. - if let Some(cache_cfg) = client_config.cache_config { + let http_cache = client_config.cache_config.and_then(|cache_cfg| { let default_cache_ttl = Duration::from_secs( cache_cfg .default_cache_ttl_seconds @@ -82,7 +60,7 @@ where let max_cache_size = ByteSize::mib(cache_cfg.max_size_mib.unwrap_or(DEFAULT_MAX_CACHE_SIZE_MIB)); - let http_cache = match HttpCache::builder(cache_cfg.db_path) + match HttpCache::builder(cache_cfg.db_path) .max_size(max_cache_size) .default_ttl(default_cache_ttl) .build() @@ -92,30 +70,17 @@ where telemetry.record(&e); None } - }; - - let client = MARSClient::new(http_cache, telemetry.clone()); - let client = Self { - client, - context_id_component, - environment, - rotation_days, - telemetry: telemetry.clone(), - }; - telemetry.record(&ClientOperationEvent::New); - return client; - } + } + }); - let client = MARSClient::new(None, telemetry.clone()); - let client = Self { + let client = MARSClient::new(environment, http_cache, telemetry.clone()); + telemetry.record(&ClientOperationEvent::New); + Self { client, context_id_component, - environment, rotation_days, telemetry: telemetry.clone(), - }; - telemetry.record(&ClientOperationEvent::New); - client + } } pub fn clear_cache(&self) -> Result<(), HttpCacheError> { @@ -126,7 +91,7 @@ where self.context_id_component.request(self.rotation_days) } - pub fn record_click(&self, click_url: Url) -> Result<(), RecordClickError> { + pub fn record_click(&self, click_url: Url, ohttp: bool) -> Result<(), RecordClickError> { // TODO: Re-enable cache invalidation behind a Nimbus experiment. // The mobile team has requested this be temporarily disabled. // let mut click_url = click_url.clone(); @@ -134,7 +99,7 @@ where // let _ = self.client.invalidate_cache_by_hash(&request_hash); // } self.client - .record_click(click_url) + .record_click(click_url, ohttp) .inspect_err(|e| { self.telemetry.record(e); }) @@ -143,7 +108,11 @@ where }) } - pub fn record_impression(&self, impression_url: Url) -> Result<(), RecordImpressionError> { + pub fn record_impression( + &self, + impression_url: Url, + ohttp: bool, + ) -> Result<(), RecordImpressionError> { // TODO: Re-enable cache invalidation behind a Nimbus experiment. // The mobile team has requested this be temporarily disabled. // let mut impression_url = impression_url.clone(); @@ -151,7 +120,7 @@ where // let _ = self.client.invalidate_cache_by_hash(&request_hash); // } self.client - .record_impression(impression_url) + .record_impression(impression_url, ohttp) .inspect_err(|e| { self.telemetry.record(e); }) @@ -161,9 +130,14 @@ where }) } - pub fn report_ad(&self, report_url: Url, reason: ReportReason) -> Result<(), ReportAdError> { + pub fn report_ad( + &self, + report_url: Url, + reason: ReportReason, + ohttp: bool, + ) -> Result<(), ReportAdError> { self.client - .report_ad(report_url, reason) + .report_ad(report_url, reason, ohttp) .inspect_err(|e| { self.telemetry.record(e); }) @@ -176,9 +150,10 @@ where &self, ad_placement_requests: Vec, options: Option, + ohttp: bool, ) -> Result, RequestAdsError> { let response = self - .request_ads::(ad_placement_requests, options) + .request_ads::(ad_placement_requests, options, ohttp) .inspect_err(|e| { self.telemetry.record(e); })?; @@ -190,8 +165,9 @@ where &self, ad_placement_requests: Vec, options: Option, + ohttp: bool, ) -> Result>, RequestAdsError> { - let result = self.request_ads::(ad_placement_requests, options); + let result = self.request_ads::(ad_placement_requests, options, ohttp); result .inspect_err(|e| { self.telemetry.record(e); @@ -206,8 +182,9 @@ where &self, ad_placement_requests: Vec, options: Option, + ohttp: bool, ) -> Result, RequestAdsError> { - let result = self.request_ads::(ad_placement_requests, options); + let result = self.request_ads::(ad_placement_requests, options, ohttp); result .inspect_err(|e| { self.telemetry.record(e); @@ -220,19 +197,19 @@ where fn request_ads( &self, - ad_placement_requests: Vec, + placements: Vec, options: Option, + ohttp: bool, ) -> Result, RequestAdsError> where A: AdResponseValue, { let context_id = self.get_context_id()?; - let url = self.environment.into_url("ads"); - let ad_request = AdRequest::try_new(context_id, ad_placement_requests, url)?; let cache_policy = options.unwrap_or_default(); - let (mut response, request_hash) = self.client.fetch_ads::(ad_request, cache_policy)?; - response.add_request_hash_to_callbacks(&request_hash); - response.add_placement_info_to_report_callbacks(); + let (mut response, request_hash) = + self.client + .fetch_ads::(context_id, placements, cache_policy, ohttp)?; + response.enrich_callbacks(&request_hash); Ok(response) } } @@ -250,6 +227,7 @@ pub enum ClientOperationEvent { mod tests { use crate::{ ffi::telemetry::MozAdsTelemetryWrapper, + mars::Environment, test_utils::{ get_example_happy_image_response, get_example_happy_spoc_response, get_example_happy_uatile_response, make_happy_placement_requests, @@ -270,7 +248,6 @@ mod tests { AdsClient { client, context_id_component, - environment: Environment::Test, rotation_days: DEFAULT_ROTATION_DAYS, telemetry: MozAdsTelemetryWrapper::noop(), } @@ -300,14 +277,10 @@ mod tests { .with_body(serde_json::to_string(&expected_response.data).unwrap()) .create(); - let telemetry = MozAdsTelemetryWrapper::noop(); - let mars_client = MARSClient::new(None, telemetry); + let mars_client = MARSClient::new(Environment::Test, None, MozAdsTelemetryWrapper::noop()); let ads_client = new_with_mars_client(mars_client); - let ad_placement_requests = make_happy_placement_requests(); - - let result = ads_client.request_image_ads(ad_placement_requests, None); - + let result = ads_client.request_image_ads(make_happy_placement_requests(), None, false); assert!(result.is_ok()); } @@ -322,14 +295,10 @@ mod tests { .with_body(serde_json::to_string(&expected_response.data).unwrap()) .create(); - let telemetry = MozAdsTelemetryWrapper::noop(); - let mars_client = MARSClient::new(None, telemetry); + let mars_client = MARSClient::new(Environment::Test, None, MozAdsTelemetryWrapper::noop()); let ads_client = new_with_mars_client(mars_client); - let ad_placement_requests = make_happy_placement_requests(); - - let result = ads_client.request_spoc_ads(ad_placement_requests, None); - + let result = ads_client.request_spoc_ads(make_happy_placement_requests(), None, false); assert!(result.is_ok()); } @@ -344,14 +313,10 @@ mod tests { .with_body(serde_json::to_string(&expected_response.data).unwrap()) .create(); - let telemetry = MozAdsTelemetryWrapper::noop(); - let mars_client = MARSClient::new(None, telemetry.clone()); + let mars_client = MARSClient::new(Environment::Test, None, MozAdsTelemetryWrapper::noop()); let ads_client = new_with_mars_client(mars_client); - let ad_placement_requests = make_happy_placement_requests(); - - let result = ads_client.request_tile_ads(ad_placement_requests, None); - + let result = ads_client.request_tile_ads(make_happy_placement_requests(), None, false); assert!(result.is_ok()); } @@ -362,8 +327,11 @@ mod tests { let cache = HttpCache::builder("test_record_click_invalidates_cache") .build() .unwrap(); - let telemetry = MozAdsTelemetryWrapper::noop(); - let mars_client = MARSClient::new(Some(cache), telemetry.clone()); + let mars_client = MARSClient::new( + Environment::Test, + Some(cache), + MozAdsTelemetryWrapper::noop(), + ); let ads_client = new_with_mars_client(mars_client); let response = get_example_happy_image_response(); @@ -372,11 +340,11 @@ mod tests { .with_status(200) .with_header("content-type", "application/json") .with_body(serde_json::to_string(&response.data).unwrap()) - .expect(2) // we expect 2 requests to the server, one for the initial ad request and one after for the cache invalidation request + .expect(2) .create(); let response = ads_client - .request_image_ads(make_happy_placement_requests(), None) + .request_image_ads(make_happy_placement_requests(), None, false) .unwrap(); let callback_url = response.values().next().unwrap().callbacks.click.clone(); @@ -384,17 +352,17 @@ mod tests { .with_status(200) .create(); - // Doing another request should hit the cache ads_client - .request_image_ads(make_happy_placement_requests(), None) + .request_image_ads(make_happy_placement_requests(), None, false) .unwrap(); - ads_client.record_click(callback_url).unwrap(); + ads_client.record_click(callback_url, false).unwrap(); ads_client .request_ads::( make_happy_placement_requests(), Some(CachePolicy::default()), + false, ) .unwrap(); } diff --git a/components/ads-client/src/client/config.rs b/components/ads-client/src/client/config.rs index 8582ff864e..e0aa892a22 100644 --- a/components/ads-client/src/client/config.rs +++ b/components/ads-client/src/client/config.rs @@ -3,17 +3,9 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -use once_cell::sync::Lazy; -use url::Url; - +use crate::mars::Environment; use crate::telemetry::Telemetry; -static MARS_API_ENDPOINT_PROD: Lazy = - Lazy::new(|| Url::parse("https://ads.mozilla.org/v1/").expect("hardcoded URL must be valid")); - -static MARS_API_ENDPOINT_STAGING: Lazy = - Lazy::new(|| Url::parse("https://ads.allizom.org/v1/").expect("hardcoded URL must be valid")); - pub struct AdsClientConfig where T: Telemetry, @@ -24,58 +16,9 @@ where pub telemetry: T, } -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub enum Environment { - #[default] - Prod, - Staging, - #[cfg(test)] - Test, -} - -impl Environment { - pub fn into_url(self, path: &str) -> Url { - let mut base = self.base_url(); - // Ensure the path has a trailing slash so that `join` appends - // rather than replacing the last segment. - if !base.path().ends_with('/') { - base.set_path(&format!("{}/", base.path())); - } - base.join(path) - .expect("joining a path to a valid base URL must succeed") - } - - fn base_url(self) -> Url { - match self { - Environment::Prod => MARS_API_ENDPOINT_PROD.clone(), - Environment::Staging => MARS_API_ENDPOINT_STAGING.clone(), - #[cfg(test)] - Environment::Test => Url::parse(&mockito::server_url()).unwrap(), - } - } -} - #[derive(Clone, Debug)] pub struct AdsCacheConfig { pub db_path: String, pub default_cache_ttl_seconds: Option, pub max_size_mib: Option, } - -#[cfg(test)] -mod tests { - use url::Host; - - use super::*; - - #[test] - fn prod_endpoint_parses_and_is_expected() { - let url = Environment::Prod.into_url("ads"); - - assert_eq!(url.as_str(), "https://ads.mozilla.org/v1/ads"); - - assert_eq!(url.scheme(), "https"); - assert_eq!(url.host(), Some(Host::Domain("ads.mozilla.org"))); - assert_eq!(url.path(), "/v1/ads"); - } -} diff --git a/components/ads-client/src/client/error.rs b/components/ads-client/src/client/error.rs new file mode 100644 index 0000000000..2542939493 --- /dev/null +++ b/components/ads-client/src/client/error.rs @@ -0,0 +1,30 @@ +/* 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::mars::error::{FetchAdsError, RecordClickError, RecordImpressionError, ReportAdError}; + +#[derive(Debug, thiserror::Error)] +pub enum ComponentError { + #[error("Error recording a click for a placement: {0}")] + RecordClick(#[from] RecordClickError), + + #[error("Error recording an impressions for a placement: {0}")] + RecordImpression(#[from] RecordImpressionError), + + #[error("Error reporting an ad: {0}")] + ReportAd(#[from] ReportAdError), + + #[error("Error requesting ads: {0}")] + RequestAds(#[from] RequestAdsError), +} + +#[derive(Debug, thiserror::Error)] +pub enum RequestAdsError { + #[error(transparent)] + ContextId(#[from] context_id::ApiError), + + #[error("Error requesting ads from MARS: {0}")] + FetchAds(#[from] FetchAdsError), +} diff --git a/components/ads-client/src/ffi.rs b/components/ads-client/src/ffi.rs index 33fb45f1d2..7a1f0f3b23 100644 --- a/components/ads-client/src/ffi.rs +++ b/components/ads-client/src/ffi.rs @@ -3,56 +3,39 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +pub mod error; pub mod telemetry; use std::sync::Arc; -use crate::client::ad_request::{AdContentCategory, AdPlacementRequest, IABContentTaxonomy}; -use crate::client::ad_response::{ - AdCallbacks, AdImage, AdSpoc, AdTile, SpocFrequencyCaps, SpocRanking, -}; -use crate::client::config::{AdsCacheConfig, AdsClientConfig, Environment}; +use crate::client::config::{AdsCacheConfig, AdsClientConfig}; use crate::client::AdsClient; -use crate::client::ReportReason; -use crate::error::ComponentError; use crate::ffi::telemetry::MozAdsTelemetryWrapper; use crate::http_cache::CachePolicy; +use crate::mars::ad_request::{AdContentCategory, AdPlacementRequest, IABContentTaxonomy}; +use crate::mars::ad_response::{ + AdCallbacks, AdImage, AdSpoc, AdTile, SpocFrequencyCaps, SpocRanking, +}; +use crate::mars::Environment; +use crate::mars::ReportReason; use crate::MozAdsClient; -use error_support::{ErrorHandling, GetErrorHandling}; use parking_lot::Mutex; use url::Url; -pub type AdsClientApiResult = std::result::Result; - +pub use error::{AdsClientApiResult, MozAdsClientApiError}; pub use telemetry::MozAdsTelemetry; -#[derive(Debug, thiserror::Error, uniffi::Error)] -pub enum MozAdsClientApiError { - #[error("Something unexpected occurred.")] - Other { reason: String }, -} - -impl From for MozAdsClientApiError { - fn from(err: context_id::ApiError) -> Self { - MozAdsClientApiError::Other { - reason: err.to_string(), - } - } -} - -impl GetErrorHandling for ComponentError { - type ExternalError = MozAdsClientApiError; - - fn get_error_handling(&self) -> ErrorHandling { - ErrorHandling::convert(MozAdsClientApiError::Other { - reason: self.to_string(), - }) - } -} - #[derive(Default, uniffi::Record)] pub struct MozAdsRequestOptions { pub cache_policy: Option, + #[uniffi(default = false)] + pub ohttp: bool, +} + +#[derive(Default, uniffi::Record)] +pub struct MozAdsCallbackOptions { + #[uniffi(default = false)] + pub ohttp: bool, } #[derive(Clone, Debug, PartialEq, uniffi::Record)] diff --git a/components/ads-client/src/ffi/error.rs b/components/ads-client/src/ffi/error.rs new file mode 100644 index 0000000000..e2da7471c2 --- /dev/null +++ b/components/ads-client/src/ffi/error.rs @@ -0,0 +1,33 @@ +/* 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::client::error::ComponentError; +use error_support::{ErrorHandling, GetErrorHandling}; + +pub type AdsClientApiResult = std::result::Result; + +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum MozAdsClientApiError { + #[error("Something unexpected occurred.")] + Other { reason: String }, +} + +impl From for MozAdsClientApiError { + fn from(err: context_id::ApiError) -> Self { + MozAdsClientApiError::Other { + reason: err.to_string(), + } + } +} + +impl GetErrorHandling for ComponentError { + type ExternalError = MozAdsClientApiError; + + fn get_error_handling(&self) -> ErrorHandling { + ErrorHandling::convert(MozAdsClientApiError::Other { + reason: self.to_string(), + }) + } +} diff --git a/components/ads-client/src/ffi/telemetry.rs b/components/ads-client/src/ffi/telemetry.rs index cf6b9af6ce..8a71e4bede 100644 --- a/components/ads-client/src/ffi/telemetry.rs +++ b/components/ads-client/src/ffi/telemetry.rs @@ -6,9 +6,10 @@ use std::any::Any; use std::sync::Arc; +use crate::client::error::RequestAdsError; use crate::client::ClientOperationEvent; -use crate::error::{RecordClickError, RecordImpressionError, ReportAdError, RequestAdsError}; use crate::http_cache::{CacheOutcome, HttpCacheBuilderError}; +use crate::mars::error::{RecordClickError, RecordImpressionError, ReportAdError}; use crate::telemetry::Telemetry; #[uniffi::export(with_foreign)] diff --git a/components/ads-client/src/http_cache.rs b/components/ads-client/src/http_cache.rs index 6ae09d67b3..48c3c4d078 100644 --- a/components/ads-client/src/http_cache.rs +++ b/components/ads-client/src/http_cache.rs @@ -7,24 +7,39 @@ mod bytesize; mod cache_control; mod clock; mod connection_initializer; +mod outcome; mod request_hash; mod store; +mod strategy; -use self::{builder::HttpCacheBuilder, cache_control::CacheControl, store::HttpCacheStore}; +use self::{ + builder::HttpCacheBuilder, + store::HttpCacheStore, + strategy::{CacheFirst, NetworkFirst}, +}; use std::hash::Hash; use viaduct::{Client, Request, Response}; pub use self::builder::HttpCacheBuilderError; pub use self::bytesize::ByteSize; +pub use self::outcome::CacheOutcome; pub use self::request_hash::RequestHash; -use std::cmp; use std::path::Path; use std::time::Duration; pub type HttpCacheSendResult = std::result::Result<(Response, Vec), viaduct::ViaductError>; +#[derive(Debug, thiserror::Error)] +pub enum HttpCacheError { + #[error("Could not build cache: {0}")] + Builder(#[from] builder::HttpCacheBuilderError), + + #[error("SQLite operation failed: {0}")] + Sqlite(#[from] rusqlite::Error), +} + #[derive(Clone, Copy, Debug)] pub enum CachePolicy { CacheFirst { ttl: Option }, @@ -37,35 +52,14 @@ impl Default for CachePolicy { } } -#[derive(Debug, thiserror::Error)] -pub enum HttpCacheError { - #[error("Could not build cache: {0}")] - Builder(#[from] builder::HttpCacheBuilderError), - - #[error("SQLite operation failed: {0}")] - Sqlite(#[from] rusqlite::Error), -} - -#[derive(Debug)] -pub enum CacheOutcome { - Hit, - LookupFailed(rusqlite::Error), // cache miss path due to lookup error - NoCache, // send policy requested a cache bypass - MissNotCacheable, // policy says "don't store" - MissStored, // stored successfully - StoreFailed(HttpCacheError), // insert/upsert failed - CleanupFailed(HttpCacheError), // cleaning expired objects failed -} - -pub struct HttpCache> { +pub struct HttpCache { default_ttl: Duration, max_size: ByteSize, store: HttpCacheStore, - _phantom: std::marker::PhantomData, } -impl> HttpCache { - pub fn builder>(db_path: P) -> HttpCacheBuilder { +impl HttpCache { + pub fn builder>(db_path: P) -> HttpCacheBuilder { HttpCacheBuilder::new(db_path.as_ref()) } @@ -81,81 +75,44 @@ impl> HttpCache { Ok(()) } - pub fn send_with_policy( + pub fn send_with_policy>( &self, client: &Client, item: T, - cache_policy: &CachePolicy, + policy: &CachePolicy, ) -> HttpCacheSendResult { + let hash = RequestHash::new(&item); + let request = item.into(); let mut outcomes = vec![]; - let request_hash = RequestHash::new(&item); - let ttl = match cache_policy { - CachePolicy::CacheFirst { ttl: Some(d) } - | CachePolicy::NetworkFirst { ttl: Some(d) } => *d, - _ => self.default_ttl, - }; + // Clean up expired entries before applying the policy if let Err(e) = self.store.delete_expired_entries() { outcomes.push(CacheOutcome::CleanupFailed(e.into())); } - if matches!(cache_policy, CachePolicy::CacheFirst { .. }) { - match self.store.lookup(&request_hash) { - Ok(Some(response)) => { - outcomes.push(CacheOutcome::Hit); - return Ok((response, outcomes)); - } - Err(e) => { - outcomes.push(CacheOutcome::LookupFailed(e)); - let (response, mut fetch_outcomes) = - self.fetch_and_cache(client, item, &ttl)?; - outcomes.append(&mut fetch_outcomes); - return Ok((response, outcomes)); - } - Ok(None) => {} + // Apply the cache policy and collect outcomes + let (response, mut strategy_outcomes) = match policy { + CachePolicy::CacheFirst { ttl } => CacheFirst { + hash, + request, + ttl: ttl.unwrap_or(self.default_ttl), } - } - - let (response, mut fetch_outcomes) = self.fetch_and_cache(client, item, &ttl)?; - outcomes.append(&mut fetch_outcomes); - Ok((response, outcomes)) - } - - fn cache_object( - &self, - request_hash: &RequestHash, - response: &Response, - ttl: &Duration, - ) -> Result<(), HttpCacheError> { - self.store.store_with_ttl(request_hash, response, ttl)?; - self.store.trim_to_max_size(self.max_size.as_u64() as i64)?; - Ok(()) - } - - fn fetch_and_cache(&self, client: &Client, item: T, ttl: &Duration) -> HttpCacheSendResult { - let request_hash = RequestHash::new(&item); - let request: Request = item.into(); - let response = client.send_sync(request)?; - let cache_control = CacheControl::from(&response); - let cache_outcome = if cache_control.should_cache() { - let ttl = match cache_control.max_age { - Some(s) => cmp::min(*ttl, Duration::from_secs(s)), - None => *ttl, - }; - - if ttl.is_zero() { - return Ok((response, vec![CacheOutcome::NoCache])); + .apply(client, &self.store), + CachePolicy::NetworkFirst { ttl } => NetworkFirst { + hash, + request, + ttl: ttl.unwrap_or(self.default_ttl), } + .apply(client, &self.store), + }?; + outcomes.append(&mut strategy_outcomes); - match self.cache_object(&request_hash, &response, &ttl) { - Ok(()) => CacheOutcome::MissStored, - Err(e) => CacheOutcome::StoreFailed(e), - } - } else { - CacheOutcome::MissNotCacheable - }; + // Trim the cache to the max size after applying the policy + if let Err(e) = self.store.trim_to_max_size(self.max_size.as_u64() as i64) { + outcomes.push(CacheOutcome::StoreFailed(e.into())); + } - Ok((response, vec![cache_outcome])) + Ok((response, outcomes)) } } @@ -194,7 +151,7 @@ mod tests { TestRequest(Request::post(url).json(&serde_json::json!({"fake":"data"}))) } - fn make_cache() -> HttpCache { + fn make_cache() -> HttpCache { // Our store opens an in-memory cache for tests. So the name is irrelevant. HttpCache::builder("ignored_in_tests.db") .default_ttl(Duration::from_secs(60)) @@ -203,7 +160,7 @@ mod tests { .expect("cache build should succeed") } - fn make_cache_with_ttl(secs: u64) -> HttpCache { + fn make_cache_with_ttl(secs: u64) -> HttpCache { // In tests our store uses an in-memory DB; filename is irrelevant. HttpCache::builder("ignored_in_tests.db") .default_ttl(Duration::from_secs(secs)) @@ -215,21 +172,20 @@ mod tests { #[test] fn test_http_cache_creation() { // Test that HttpCache can be created successfully with test config - let cache: Result, _> = HttpCache::builder("test_cache.db").build(); + let cache: Result = HttpCache::builder("test_cache.db").build(); assert!(cache.is_ok()); // Test with custom config - let cache_with_config: Result, _> = - HttpCache::builder("custom_test.db") - .max_size(ByteSize::mib(1)) - .default_ttl(Duration::from_secs(60)) - .build(); + let cache_with_config: Result = HttpCache::builder("custom_test.db") + .max_size(ByteSize::mib(1)) + .default_ttl(Duration::from_secs(60)) + .build(); assert!(cache_with_config.is_ok()); } #[test] fn test_clear_cache() { - let cache: HttpCache = HttpCache::builder("test_clear.db").build().unwrap(); + let cache: HttpCache = HttpCache::builder("test_clear.db").build().unwrap(); // Create a test request and response let hash = RequestHash::new(&("Get", "https://example.com/test")); @@ -505,8 +461,7 @@ mod tests { #[test] fn test_invalidate_by_hash() { - let cache: HttpCache = - HttpCache::builder("test_invalidate.db").build().unwrap(); + let cache: HttpCache = HttpCache::builder("test_invalidate.db").build().unwrap(); let hash1 = RequestHash::new(&("Post", "https://example.com/api1")); let hash2 = RequestHash::new(&("Post", "https://example.com/api2")); diff --git a/components/ads-client/src/http_cache/builder.rs b/components/ads-client/src/http_cache/builder.rs index 3d5bc44a62..97f92f004b 100644 --- a/components/ads-client/src/http_cache/builder.rs +++ b/components/ads-client/src/http_cache/builder.rs @@ -9,10 +9,8 @@ use super::connection_initializer::HttpCacheConnectionInitializer; use super::store::HttpCacheStore; use rusqlite::Connection; use sql_support::open_database; -use std::hash::Hash; use std::path::PathBuf; use std::time::Duration; -use viaduct::Request; const DEFAULT_MAX_SIZE: ByteSize = ByteSize::mib(10); const DEFAULT_TTL: Duration = Duration::from_secs(300); @@ -44,20 +42,18 @@ pub enum HttpCacheBuilderError { }, } -pub struct HttpCacheBuilder> { +pub struct HttpCacheBuilder { db_path: PathBuf, max_size: Option, default_ttl: Option, - _phantom: std::marker::PhantomData, } -impl> HttpCacheBuilder { +impl HttpCacheBuilder { pub fn new(db_path: impl Into) -> Self { Self { db_path: db_path.into(), max_size: None, default_ttl: None, - _phantom: std::marker::PhantomData, } } @@ -109,7 +105,7 @@ impl> HttpCacheBuilder { Ok(conn) } - pub fn build(&self) -> Result, HttpCacheBuilderError> { + pub fn build(&self) -> Result { self.validate()?; let conn = self.open_connection()?; @@ -121,12 +117,11 @@ impl> HttpCacheBuilder { default_ttl, max_size, store, - _phantom: std::marker::PhantomData, }) } #[cfg(test)] - pub fn build_for_time_dependent_tests(&self) -> Result, HttpCacheBuilderError> { + pub fn build_for_time_dependent_tests(&self) -> Result { self.validate()?; let conn = self.open_connection()?; @@ -138,7 +133,6 @@ impl> HttpCacheBuilder { default_ttl, max_size, store, - _phantom: std::marker::PhantomData, }) } } @@ -147,18 +141,7 @@ impl> HttpCacheBuilder { mod tests { use super::*; - // Test-only type that satisfies Hash + Into - #[derive(Hash, Clone)] - struct TestItem(String); - - impl From for Request { - fn from(_t: TestItem) -> Self { - Request::get("https://example.com".parse().unwrap()) - } - } - - // Helper to avoid repeating the generic type annotation on every test. - fn make_test_builder(path: &str) -> HttpCacheBuilder { + fn make_test_builder(path: &str) -> HttpCacheBuilder { HttpCacheBuilder::new(path) } diff --git a/components/ads-client/src/http_cache/cache_control.rs b/components/ads-client/src/http_cache/cache_control.rs index c2f0fe7e85..526549dcaa 100644 --- a/components/ads-client/src/http_cache/cache_control.rs +++ b/components/ads-client/src/http_cache/cache_control.rs @@ -3,6 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ use serde::{Deserialize, Serialize}; +use std::time::Duration; use viaduct::{header_names, Response}; #[derive(Clone, Debug, Deserialize, Serialize)] @@ -49,6 +50,13 @@ impl CacheControl { pub fn should_cache(&self) -> bool { !self.no_store } + + pub fn effective_ttl(&self, requested_ttl: Duration) -> Duration { + match self.max_age { + Some(s) => std::cmp::min(requested_ttl, Duration::from_secs(s)), + None => requested_ttl, + } + } } #[cfg(test)] diff --git a/components/ads-client/src/http_cache/outcome.rs b/components/ads-client/src/http_cache/outcome.rs new file mode 100644 index 0000000000..93d86223f5 --- /dev/null +++ b/components/ads-client/src/http_cache/outcome.rs @@ -0,0 +1,17 @@ +/* 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 super::HttpCacheError; + +#[derive(Debug)] +pub enum CacheOutcome { + CleanupFailed(HttpCacheError), // cleaning expired objects failed + Hit, // cache hit + LookupFailed(rusqlite::Error), // cache miss path due to lookup error + MissNotCacheable, // policy says "don't store" + MissStored, // stored successfully + NoCache, // send policy requested a cache bypass + StoreFailed(HttpCacheError), // insert/upsert failed +} diff --git a/components/ads-client/src/http_cache/strategy.rs b/components/ads-client/src/http_cache/strategy.rs new file mode 100644 index 0000000000..918caf5b61 --- /dev/null +++ b/components/ads-client/src/http_cache/strategy.rs @@ -0,0 +1,63 @@ +/* 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 super::cache_control::CacheControl; +use super::request_hash::RequestHash; +use super::store::HttpCacheStore; +use super::{CacheOutcome, HttpCacheSendResult}; +use std::time::Duration; +use viaduct::{Client, Request}; + +pub struct CacheFirst { + pub hash: RequestHash, + pub request: Request, + pub ttl: Duration, +} + +impl CacheFirst { + pub fn apply(self, client: &Client, store: &HttpCacheStore) -> HttpCacheSendResult { + let mut outcomes = vec![]; + match store.lookup(&self.hash) { + Ok(Some(response)) => return Ok((response, vec![CacheOutcome::Hit])), + Err(e) => outcomes.push(CacheOutcome::LookupFailed(e)), + Ok(None) => {} + } + + let network = NetworkFirst { + hash: self.hash, + request: self.request, + ttl: self.ttl, + }; + let (response, mut network_outcomes) = network.apply(client, store)?; + outcomes.append(&mut network_outcomes); + Ok((response, outcomes)) + } +} + +pub struct NetworkFirst { + pub hash: RequestHash, + pub request: Request, + pub ttl: Duration, +} + +impl NetworkFirst { + pub fn apply(self, client: &Client, store: &HttpCacheStore) -> HttpCacheSendResult { + let response = client.send_sync(self.request)?; + let cache_control = CacheControl::from(&response); + let outcome = if cache_control.should_cache() { + let ttl = cache_control.effective_ttl(self.ttl); + if ttl.is_zero() { + return Ok((response, vec![CacheOutcome::NoCache])); + } + match store.store_with_ttl(&self.hash, &response, &ttl) { + Ok(()) => CacheOutcome::MissStored, + Err(e) => CacheOutcome::StoreFailed(e.into()), + } + } else { + CacheOutcome::MissNotCacheable + }; + Ok((response, vec![outcome])) + } +} diff --git a/components/ads-client/src/lib.rs b/components/ads-client/src/lib.rs index 756b617e7a..d2e6209097 100644 --- a/components/ads-client/src/lib.rs +++ b/components/ads-client/src/lib.rs @@ -5,17 +5,17 @@ use std::collections::HashMap; -use error::{CallbackRequestError, ComponentError}; +use client::error::ComponentError; use error_support::handle_error; +use mars::error::CallbackRequestError; use parking_lot::Mutex; use url::Url as AdsClientUrl; -use client::ad_request::AdPlacementRequest; use client::AdsClient; use http_cache::CachePolicy; +use mars::ad_request::AdPlacementRequest; mod client; -mod error; mod ffi; pub mod http_cache; mod mars; @@ -43,7 +43,67 @@ pub struct MozAdsClient { #[uniffi::export] impl MozAdsClient { + pub fn clear_cache(&self) -> AdsClientApiResult<()> { + let inner = self.inner.lock(); + inner + .clear_cache() + .map_err(|_| MozAdsClientApiError::Other { + reason: "Failed to clear cache".to_string(), + }) + } + + #[handle_error(ComponentError)] + #[uniffi::method(default(options = None))] + pub fn record_click( + &self, + click_url: String, + options: Option, + ) -> AdsClientApiResult<()> { + let url = AdsClientUrl::parse(&click_url) + .map_err(|e| ComponentError::RecordClick(CallbackRequestError::InvalidUrl(e).into()))?; + let ohttp = options.map(|o| o.ohttp).unwrap_or(false); + let inner = self.inner.lock(); + inner + .record_click(url, ohttp) + .map_err(ComponentError::RecordClick) + } + + #[handle_error(ComponentError)] + #[uniffi::method(default(options = None))] + pub fn record_impression( + &self, + impression_url: String, + options: Option, + ) -> AdsClientApiResult<()> { + let url = AdsClientUrl::parse(&impression_url).map_err(|e| { + ComponentError::RecordImpression(CallbackRequestError::InvalidUrl(e).into()) + })?; + let ohttp = options.map(|o| o.ohttp).unwrap_or(false); + let inner = self.inner.lock(); + inner + .record_impression(url, ohttp) + .map_err(ComponentError::RecordImpression) + } + #[handle_error(ComponentError)] + #[uniffi::method(default(options = None))] + pub fn report_ad( + &self, + report_url: String, + reason: MozAdsReportReason, + options: Option, + ) -> AdsClientApiResult<()> { + let url = AdsClientUrl::parse(&report_url) + .map_err(|e| ComponentError::ReportAd(CallbackRequestError::InvalidUrl(e).into()))?; + let ohttp = options.map(|o| o.ohttp).unwrap_or(false); + let inner = self.inner.lock(); + inner + .report_ad(url, reason.into(), ohttp) + .map_err(ComponentError::ReportAd) + } + + #[handle_error(ComponentError)] + #[uniffi::method(default(options = None))] pub fn request_image_ads( &self, moz_ad_requests: Vec, @@ -51,14 +111,16 @@ impl MozAdsClient { ) -> AdsClientApiResult> { let inner = self.inner.lock(); let requests: Vec = moz_ad_requests.iter().map(|r| r.into()).collect(); + let ohttp = options.as_ref().map(|o| o.ohttp).unwrap_or(false); let cache_policy: CachePolicy = options.into(); let response = inner - .request_image_ads(requests, Some(cache_policy)) + .request_image_ads(requests, Some(cache_policy), ohttp) .map_err(ComponentError::RequestAds)?; Ok(response.into_iter().map(|(k, v)| (k, v.into())).collect()) } #[handle_error(ComponentError)] + #[uniffi::method(default(options = None))] pub fn request_spoc_ads( &self, moz_ad_requests: Vec, @@ -66,9 +128,10 @@ impl MozAdsClient { ) -> AdsClientApiResult>> { let inner = self.inner.lock(); let requests: Vec = moz_ad_requests.iter().map(|r| r.into()).collect(); + let ohttp = options.as_ref().map(|o| o.ohttp).unwrap_or(false); let cache_policy: CachePolicy = options.into(); let response = inner - .request_spoc_ads(requests, Some(cache_policy)) + .request_spoc_ads(requests, Some(cache_policy), ohttp) .map_err(ComponentError::RequestAds)?; Ok(response .into_iter() @@ -77,6 +140,7 @@ impl MozAdsClient { } #[handle_error(ComponentError)] + #[uniffi::method(default(options = None))] pub fn request_tile_ads( &self, moz_ad_requests: Vec, @@ -84,52 +148,11 @@ impl MozAdsClient { ) -> AdsClientApiResult> { let inner = self.inner.lock(); let requests: Vec = moz_ad_requests.iter().map(|r| r.into()).collect(); + let ohttp = options.as_ref().map(|o| o.ohttp).unwrap_or(false); let cache_policy: CachePolicy = options.into(); let response = inner - .request_tile_ads(requests, Some(cache_policy)) + .request_tile_ads(requests, Some(cache_policy), ohttp) .map_err(ComponentError::RequestAds)?; Ok(response.into_iter().map(|(k, v)| (k, v.into())).collect()) } - - #[handle_error(ComponentError)] - pub fn record_impression(&self, impression_url: String) -> AdsClientApiResult<()> { - let url = AdsClientUrl::parse(&impression_url).map_err(|e| { - ComponentError::RecordImpression(CallbackRequestError::InvalidUrl(e).into()) - })?; - let inner = self.inner.lock(); - inner - .record_impression(url) - .map_err(ComponentError::RecordImpression) - } - - #[handle_error(ComponentError)] - pub fn record_click(&self, click_url: String) -> AdsClientApiResult<()> { - let url = AdsClientUrl::parse(&click_url) - .map_err(|e| ComponentError::RecordClick(CallbackRequestError::InvalidUrl(e).into()))?; - let inner = self.inner.lock(); - inner.record_click(url).map_err(ComponentError::RecordClick) - } - - #[handle_error(ComponentError)] - pub fn report_ad( - &self, - report_url: String, - reason: MozAdsReportReason, - ) -> AdsClientApiResult<()> { - let url = AdsClientUrl::parse(&report_url) - .map_err(|e| ComponentError::ReportAd(CallbackRequestError::InvalidUrl(e).into()))?; - let inner = self.inner.lock(); - inner - .report_ad(url, reason.into()) - .map_err(ComponentError::ReportAd) - } - - pub fn clear_cache(&self) -> AdsClientApiResult<()> { - let inner = self.inner.lock(); - inner - .clear_cache() - .map_err(|_| MozAdsClientApiError::Other { - reason: "Failed to clear cache".to_string(), - }) - } } diff --git a/components/ads-client/src/mars.rs b/components/ads-client/src/mars.rs index 78ee723972..b1515c290a 100644 --- a/components/ads-client/src/mars.rs +++ b/components/ads-client/src/mars.rs @@ -3,29 +3,42 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -use crate::{ - client::{ - ad_request::AdRequest, - ad_response::{AdResponse, AdResponseValue}, - ReportReason, - }, +pub mod ad_request; +pub mod ad_response; +pub mod environment; +pub mod error; +mod preflight; +pub mod report_reason; + +pub use environment::Environment; +pub use report_reason::ReportReason; + +use self::{ + ad_request::{AdPlacementRequest, AdRequest}, + ad_response::{AdResponse, AdResponseValue}, error::{ check_http_status_for_error, CallbackRequestError, FetchAdsError, RecordClickError, RecordImpressionError, ReportAdError, }, +}; +use crate::{ http_cache::{HttpCache, HttpCacheError, RequestHash}, telemetry::Telemetry, CachePolicy, }; + +pub const OHTTP_CHANNEL_ID: &str = "ads-client"; + +use self::preflight::{PreflightRequest, PreflightResponse}; use url::Url; -use viaduct::{Client, ClientSettings, Request}; +use viaduct::{Client, ClientSettings, Headers, Request}; pub struct MARSClient where T: Telemetry, { - http_client: Client, - http_cache: Option>, + environment: Environment, + http_cache: Option, telemetry: T, } @@ -33,9 +46,9 @@ impl MARSClient where T: Telemetry, { - pub fn new(http_cache: Option>, telemetry: T) -> Self { + pub fn new(environment: Environment, http_cache: Option, telemetry: T) -> Self { Self { - http_client: Client::new(ClientSettings::default()), + environment, http_cache, telemetry, } @@ -50,17 +63,28 @@ where pub fn fetch_ads( &self, - ad_request: AdRequest, + context_id: String, + placements: Vec, cache_policy: CachePolicy, + ohttp: bool, ) -> Result<(AdResponse, RequestHash), FetchAdsError> where A: AdResponseValue, { + let url = self.environment.into_url("ads"); + let mut ad_request = AdRequest::try_new(context_id, placements, url)?; let request_hash = RequestHash::new(&ad_request); + if ohttp { + ad_request + .headers + .extend(Headers::from(self.fetch_preflight()?)); + } + + let client = self.client_for(ohttp)?; let response: AdResponse = if let Some(cache) = self.http_cache.as_ref() { let (response, cache_outcomes) = - cache.send_with_policy(&self.http_client, ad_request, &cache_policy)?; + cache.send_with_policy(&client, ad_request, &cache_policy)?; for outcome in &cache_outcomes { self.telemetry.record(outcome); } @@ -68,7 +92,7 @@ where AdResponse::::parse(response.json()?, &self.telemetry)? } else { let request: Request = ad_request.into(); - let response = request.send()?; + let response = client.send_sync(request)?; check_http_status_for_error(&response)?; AdResponse::::parse(response.json()?, &self.telemetry)? }; @@ -87,24 +111,70 @@ where Ok(()) } - pub fn record_click(&self, callback: Url) -> Result<(), RecordClickError> { - Ok(self.make_callback_request(callback)?) + pub fn record_click(&self, callback: Url, ohttp: bool) -> Result<(), RecordClickError> { + Ok(self.make_callback_request(callback, ohttp)?) } - pub fn record_impression(&self, callback: Url) -> Result<(), RecordImpressionError> { - Ok(self.make_callback_request(callback)?) + pub fn record_impression( + &self, + callback: Url, + ohttp: bool, + ) -> Result<(), RecordImpressionError> { + Ok(self.make_callback_request(callback, ohttp)?) } - pub fn report_ad(&self, mut callback: Url, reason: ReportReason) -> Result<(), ReportAdError> { + pub fn report_ad( + &self, + mut callback: Url, + reason: ReportReason, + ohttp: bool, + ) -> Result<(), ReportAdError> { callback .query_pairs_mut() .append_pair("reason", reason.as_str()); - Ok(self.make_callback_request(callback)?) + Ok(self.make_callback_request(callback, ohttp)?) } - fn make_callback_request(&self, callback: Url) -> Result<(), CallbackRequestError> { - let request = Request::get(callback); - let response = request.send()?; + fn client_for(&self, ohttp: bool) -> Result { + if ohttp { + Client::with_ohttp_channel(OHTTP_CHANNEL_ID, ClientSettings::default()) + } else { + Ok(Client::new(ClientSettings::default())) + } + } + + fn fetch_preflight(&self) -> Result { + let client = Client::new(ClientSettings::default()); + let preflight_request = PreflightRequest(self.environment.into_url("ads-preflight")); + if let Some(cache) = &self.http_cache { + let (response, _) = cache.send_with_policy( + &client, + preflight_request, + &CachePolicy::CacheFirst { ttl: None }, + )?; + check_http_status_for_error(&response)?; + Ok(response.json()?) + } else { + let request: Request = preflight_request.into(); + let response = client.send_sync(request)?; + check_http_status_for_error(&response)?; + Ok(response.json()?) + } + } + + fn make_callback_request( + &self, + callback: Url, + ohttp: bool, + ) -> Result<(), CallbackRequestError> { + let mut request = Request::get(callback); + if ohttp { + request + .headers + .extend(Headers::from(self.fetch_preflight()?)); + } + let client = self.client_for(ohttp)?; + let response = client.send_sync(request)?; check_http_status_for_error(&response).map_err(Into::into) } } @@ -112,25 +182,35 @@ where #[cfg(test)] mod tests { + use super::ad_response::AdImage; use super::*; - use crate::client::ad_response::AdImage; use crate::ffi::telemetry::MozAdsTelemetryWrapper; - use crate::test_utils::{get_example_happy_image_response, make_happy_ad_request}; + use crate::test_utils::{ + get_example_happy_image_response, make_happy_placement_requests, TEST_CONTEXT_ID, + }; use mockito::mock; + fn make_test_client(http_cache: Option) -> MARSClient { + MARSClient::new( + Environment::Test, + http_cache, + MozAdsTelemetryWrapper::noop(), + ) + } + #[test] fn test_record_impression_with_valid_url_should_succeed() { viaduct_dev::init_backend_dev(); let _m = mock("GET", "/impression_callback_url") .with_status(200) .create(); - let client = MARSClient::new(None, MozAdsTelemetryWrapper::noop()); + let client = make_test_client(None); let url = Url::parse(&format!( "{}/impression_callback_url", &mockito::server_url() )) .unwrap(); - let result = client.record_impression(url); + let result = client.record_impression(url, false); assert!(result.is_ok()); } @@ -139,9 +219,9 @@ mod tests { viaduct_dev::init_backend_dev(); let _m = mock("GET", "/click_callback_url").with_status(200).create(); - let client = MARSClient::new(None, MozAdsTelemetryWrapper::noop()); + let client = make_test_client(None); let url = Url::parse(&format!("{}/click_callback_url", &mockito::server_url())).unwrap(); - let result = client.record_click(url); + let result = client.record_click(url, false); assert!(result.is_ok()); } @@ -156,13 +236,13 @@ mod tests { .with_status(200) .create(); - let client = MARSClient::new(None, MozAdsTelemetryWrapper::noop()); + let client = make_test_client(None); let url = Url::parse(&format!( "{}/report_ad_callback_url", &mockito::server_url() )) .unwrap(); - let result = client.report_ad(url, ReportReason::NotInterested); + let result = client.report_ad(url, ReportReason::NotInterested, false); assert!(result.is_ok()); } @@ -178,11 +258,14 @@ mod tests { .with_body(serde_json::to_string(&expected_response.data).unwrap()) .create(); - let client = MARSClient::new(None, MozAdsTelemetryWrapper::noop()); + let client = make_test_client(None); - let ad_request = make_happy_ad_request(); - - let result = client.fetch_ads::(ad_request, CachePolicy::default()); + let result = client.fetch_ads::( + TEST_CONTEXT_ID.to_string(), + make_happy_placement_requests(), + CachePolicy::default(), + false, + ); assert!(result.is_ok()); let (response, _request_hash) = result.unwrap(); assert_eq!(expected_response, response); @@ -199,17 +282,27 @@ mod tests { .expect(1) // only first request goes to network .create(); - let client = MARSClient::new(None, MozAdsTelemetryWrapper::noop()); + let client = make_test_client(None); // First call should be a miss then warm the cache - let (response1, _request_hash1) = client - .fetch_ads::(make_happy_ad_request(), CachePolicy::default()) + let (response1, _) = client + .fetch_ads::( + TEST_CONTEXT_ID.to_string(), + make_happy_placement_requests(), + CachePolicy::default(), + false, + ) .unwrap(); assert_eq!(response1, expected); // Second call should be a hit - let (response2, _request_hash2) = client - .fetch_ads::(make_happy_ad_request(), CachePolicy::default()) + let (response2, _) = client + .fetch_ads::( + TEST_CONTEXT_ID.to_string(), + make_happy_placement_requests(), + CachePolicy::default(), + false, + ) .unwrap(); assert_eq!(response2, expected); } @@ -223,13 +316,12 @@ mod tests { .build() .unwrap(); - let client = MARSClient::new(Some(cache), MozAdsTelemetryWrapper::noop()); - + let client = make_test_client(Some(cache)); let callback_url = Url::parse(&format!("{}/click", mockito::server_url())).unwrap(); let _m = mock("GET", "/click").with_status(200).create(); - let result = client.record_click(callback_url); + let result = client.record_click(callback_url, false); assert!(result.is_ok()); } @@ -242,13 +334,12 @@ mod tests { .build() .unwrap(); - let client = MARSClient::new(Some(cache), MozAdsTelemetryWrapper::noop()); - + let client = make_test_client(Some(cache)); let callback_url = Url::parse(&format!("{}/impression", mockito::server_url())).unwrap(); let _m = mock("GET", "/impression").with_status(200).create(); - let result = client.record_impression(callback_url); + let result = client.record_impression(callback_url, false); assert!(result.is_ok()); } } diff --git a/components/ads-client/src/client/ad_request.rs b/components/ads-client/src/mars/ad_request.rs similarity index 95% rename from components/ads-client/src/client/ad_request.rs rename to components/ads-client/src/mars/ad_request.rs index 5e3d181123..f595725717 100644 --- a/components/ads-client/src/client/ad_request.rs +++ b/components/ads-client/src/mars/ad_request.rs @@ -8,13 +8,15 @@ use std::hash::{Hash, Hasher}; use serde::{Deserialize, Serialize}; use url::Url; -use viaduct::Request; +use viaduct::{Headers, Request}; -use crate::error::BuildRequestError; +use super::error::BuildRequestError; #[derive(Debug, PartialEq, Serialize)] pub struct AdRequest { pub context_id: String, + #[serde(skip)] + pub headers: Headers, pub placements: Vec, /// Skipped to exclude from the request body #[serde(skip)] @@ -23,6 +25,7 @@ pub struct AdRequest { /// Hash implementation intentionally excludes `context_id` as it rotates /// on client re-instantiation and should not invalidate cached responses. +/// `headers` are also excluded as they are request metadata, not cache keys. impl Hash for AdRequest { fn hash(&self, state: &mut H) { self.url.as_str().hash(state); @@ -33,7 +36,9 @@ impl Hash for AdRequest { impl From for Request { fn from(ad_request: AdRequest) -> Self { let url = ad_request.url.clone(); - Request::post(url).json(&ad_request) + let mut request = Request::post(url).json(&ad_request); + request.headers.extend(ad_request.headers); + request } } @@ -49,6 +54,7 @@ impl AdRequest { let mut request = AdRequest { context_id, + headers: Headers::new(), placements: vec![], url, }; @@ -193,6 +199,7 @@ mod tests { let expected_request = AdRequest { context_id: TEST_CONTEXT_ID.to_string(), + headers: Headers::new(), placements: vec![ AdPlacementRequest { content: Some(AdContentCategory { diff --git a/components/ads-client/src/client/ad_response.rs b/components/ads-client/src/mars/ad_response.rs similarity index 91% rename from components/ads-client/src/client/ad_response.rs rename to components/ads-client/src/mars/ad_response.rs index 03f7f80c52..b9b4b20cd4 100644 --- a/components/ads-client/src/client/ad_response.rs +++ b/components/ads-client/src/mars/ad_response.rs @@ -43,11 +43,11 @@ impl AdResponse { Ok(AdResponse { data: result }) } - pub fn add_request_hash_to_callbacks(&mut self, request_hash: &RequestHash) { - for ads in self.data.values_mut() { - for ad in ads.iter_mut() { + 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(); - let hash_str = request_hash.to_string(); callbacks .click .query_pairs_mut() @@ -56,14 +56,7 @@ impl AdResponse { .impression .query_pairs_mut() .append_pair("request_hash", &hash_str); - } - } - } - - pub fn add_placement_info_to_report_callbacks(&mut self) { - for (placement_id, ads) in self.data.iter_mut() { - for (position, ad) in ads.iter_mut().enumerate() { - if let Some(report_url) = ad.callbacks_mut().report.as_mut() { + if let Some(report_url) = callbacks.report.as_mut() { report_url .query_pairs_mut() .append_pair("placement_id", placement_id) @@ -447,43 +440,7 @@ mod tests { } #[test] - fn test_add_request_hash_to_callbacks() { - let mut response = AdResponse { - data: HashMap::from([( - "placement_1".to_string(), - vec![AdImage { - alt_text: Some("An ad for a puppy".to_string()), - block_key: "abc123".into(), - callbacks: AdCallbacks { - click: Url::parse("https://example.com/click").unwrap(), - impression: Url::parse("https://example.com/impression").unwrap(), - report: Some(Url::parse("https://example.com/report").unwrap()), - }, - format: "billboard".to_string(), - image_url: "https://example.com/image.png".to_string(), - url: "https://example.com/ad".to_string(), - }], - )]), - }; - - let request_hash = RequestHash::from("abc123def456"); - response.add_request_hash_to_callbacks(&request_hash); - let callbacks = &response.data.values().next().unwrap()[0].callbacks; - - assert!(callbacks - .click - .query() - .unwrap_or("") - .contains("request_hash=abc123def456")); - assert!(callbacks - .impression - .query() - .unwrap_or("") - .contains("request_hash=abc123def456")); - } - - #[test] - fn test_add_placement_info_to_report_callbacks() { + fn test_enrich_callbacks() { let mut response = AdResponse { data: HashMap::from([( "mock_tile_1".to_string(), @@ -516,10 +473,26 @@ mod tests { )]), }; - response.add_placement_info_to_report_callbacks(); + 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 @@ -542,7 +515,7 @@ mod tests { } #[test] - fn test_add_placement_info_skips_ads_without_report_url() { + fn test_enrich_callbacks_skips_ads_without_report_url() { let mut response = AdResponse { data: HashMap::from([( "mock_tile_1".to_string(), @@ -562,12 +535,24 @@ mod tests { }; // Should not panic - response.add_placement_info_to_report_callbacks(); + 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()); - assert!(ad.callbacks.click.query().is_none()); - assert!(ad.callbacks.impression.query().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] diff --git a/components/ads-client/src/mars/environment.rs b/components/ads-client/src/mars/environment.rs new file mode 100644 index 0000000000..53833e36b8 --- /dev/null +++ b/components/ads-client/src/mars/environment.rs @@ -0,0 +1,62 @@ +/* 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 once_cell::sync::Lazy; +use url::Url; + +static MARS_API_ENDPOINT_PROD: Lazy = + Lazy::new(|| Url::parse("https://ads.mozilla.org/v1/").expect("hardcoded URL must be valid")); + +static MARS_API_ENDPOINT_STAGING: Lazy = + Lazy::new(|| Url::parse("https://ads.allizom.org/v1/").expect("hardcoded URL must be valid")); + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum Environment { + #[default] + Prod, + Staging, + #[cfg(test)] + Test, +} + +impl Environment { + pub fn into_url(self, path: &str) -> Url { + let mut base = self.base_url(); + // Ensure the path has a trailing slash so that `join` appends + // rather than replacing the last segment. + if !base.path().ends_with('/') { + base.set_path(&format!("{}/", base.path())); + } + base.join(path) + .expect("joining a path to a valid base URL must succeed") + } + + fn base_url(self) -> Url { + match self { + Environment::Prod => MARS_API_ENDPOINT_PROD.clone(), + Environment::Staging => MARS_API_ENDPOINT_STAGING.clone(), + #[cfg(test)] + Environment::Test => Url::parse(&mockito::server_url()).unwrap(), + } + } +} + +#[cfg(test)] +mod tests { + use url::Host; + + use super::*; + + #[test] + fn prod_endpoint_parses_and_is_expected() { + let url = Environment::Prod.into_url("ads"); + + assert_eq!(url.as_str(), "https://ads.mozilla.org/v1/ads"); + + assert_eq!(url.scheme(), "https"); + assert_eq!(url.host(), Some(Host::Domain("ads.mozilla.org"))); + assert_eq!(url.path(), "/v1/ads"); + } +} diff --git a/components/ads-client/src/error.rs b/components/ads-client/src/mars/error.rs similarity index 84% rename from components/ads-client/src/error.rs rename to components/ads-client/src/mars/error.rs index 87ad4fac10..86968c72a0 100644 --- a/components/ads-client/src/error.rs +++ b/components/ads-client/src/mars/error.rs @@ -3,34 +3,42 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -use error_support::error; use viaduct::Response; #[derive(Debug, thiserror::Error)] -pub enum ComponentError { - #[error("Error recording a click for a placement: {0}")] - RecordClick(#[from] RecordClickError), - - #[error("Error recording an impressions for a placement: {0}")] - RecordImpression(#[from] RecordImpressionError), +pub enum HTTPError { + #[error("Bad request ({code}): {message}")] + BadRequest { code: u16, message: String }, - #[error("Error reporting an ad: {0}")] - ReportAd(#[from] ReportAdError), + #[error("Server error ({code}): {message}")] + Server { code: u16, message: String }, - #[error("Error requesting ads: {0}")] - RequestAds(#[from] RequestAdsError), + #[error("Unexpected error ({code}): {message}")] + Unexpected { code: u16, message: String }, } -#[derive(Debug, thiserror::Error)] -pub enum RequestAdsError { - #[error("Error building ad requests from configs: {0}")] - BuildRequest(#[from] BuildRequestError), - - #[error(transparent)] - ContextId(#[from] context_id::ApiError), +pub fn check_http_status_for_error(response: &Response) -> Result<(), HTTPError> { + let status = response.status; - #[error("Error requesting ads from MARS: {0}")] - FetchAds(#[from] FetchAdsError), + if status == 200 { + return Ok(()); + } + let error_message = response.text(); + let error = match status { + 400 => HTTPError::BadRequest { + code: status, + message: error_message.to_string(), + }, + 500..=599 => HTTPError::Server { + code: status, + message: error_message.to_string(), + }, + _ => HTTPError::Unexpected { + code: status, + message: error_message.to_string(), + }, + }; + Err(error) } #[derive(Debug, thiserror::Error)] @@ -44,12 +52,18 @@ pub enum BuildRequestError { #[derive(Debug, thiserror::Error)] pub enum FetchAdsError { + #[error("Error building ad request: {0}")] + BuildRequest(#[from] BuildRequestError), + #[error("Could not fetch ads, MARS responded with: {0}")] HTTPError(#[from] HTTPError), #[error("JSON error: {0}")] Json(#[from] serde_json::Error), + #[error("OHTTP preflight failed: {0}")] + Preflight(#[from] CallbackRequestError), + #[error("Error sending request: {0}")] Request(#[from] viaduct::ViaductError), @@ -73,13 +87,13 @@ pub enum CallbackRequestError { } #[derive(Debug, thiserror::Error)] -pub enum RecordImpressionError { +pub enum RecordClickError { #[error("Callback request to MARS failed: {0}")] CallbackRequest(#[from] CallbackRequestError), } #[derive(Debug, thiserror::Error)] -pub enum RecordClickError { +pub enum RecordImpressionError { #[error("Callback request to MARS failed: {0}")] CallbackRequest(#[from] CallbackRequestError), } @@ -90,42 +104,6 @@ pub enum ReportAdError { CallbackRequest(#[from] CallbackRequestError), } -#[derive(Debug, thiserror::Error)] -pub enum HTTPError { - #[error("Bad request ({code}): {message}")] - BadRequest { code: u16, message: String }, - - #[error("Server error ({code}): {message}")] - Server { code: u16, message: String }, - - #[error("Unexpected error ({code}): {message}")] - Unexpected { code: u16, message: String }, -} - -pub fn check_http_status_for_error(response: &Response) -> Result<(), HTTPError> { - let status = response.status; - - if status == 200 { - return Ok(()); - } - let error_message = response.text(); - let error = match status { - 400 => HTTPError::BadRequest { - code: status, - message: error_message.to_string(), - }, - 500..=599 => HTTPError::Server { - code: status, - message: error_message.to_string(), - }, - _ => HTTPError::Unexpected { - code: status, - message: error_message.to_string(), - }, - }; - Err(error) -} - #[cfg(test)] mod tests { use super::*; diff --git a/components/ads-client/src/mars/preflight.rs b/components/ads-client/src/mars/preflight.rs new file mode 100644 index 0000000000..94a82dfbe8 --- /dev/null +++ b/components/ads-client/src/mars/preflight.rs @@ -0,0 +1,47 @@ +/* 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 serde::Deserialize; +use std::hash::{Hash, Hasher}; +use url::Url; +use viaduct::{Headers, Request}; + +/// Response from the MARS `/v1/ads-preflight` endpoint. +#[derive(Debug, Deserialize)] +pub struct PreflightResponse { + #[serde(default)] + pub geo_location: String, + #[serde(default)] + pub normalized_ua: String, +} + +impl From for Headers { + fn from(preflight: PreflightResponse) -> Self { + let mut headers = Headers::new(); + headers + .insert("X-Geo-Location", preflight.geo_location) + .expect("valid header"); + if !preflight.normalized_ua.is_empty() { + headers + .insert("X-User-Agent", preflight.normalized_ua) + .expect("valid header"); + } + headers + } +} + +pub(super) struct PreflightRequest(pub Url); + +impl Hash for PreflightRequest { + fn hash(&self, state: &mut H) { + self.0.as_str().hash(state); + } +} + +impl From for Request { + fn from(req: PreflightRequest) -> Self { + Request::get(req.0) + } +} diff --git a/components/ads-client/src/mars/report_reason.rs b/components/ads-client/src/mars/report_reason.rs new file mode 100644 index 0000000000..11677aeaeb --- /dev/null +++ b/components/ads-client/src/mars/report_reason.rs @@ -0,0 +1,21 @@ +/* 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/. +*/ + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ReportReason { + Inappropriate, + NotInterested, + SeenTooManyTimes, +} + +impl ReportReason { + pub fn as_str(&self) -> &'static str { + match self { + ReportReason::Inappropriate => "inappropriate", + ReportReason::NotInterested => "not_interested", + ReportReason::SeenTooManyTimes => "seen_too_many_times", + } + } +} diff --git a/components/ads-client/src/test_utils.rs b/components/ads-client/src/test_utils.rs index b9b9c14205..305278c54c 100644 --- a/components/ads-client/src/test_utils.rs +++ b/components/ads-client/src/test_utils.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use url::Url; -use crate::client::{ +use crate::mars::{ ad_request::{AdContentCategory, AdPlacementRequest, AdRequest, IABContentTaxonomy}, ad_response::{ AdCallbacks, AdImage, AdResponse, AdSpoc, AdTile, SpocFrequencyCaps, SpocRanking,