diff --git a/components/merino/src/lib.rs b/components/merino/src/lib.rs index f2c1d65343..5ce7393f12 100644 --- a/components/merino/src/lib.rs +++ b/components/merino/src/lib.rs @@ -20,4 +20,5 @@ pub mod curated_recommendations; pub mod suggest; +pub mod worldcup; uniffi::setup_scaffolding!("merino"); diff --git a/components/merino/src/worldcup/error.rs b/components/merino/src/worldcup/error.rs new file mode 100644 index 0000000000..2f5a420e60 --- /dev/null +++ b/components/merino/src/worldcup/error.rs @@ -0,0 +1,77 @@ +/* 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/. */ + +pub use error_support::error; +use error_support::{ErrorHandling, GetErrorHandling}; + +pub type Result = std::result::Result; +pub type ApiResult = std::result::Result; + +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum MerinoWorldCupApiError { + /// A network-level failure. + #[error("WorldCup network error: {reason}")] + Network { reason: String }, + + /// Any other error, e.g. HTTP errors, validation errors. + #[error("WorldCup error: code {code:?}, reason: {reason}")] + Other { code: Option, reason: String }, +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Failed to parse a URL. + #[error("URL parse error: {0}")] + UrlParse(#[from] url::ParseError), + + /// Failed to send the HTTP request. + #[error("Error sending request: {0}")] + Request(#[from] viaduct::ViaductError), + + /// The server rejected the request due to malformed syntax (HTTP 400). + #[error("Bad request ({code}): {message}")] + BadRequest { code: u16, message: String }, + + /// The server rejected the request due to invalid input (HTTP 422). + #[error("Validation error ({code}): {message}")] + Validation { code: u16, message: String }, + + /// The server encountered an internal error (HTTP 5xx). + #[error("Server error ({code}): {message}")] + Server { code: u16, message: String }, + + /// An unexpected HTTP status code was received. + #[error("Unexpected error ({code}): {message}")] + Unexpected { code: u16, message: String }, +} + +impl GetErrorHandling for Error { + type ExternalError = MerinoWorldCupApiError; + + fn get_error_handling(&self) -> ErrorHandling { + match self { + Self::Request { .. } => ErrorHandling::convert(MerinoWorldCupApiError::Network { + reason: self.to_string(), + }) + .log_warning(), + + Self::Validation { code, .. } + | Self::Server { code, .. } + | Self::Unexpected { code, .. } + | Self::BadRequest { code, .. } => { + ErrorHandling::convert(MerinoWorldCupApiError::Other { + code: Some(*code), + reason: self.to_string(), + }) + .report_error("merino-http-error") + } + + Self::UrlParse(_) => ErrorHandling::convert(MerinoWorldCupApiError::Other { + code: None, + reason: self.to_string(), + }) + .report_error("merino-unexpected"), + } + } +} diff --git a/components/merino/src/worldcup/http.rs b/components/merino/src/worldcup/http.rs new file mode 100644 index 0000000000..7ec088a5e1 --- /dev/null +++ b/components/merino/src/worldcup/http.rs @@ -0,0 +1,176 @@ +/* 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 url::Url; +use viaduct::{Client, ClientSettings, Request, Response}; + +use super::error::{Error, Result}; + +pub struct HttpClient; + +#[derive(Default)] +pub struct WorldCupQueryParams { + pub limit: Option, + pub teams: Option, + pub date: Option, +} + +pub trait HttpClientTrait { + fn make_teams_request(&self, url: Url, params: WorldCupQueryParams) + -> Result>; + fn make_matches_request( + &self, + url: Url, + params: WorldCupQueryParams, + ) -> Result>; + fn make_live_request(&self, url: Url, params: WorldCupQueryParams) -> Result>; +} + +impl HttpClientTrait for HttpClient { + fn make_teams_request( + &self, + url: Url, + params: WorldCupQueryParams, + ) -> Result> { + send_get(build_url(url, ¶ms)) + } + + fn make_matches_request( + &self, + url: Url, + params: WorldCupQueryParams, + ) -> Result> { + send_get(build_url(url, ¶ms)) + } + + fn make_live_request(&self, url: Url, params: WorldCupQueryParams) -> Result> { + send_get(build_url(url, ¶ms)) + } +} + +pub fn build_url(endpoint_url: Url, params: &WorldCupQueryParams) -> Url { + let mut url = endpoint_url; + { + let mut pairs = url.query_pairs_mut(); + if let Some(v) = params.limit { + pairs.append_pair("limit", &v.to_string()); + } + if let Some(v) = ¶ms.teams { + pairs.append_pair("teams", v); + } + if let Some(v) = ¶ms.date { + pairs.append_pair("date", v); + } + } + url +} + +fn send_get(url: Url) -> Result> { + let client = Client::with_ohttp_channel("merino", ClientSettings::default())?; + let request = Request::get(url).header("accept", "application/json")?; + let response = client.send_sync(request)?; + let status = response.status; + match status { + 200 => Ok(Some(response)), + 204 => Ok(None), + 400 => Err(Error::BadRequest { + code: status, + message: response.text().to_string(), + }), + 422 => Err(Error::Validation { + code: status, + message: response.text().to_string(), + }), + 500..=599 => Err(Error::Server { + code: status, + message: response.text().to_string(), + }), + _ => Err(Error::Unexpected { + code: status, + message: response.text().to_string(), + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const BASE_URL: &str = "https://merino.services.mozilla.com/api/v1/wcs/teams"; + + fn base_url() -> Url { + Url::parse(BASE_URL).unwrap() + } + + fn has_param(url: &Url, key: &str, value: &str) -> bool { + url.query_pairs().any(|(k, v)| k == key && v == value) + } + + #[test] + fn test_build_url_with_params() { + let options = WorldCupQueryParams { + limit: Some(10), + ..WorldCupQueryParams::default() + }; + let url = build_url(base_url(), &options); + assert!(has_param(&url, "limit", "10")); + } + + #[test] + fn test_build_url_with_teams() { + let options = WorldCupQueryParams { + teams: Some("FRA,ENG".to_string()), + ..WorldCupQueryParams::default() + }; + let url = build_url(base_url(), &options); + assert!(has_param(&url, "teams", "FRA,ENG")); + } + + #[test] + fn test_build_url_with_date() { + let options = WorldCupQueryParams { + date: Some("2026-06-15".to_string()), + ..WorldCupQueryParams::default() + }; + let url = build_url(base_url(), &options); + assert!(has_param(&url, "date", "2026-06-15")); + } + + #[test] + fn test_build_url_with_all_options() { + let options = WorldCupQueryParams { + limit: Some(5), + teams: Some("FRA,ENG".to_string()), + date: Some("2026-06-20".to_string()), + }; + let url = build_url(base_url(), &options); + assert!(has_param(&url, "limit", "5")); + assert!(has_param(&url, "teams", "FRA,ENG")); + assert!(has_param(&url, "date", "2026-06-20")); + } + + #[test] + fn test_build_url_omits_none_options() { + let url = build_url(base_url(), &WorldCupQueryParams::default()); + let keys: Vec<_> = url.query_pairs().map(|(k, _)| k.into_owned()).collect(); + assert_eq!(keys.len(), 0); + } + + #[test] + fn test_build_url_full_string() { + let options = WorldCupQueryParams { + limit: Some(3), + teams: Some("FRA".to_string()), + date: Some("2026-06-25".to_string()), + }; + let url = build_url(base_url(), &options); + assert_eq!( + url.to_string(), + "https://merino.services.mozilla.com/api/v1/wcs/teams\ + ?limit=3\ + &teams=FRA\ + &date=2026-06-25" + ); + } +} diff --git a/components/merino/src/worldcup/mod.rs b/components/merino/src/worldcup/mod.rs new file mode 100644 index 0000000000..64939fd8c6 --- /dev/null +++ b/components/merino/src/worldcup/mod.rs @@ -0,0 +1,159 @@ +/* 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/. */ + +mod error; +mod http; +mod schema; +#[cfg(test)] +mod tests; + +use error_support::handle_error; +use url::Url; + +pub use error::{ApiResult, Error, MerinoWorldCupApiError, Result}; +pub use schema::WorldCupOptions; + +const DEFAULT_BASE_HOST: &str = "https://merino.services.mozilla.com"; + +/// A client for the merino wcs endpoint. +/// +/// Use [`WorldCupClient::new`] to create an instance, then call +/// [`WordCupClient::get_*`] to fetch wcs content. +#[derive(uniffi::Object)] +pub struct WorldCupClient { + inner: WorldCupClientInner, + base_url: Url, +} + +struct WorldCupClientInner { + http_client: T, +} + +#[derive(Default)] +pub struct WorldCupClientBuilder { + base_host: Option, +} + +impl WorldCupClientBuilder { + pub fn new() -> Self { + Self::default() + } + + pub fn base_host(mut self, base_host: String) -> Self { + self.base_host = Some(base_host); + self + } + + pub fn build(self) -> Result { + let base_host = self + .base_host + .unwrap_or_else(|| DEFAULT_BASE_HOST.to_string()); + + let base_url = Url::parse(&format!("{}/api/v1/wcs/", base_host))?; + + Ok(WorldCupClient { + inner: WorldCupClientInner::new()?, + base_url, + }) + } +} + +#[uniffi::export] +impl WorldCupClient { + /// Creates a new `WorldCupClient` from the given configuration. + #[uniffi::constructor] + #[handle_error(Error)] + pub fn new(base_host: Option) -> ApiResult { + let mut builder = WorldCupClientBuilder::new(); + + if let Some(host) = base_host { + builder = builder.base_host(host); + } + + builder.build() + } + + #[handle_error(Error)] + /// Fetches teams from the merino wcs endpoint + pub fn get_teams(&self, options: WorldCupOptions) -> ApiResult> { + let url = self.base_url.join("teams")?; + let response = self.inner.get_teams(url, options)?; + Ok(response.map(|r| r.text().to_string())) + } + + #[handle_error(Error)] + /// Fetches matches from merino wcs endpoint + pub fn get_matches(&self, options: WorldCupOptions) -> ApiResult> { + let url = self.base_url.join("matches")?; + let response = self.inner.get_matches(url, options)?; + Ok(response.map(|r| r.text().to_string())) + } + + #[handle_error(Error)] + /// Fetches live info from merino wcs endpoint + pub fn get_live(&self, options: WorldCupOptions) -> ApiResult> { + let url = self.base_url.join("live")?; + let response = self.inner.get_live(url, options)?; + Ok(response.map(|r| r.text().to_string())) + } +} + +impl WorldCupClientInner { + pub fn new() -> Result { + Ok(Self { + http_client: http::HttpClient, + }) + } +} + +impl WorldCupClientInner { + fn params(options: WorldCupOptions) -> http::WorldCupQueryParams { + let teams = options + .teams + .as_ref() + .filter(|v| !v.is_empty()) + .map(|v| v.join(",")); + http::WorldCupQueryParams { + limit: options.limit, + teams, + date: options.date, + } + } + + pub fn get_teams( + &self, + url: Url, + options: WorldCupOptions, + ) -> Result> { + self.http_client + .make_teams_request(url, Self::params(options)) + } + + pub fn get_matches( + &self, + url: Url, + options: WorldCupOptions, + ) -> Result> { + self.http_client + .make_matches_request(url, Self::params(options)) + } + + pub fn get_live( + &self, + url: Url, + options: WorldCupOptions, + ) -> Result> { + self.http_client + .make_live_request(url, Self::params(options)) + } +} + +#[cfg(test)] +impl WorldCupClientInner { + pub fn new_with_client(client: T) -> Self { + Self { + http_client: client, + } + } +} diff --git a/components/merino/src/worldcup/schema.rs b/components/merino/src/worldcup/schema.rs new file mode 100644 index 0000000000..918c5ae67b --- /dev/null +++ b/components/merino/src/worldcup/schema.rs @@ -0,0 +1,13 @@ +use uniffi::Record; + +/// Options for world cup endpoint requests. +/// All fields are optional — omitted fields are not sent to merino. +#[derive(Clone, Debug, Record)] +pub struct WorldCupOptions { + /// Maximum number of results to return. + pub limit: Option, + /// Filter results by team(s) (e.g. `["FRA", "ENG"]`). + pub teams: Option>, + /// Filter results by date (e.g. `"2026-06-15"`). + pub date: Option, +} diff --git a/components/merino/src/worldcup/tests.rs b/components/merino/src/worldcup/tests.rs new file mode 100644 index 0000000000..1eba4b52ce --- /dev/null +++ b/components/merino/src/worldcup/tests.rs @@ -0,0 +1,760 @@ +/* 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::*; +use url::Url; +use viaduct::{Headers, Method, Response}; +const TEAMS_RESPONSE: &str = r#"{ + "teams": [ + { + "key": "ENG", + "global_team_id": 1001, + "name": "England", + "region": "ENG", + "colors": ["White", "Red", "Navy Blue"], + "icon": "https://example.com/logos/england.png", + "group": "Group A", + "eliminated": false + }, + { + "key": "BRA", + "global_team_id": 1002, + "name": "Brazil", + "region": "BRA", + "colors": ["Yellow", "Green", "Blue"], + "icon": "https://example.com/logos/brazil.png", + "group": "Group A", + "eliminated": false + }] +}"#; + +const GROUP_RESPONSE: &str = r#"{ + "Group A": [ + { + "key": "ENG", + "global_team_id": 1001, + "name": "England", + "region": "ENG", + "colors": ["White", "Red", "Navy Blue"], + "icon": "https://example.com/logos/england.png", + "group": "Group A", + "eliminated": false + }, + { + "key": "BRA", + "global_team_id": 1002, + "name": "Brazil", + "region": "BRA", + "colors": ["Yellow", "Green", "Blue"], + "icon": "https://example.com/logos/brazil.png", + "group": "Group A", + "eliminated": false + }], + "Group B": [ + { + "key": "GER", + "global_team_id": 1003, + "name": "Germany", + "region": "DEU", + "colors": ["Black", "White", "Gold"], + "icon": "https://example.com/logos/germany.png", + "group": "Group B", + "eliminated": true + }, + { + "key": "JPN", + "global_team_id": 1004, + "name": "Japan", + "region": "JPN", + "colors": ["Blue", "White", "Red"], + "icon": "https://example.com/logos/japan.png", + "group": "Group B", + "eliminated": false + }] +}"#; + +const LIVE_RESPONSE: &str = r#"{ + "current": [ + { + "date": "2026-04-30T14:00:00+00:00", + "global_event_id": 1002, + "home_team": { + "key": "ENG", + "global_team_id": 90000005, + "name": "England", + "region": "ENG", + "colors": [ + "White", + "Red" + ], + "icon_url": "https://storage.googleapis.com/merino-images-prod/logos/nations/nations_gb-eng.png", + "group": "Group C", + "eliminated": false, + "standing": { + "wins": 0, + "losses": 0, + "draws": 0, + "points": 0 + } + }, + "away_team": { + "key": "USA", + "global_team_id": 90000006, + "name": "United States", + "region": "USA", + "colors": [ + "Navy", + "White", + "Red" + ], + "icon_url": "https://storage.googleapis.com/merino-images-prod/logos/nations/nations_us.png", + "group": "Group C", + "eliminated": false, + "standing": { + "wins": 0, + "losses": 0, + "draws": 0, + "points": 0 + } + }, + "period": "2", + "home_score": 1, + "away_score": 0, + "home_extra": null, + "away_extra": null, + "home_penalty": null, + "away_penalty": null, + "clock": "67", + "updated": 1777554000, + "status": "In Progress", + "status_type": "live", + "query": null, + "sport": "soccer" + } + ] +}"#; + +const MATCH_RESPONSE: &str = r#"{ + "previous": [ + { + "date": "2026-04-29T14:00:00+00:00", + "global_event_id": 1000, + "home_team": { + "key": "BRA", + "global_team_id": 90000001, + "name": "Brazil", + "region": "BRA", + "colors": [ + "Yellow", + "Green", + "Blue" + ], + "icon_url": "https://storage.googleapis.com/merino-images-prod/logos/nations/nations_br.png", + "group": "Group A", + "eliminated": false, + "standing": { + "wins": 0, + "losses": 0, + "draws": 0, + "points": 0 + } + }, + "away_team": { + "key": "ARG", + "global_team_id": 90000002, + "name": "Argentina", + "region": "ARG", + "colors": [ + "Sky Blue", + "White" + ], + "icon_url": "https://storage.googleapis.com/merino-images-prod/logos/nations/nations_ar.png", + "group": "Group A", + "eliminated": false, + "standing": { + "wins": 0, + "losses": 0, + "draws": 0, + "points": 0 + } + }, + "period": "FT", + "home_score": 2, + "away_score": 1, + "home_extra": null, + "away_extra": null, + "home_penalty": null, + "away_penalty": null, + "clock": "90", + "updated": 1777467600, + "status": "Final", + "status_type": "past", + "query": null, + "sport": "soccer" + }, + { + "date": "2026-04-29T18:00:00+00:00", + "global_event_id": 1001, + "home_team": { + "key": "GER", + "global_team_id": 90000003, + "name": "Germany", + "region": "GER", + "colors": [ + "Black", + "Red", + "Yellow" + ], + "icon_url": "https://storage.googleapis.com/merino-images-prod/logos/nations/nations_de.png", + "group": "Group B", + "eliminated": false, + "standing": { + "wins": 0, + "losses": 0, + "draws": 0, + "points": 0 + } + }, + "away_team": { + "key": "FRA", + "global_team_id": 90000004, + "name": "France", + "region": "FRA", + "colors": [ + "Blue", + "White", + "Red" + ], + "icon_url": "https://storage.googleapis.com/merino-images-prod/logos/nations/nations_fr.png", + "group": "Group B", + "eliminated": false, + "standing": { + "wins": 0, + "losses": 0, + "draws": 0, + "points": 0 + } + }, + "period": "FT(P)", + "home_score": 1, + "away_score": 1, + "home_extra": 1, + "away_extra": 1, + "home_penalty": 5, + "away_penalty": 4, + "clock": "120", + "updated": 1777482000, + "status": "Final", + "status_type": "past", + "query": null, + "sport": "soccer" + } + ], + "current": [ + { + "date": "2026-04-30T14:00:00+00:00", + "global_event_id": 1002, + "home_team": { + "key": "ENG", + "global_team_id": 90000005, + "name": "England", + "region": "ENG", + "colors": [ + "White", + "Red" + ], + "icon_url": "https://storage.googleapis.com/merino-images-prod/logos/nations/nations_gb-eng.png", + "group": "Group C", + "eliminated": false, + "standing": { + "wins": 0, + "losses": 0, + "draws": 0, + "points": 0 + } + }, + "away_team": { + "key": "USA", + "global_team_id": 90000006, + "name": "United States", + "region": "USA", + "colors": [ + "Navy", + "White", + "Red" + ], + "icon_url": "https://storage.googleapis.com/merino-images-prod/logos/nations/nations_us.png", + "group": "Group C", + "eliminated": false, + "standing": { + "wins": 0, + "losses": 0, + "draws": 0, + "points": 0 + } + }, + "period": "2", + "home_score": 1, + "away_score": 0, + "home_extra": null, + "away_extra": null, + "home_penalty": null, + "away_penalty": null, + "clock": "67", + "updated": 1777554000, + "status": "In Progress", + "status_type": "live", + "query": null, + "sport": "soccer" + }, + { + "date": "2026-04-30T17:00:00+00:00", + "global_event_id": 1003, + "home_team": { + "key": "BRA", + "global_team_id": 90000001, + "name": "Brazil", + "region": "BRA", + "colors": [ + "Yellow", + "Green", + "Blue" + ], + "icon_url": "https://storage.googleapis.com/merino-images-prod/logos/nations/nations_br.png", + "group": "Group A", + "eliminated": false, + "standing": { + "wins": 0, + "losses": 0, + "draws": 0, + "points": 0 + } + }, + "away_team": { + "key": "GER", + "global_team_id": 90000003, + "name": "Germany", + "region": "GER", + "colors": [ + "Black", + "Red", + "Yellow" + ], + "icon_url": "https://storage.googleapis.com/merino-images-prod/logos/nations/nations_de.png", + "group": "Group B", + "eliminated": false, + "standing": { + "wins": 0, + "losses": 0, + "draws": 0, + "points": 0 + } + }, + "period": "ET", + "home_score": 2, + "away_score": 2, + "home_extra": null, + "away_extra": null, + "home_penalty": null, + "away_penalty": null, + "clock": "90+15", + "updated": 1777564800, + "status": "In Progress", + "status_type": "live", + "query": null, + "sport": "soccer" + } + ], + "next": [ + { + "date": "2026-05-01T15:00:00+00:00", + "global_event_id": 1004, + "home_team": { + "key": "ARG", + "global_team_id": 90000002, + "name": "Argentina", + "region": "ARG", + "colors": [ + "Sky Blue", + "White" + ], + "icon_url": "https://storage.googleapis.com/merino-images-prod/logos/nations/nations_ar.png", + "group": "Group A", + "eliminated": false, + "standing": { + "wins": 0, + "losses": 0, + "draws": 0, + "points": 0 + } + }, + "away_team": { + "key": "ENG", + "global_team_id": 90000005, + "name": "England", + "region": "ENG", + "colors": [ + "White", + "Red" + ], + "icon_url": "https://storage.googleapis.com/merino-images-prod/logos/nations/nations_gb-eng.png", + "group": "Group C", + "eliminated": false, + "standing": { + "wins": 0, + "losses": 0, + "draws": 0, + "points": 0 + } + }, + "period": "1", + "home_score": null, + "away_score": null, + "home_extra": null, + "away_extra": null, + "home_penalty": null, + "away_penalty": null, + "clock": "0", + "updated": 1777644000, + "status": "Scheduled", + "status_type": "scheduled", + "query": null, + "sport": "soccer" + }, + { + "date": "2026-05-01T19:00:00+00:00", + "global_event_id": 1005, + "home_team": { + "key": "FRA", + "global_team_id": 90000004, + "name": "France", + "region": "FRA", + "colors": [ + "Blue", + "White", + "Red" + ], + "icon_url": "https://storage.googleapis.com/merino-images-prod/logos/nations/nations_fr.png", + "group": "Group B", + "eliminated": false, + "standing": { + "wins": 0, + "losses": 0, + "draws": 0, + "points": 0 + } + }, + "away_team": { + "key": "USA", + "global_team_id": 90000006, + "name": "United States", + "region": "USA", + "colors": [ + "Navy", + "White", + "Red" + ], + "icon_url": "https://storage.googleapis.com/merino-images-prod/logos/nations/nations_us.png", + "group": "Group C", + "eliminated": false, + "standing": { + "wins": 0, + "losses": 0, + "draws": 0, + "points": 0 + } + }, + "period": "1", + "home_score": null, + "away_score": null, + "home_extra": null, + "away_extra": null, + "home_penalty": null, + "away_penalty": null, + "clock": "0", + "updated": 1777658400, + "status": "Scheduled", + "status_type": "scheduled", + "query": null, + "sport": "soccer" + } + ] +}"#; + +fn make_response(status: u16, body: &str, url: Url) -> Response { + Response { + request_method: Method::Get, + url, + status, + headers: Headers::new(), + body: body.as_bytes().to_vec(), + } +} + +fn default_options() -> WorldCupOptions { + WorldCupOptions { + limit: None, + teams: None, + date: None, + } +} + +fn base_url() -> Url { + WorldCupClientBuilder::new().build().unwrap().base_url +} + +struct FakeHttpClientSuccess; + +impl http::HttpClientTrait for FakeHttpClientSuccess { + fn make_teams_request( + &self, + url: Url, + _params: http::WorldCupQueryParams, + ) -> Result> { + Ok(Some(make_response(200, TEAMS_RESPONSE, url))) + } + fn make_matches_request( + &self, + url: Url, + _params: http::WorldCupQueryParams, + ) -> Result> { + Ok(Some(make_response(200, MATCH_RESPONSE, url))) + } + fn make_live_request( + &self, + url: Url, + _params: http::WorldCupQueryParams, + ) -> Result> { + Ok(Some(make_response(200, LIVE_RESPONSE, url))) + } +} + +struct FakeHttpClientNoContent; + +impl http::HttpClientTrait for FakeHttpClientNoContent { + fn make_teams_request( + &self, + _url: Url, + _params: http::WorldCupQueryParams, + ) -> Result> { + Ok(None) + } + fn make_matches_request( + &self, + _url: Url, + _params: http::WorldCupQueryParams, + ) -> Result> { + Ok(None) + } + fn make_live_request( + &self, + _url: Url, + _params: http::WorldCupQueryParams, + ) -> Result> { + Ok(None) + } +} + +struct FakeHttpClientServerError; + +impl http::HttpClientTrait for FakeHttpClientServerError { + fn make_teams_request( + &self, + _url: Url, + _params: http::WorldCupQueryParams, + ) -> Result> { + Err(Error::Server { + code: 500, + message: "Internal server error".to_string(), + }) + } + fn make_matches_request( + &self, + _url: Url, + _params: http::WorldCupQueryParams, + ) -> Result> { + Err(Error::Server { + code: 500, + message: "Internal server error".to_string(), + }) + } + fn make_live_request( + &self, + _url: Url, + _params: http::WorldCupQueryParams, + ) -> Result> { + Err(Error::Server { + code: 500, + message: "Internal server error".to_string(), + }) + } +} + +struct FakeCapturingClient { + captured_url: std::sync::Arc>>, +} + +impl http::HttpClientTrait for FakeCapturingClient { + fn make_teams_request( + &self, + url: Url, + _params: http::WorldCupQueryParams, + ) -> Result> { + *self.captured_url.lock().unwrap() = Some(url.clone()); + Ok(Some(make_response(200, "{}", url))) + } + fn make_matches_request( + &self, + url: Url, + params: http::WorldCupQueryParams, + ) -> Result> { + *self.captured_url.lock().unwrap() = Some(http::build_url(url.clone(), ¶ms)); + Ok(Some(make_response(200, "{}", url))) + } + fn make_live_request( + &self, + url: Url, + _params: http::WorldCupQueryParams, + ) -> Result> { + *self.captured_url.lock().unwrap() = Some(url.clone()); + Ok(Some(make_response(200, "{}", url))) + } +} + +#[test] +fn test_get_teams_success() { + let client = WorldCupClientInner::new_with_client(FakeHttpClientSuccess); + let result = client.get_teams(base_url().join("teams").unwrap(), default_options()); + assert!(result.is_ok()); + assert_eq!(result.unwrap().unwrap().text(), TEAMS_RESPONSE); +} + +#[test] +fn test_get_matches_success() { + let client = WorldCupClientInner::new_with_client(FakeHttpClientSuccess); + let result = client.get_matches(base_url().join("matches").unwrap(), default_options()); + assert!(result.is_ok()); + assert_eq!(result.unwrap().unwrap().text(), MATCH_RESPONSE); +} + +#[test] +fn test_get_live_success() { + let client = WorldCupClientInner::new_with_client(FakeHttpClientSuccess); + let result = client.get_live(base_url().join("live").unwrap(), default_options()); + assert!(result.is_ok()); + assert_eq!(result.unwrap().unwrap().text(), LIVE_RESPONSE); +} + +#[test] +fn test_no_content_returns_none() { + let client = WorldCupClientInner::new_with_client(FakeHttpClientNoContent); + + let result_teams = client.get_teams(base_url().join("teams").unwrap(), default_options()); + assert!(result_teams.is_ok()); + assert!(result_teams.unwrap().is_none()); + + let result_matches = client.get_matches(base_url().join("matches").unwrap(), default_options()); + assert!(result_matches.is_ok()); + assert!(result_matches.unwrap().is_none()); + + let result_live = client.get_live(base_url().join("live").unwrap(), default_options()); + assert!(result_live.is_ok()); + assert!(result_live.unwrap().is_none()); +} + +#[test] +fn test_server_error() { + let client = WorldCupClientInner::new_with_client(FakeHttpClientServerError); + let result = client.get_teams(base_url().join("teams").unwrap(), default_options()); + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + Error::Server { code: 500, .. } + )); +} + +#[test] +fn test_builder_uses_default_base_host() { + let client = WorldCupClientBuilder::new().build().unwrap(); + assert_eq!( + client.base_url.as_str(), + "https://merino.services.mozilla.com/api/v1/wcs/" + ); +} + +#[test] +fn test_builder_uses_custom_base_host() { + let client = WorldCupClientBuilder::new() + .base_host("https://stage.merino.services.mozilla.com".to_string()) + .build() + .unwrap(); + assert_eq!( + client.base_url.as_str(), + "https://stage.merino.services.mozilla.com/api/v1/wcs/" + ); +} + +#[test] +fn test_teams_endpoint_url() { + let captured_url = std::sync::Arc::new(std::sync::Mutex::new(None::)); + let client_inner = WorldCupClientInner::new_with_client(FakeCapturingClient { + captured_url: captured_url.clone(), + }); + let client = WorldCupClientBuilder::new().build().unwrap(); + let _ = client_inner.get_teams(client.base_url.join("teams").unwrap(), default_options()); + let captured = captured_url.lock().unwrap(); + assert_eq!( + captured.as_ref().unwrap().as_str(), + "https://merino.services.mozilla.com/api/v1/wcs/teams" + ); +} + +#[test] +fn test_matches_endpoint_url_with_limit() { + let captured_url = std::sync::Arc::new(std::sync::Mutex::new(None::)); + let client_inner = WorldCupClientInner::new_with_client(FakeCapturingClient { + captured_url: captured_url.clone(), + }); + let client = WorldCupClientBuilder::new().build().unwrap(); + let _ = client_inner.get_matches( + client.base_url.join("matches").unwrap(), + WorldCupOptions { + limit: Some(2), + teams: None, + date: None, + }, + ); + let captured = captured_url.lock().unwrap(); + assert_eq!( + captured.as_ref().unwrap().as_str(), + "https://merino.services.mozilla.com/api/v1/wcs/matches?limit=2" + ); +} + +#[test] +fn test_live_endpoint_url() { + let captured_url = std::sync::Arc::new(std::sync::Mutex::new(None::)); + let client_inner = WorldCupClientInner::new_with_client(FakeCapturingClient { + captured_url: captured_url.clone(), + }); + let client = WorldCupClientBuilder::new().build().unwrap(); + let _ = client_inner.get_live(client.base_url.join("live").unwrap(), default_options()); + let captured = captured_url.lock().unwrap(); + assert_eq!( + captured.as_ref().unwrap().as_str(), + "https://merino.services.mozilla.com/api/v1/wcs/live" + ); +} + +#[test] +fn test_builder_fails_with_invalid_base_host() { + let result = WorldCupClientBuilder::new() + .base_host("not a valid url".to_string()) + .build(); + match result { + Err(Error::UrlParse(_)) => {} + Err(other) => panic!("Expected UrlParse error, got: {:?}", other), + Ok(_) => panic!("Expected error for invalid base_host"), + } +} diff --git a/examples/merino-cli/src/main.rs b/examples/merino-cli/src/main.rs index 19e0d53366..ffac6707bf 100644 --- a/examples/merino-cli/src/main.rs +++ b/examples/merino-cli/src/main.rs @@ -11,6 +11,7 @@ use merino::curated_recommendations::models::request::{ }; use merino::curated_recommendations::CuratedRecommendationsClient; use merino::suggest::{SuggestClient, SuggestConfig, SuggestOptions}; +use merino::worldcup::{WorldCupClient, WorldCupOptions}; use viaduct::{configure_ohttp_channel, OhttpConfig}; #[derive(Debug, Parser)] @@ -85,6 +86,41 @@ enum Commands { #[arg(long)] accept_language: Option, }, + /// Fetch World Cup data + WorldCup { + /// OHTTP relay URL + #[arg(long, default_value = "https://ohttp-merino.mozilla.fastly-edge.com")] + relay_url: String, + + /// OHTTP gateway host + #[arg(long, default_value = "ohttp-gateway-merino.services.mozilla.com")] + gateway_host: String, + + /// Maximum number of results to return + #[arg(long)] + limit: Option, + + /// Filter by team codes (e.g. --teams FRA --teams ENG) + #[arg(long)] + teams: Option>, + + /// Filter by date (e.g. 2026-06-15) + #[arg(long)] + date: Option, + + #[command(subcommand)] + endpoint: WorldCupEndpoint, + }, +} + +#[derive(Debug, Subcommand)] +enum WorldCupEndpoint { + /// Fetch teams + Teams, + /// Fetch matches + Matches, + /// Fetch live match data + Live, } fn main() -> Result<()> { @@ -154,6 +190,36 @@ fn main() -> Result<()> { None => println!("No suggestions available (204 No Content)"), } } + Commands::WorldCup { + relay_url, + gateway_host, + limit, + teams, + date, + endpoint, + } => { + configure_ohttp_channel( + "merino".to_string(), + OhttpConfig { + relay_url, + gateway_host, + }, + )?; + let client = WorldCupClient::new(cli.base_host)?; + let options = WorldCupOptions { limit, teams, date }; + let result = match endpoint { + WorldCupEndpoint::Teams => client.get_teams(options), + WorldCupEndpoint::Matches => client.get_matches(options), + WorldCupEndpoint::Live => client.get_live(options), + }; + match result? { + Some(response) => { + let json: serde_json::Value = serde_json::from_str(&response)?; + println!("{}", serde_json::to_string_pretty(&json)?); + } + None => println!("No data available (204 No Content)"), + } + } } Ok(())