From 870b3bee26e57fbc6dea8c652cccae2047dfad36 Mon Sep 17 00:00:00 2001 From: Tif Tran Date: Thu, 30 Apr 2026 10:40:08 -0700 Subject: [PATCH 1/9] [DISCO-4151] Merino: add client for wcs --- components/merino/src/lib.rs | 1 + components/merino/src/worldcup/error.rs | 77 +++ components/merino/src/worldcup/http.rs | 200 ++++++ components/merino/src/worldcup/mod.rs | 176 +++++ components/merino/src/worldcup/schema.rs | 13 + components/merino/src/worldcup/tests.rs | 819 +++++++++++++++++++++++ 6 files changed, 1286 insertions(+) create mode 100644 components/merino/src/worldcup/error.rs create mode 100644 components/merino/src/worldcup/http.rs create mode 100644 components/merino/src/worldcup/mod.rs create mode 100644 components/merino/src/worldcup/schema.rs create mode 100644 components/merino/src/worldcup/tests.rs 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..444355e8c6 --- /dev/null +++ b/components/merino/src/worldcup/http.rs @@ -0,0 +1,200 @@ +/* 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<'a> { + pub limit: Option, + pub teams: Option, + pub date: Option<&'a str>, +} + +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_groups_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_groups_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) = params.date { + pairs.append_pair("date", v); + } + } + url +} + +#[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"), + ..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"), + }; + 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"), + }; + 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" + ); + } +} + +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(), + }), + } +} diff --git a/components/merino/src/worldcup/mod.rs b/components/merino/src/worldcup/mod.rs new file mode 100644 index 0000000000..c735af00bf --- /dev/null +++ b/components/merino/src/worldcup/mod.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/. */ + +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 groups from merino wcs endpoint + pub fn get_groups(&self, options: WorldCupOptions) -> ApiResult> { + let url = self.base_url.join("groups")?; + let response = self.inner.get_groups(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.as_deref(), + } + } + + 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_groups( + &self, + url: Url, + options: WorldCupOptions, + ) -> Result> { + self.http_client + .make_groups_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..0d15f1286e --- /dev/null +++ b/components/merino/src/worldcup/tests.rs @@ -0,0 +1,819 @@ +/* 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_groups_request( + &self, + url: Url, + _params: http::WorldCupQueryParams<'_>, + ) -> Result> { + Ok(Some(make_response(200, GROUP_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_groups_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_groups_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_groups_request( + &self, + url: Url, + _params: http::WorldCupQueryParams<'_>, + ) -> Result> { + *self.captured_url.lock().unwrap() = Some(url.clone()); + 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_groups_success() { + let client = WorldCupClientInner::new_with_client(FakeHttpClientSuccess); + let result = client.get_groups(base_url().join("groups").unwrap(), default_options()); + assert!(result.is_ok()); + assert_eq!(result.unwrap().unwrap().text(), GROUP_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_groups = client.get_groups(base_url().join("groups").unwrap(), default_options()); + assert!(result_groups.is_ok()); + assert!(result_groups.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_groups_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_groups(client.base_url.join("groups").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/groups" + ); +} + +#[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"), + } +} From 0eabb7b4ff7f9b57d60bb5e985b5cbc1384e8fb9 Mon Sep 17 00:00:00 2001 From: Tif Tran Date: Thu, 30 Apr 2026 20:54:30 -0700 Subject: [PATCH 2/9] typing changes --- components/merino/src/worldcup/http.rs | 97 +++++++++++-------------- components/merino/src/worldcup/mod.rs | 12 +-- components/merino/src/worldcup/tests.rs | 32 ++++---- 3 files changed, 65 insertions(+), 76 deletions(-) diff --git a/components/merino/src/worldcup/http.rs b/components/merino/src/worldcup/http.rs index 444355e8c6..80f0806081 100644 --- a/components/merino/src/worldcup/http.rs +++ b/components/merino/src/worldcup/http.rs @@ -10,40 +10,33 @@ use super::error::{Error, Result}; pub struct HttpClient; #[derive(Default)] -pub struct WorldCupQueryParams<'a> { +pub struct WorldCupQueryParams { pub limit: Option, pub teams: Option, - pub date: Option<&'a str>, + pub date: Option, } pub trait HttpClientTrait { - fn make_teams_request( - &self, - url: Url, - params: WorldCupQueryParams<'_>, - ) -> Result>; + fn make_teams_request(&self, url: Url, params: WorldCupQueryParams) + -> Result>; fn make_matches_request( &self, url: Url, - params: WorldCupQueryParams<'_>, + params: WorldCupQueryParams, ) -> Result>; fn make_groups_request( &self, url: Url, - params: WorldCupQueryParams<'_>, - ) -> Result>; - fn make_live_request( - &self, - url: Url, - params: WorldCupQueryParams<'_>, + 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<'_>, + params: WorldCupQueryParams, ) -> Result> { send_get(build_url(url, ¶ms)) } @@ -51,7 +44,7 @@ impl HttpClientTrait for HttpClient { fn make_matches_request( &self, url: Url, - params: WorldCupQueryParams<'_>, + params: WorldCupQueryParams, ) -> Result> { send_get(build_url(url, ¶ms)) } @@ -59,21 +52,17 @@ impl HttpClientTrait for HttpClient { fn make_groups_request( &self, url: Url, - params: WorldCupQueryParams<'_>, + params: WorldCupQueryParams, ) -> Result> { send_get(build_url(url, ¶ms)) } - fn make_live_request( - &self, - url: Url, - params: WorldCupQueryParams<'_>, - ) -> Result> { + 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 { +pub fn build_url(endpoint_url: Url, params: &WorldCupQueryParams) -> Url { let mut url = endpoint_url; { let mut pairs = url.query_pairs_mut(); @@ -83,13 +72,40 @@ pub fn build_url(endpoint_url: Url, params: &WorldCupQueryParams<'_>) -> Url { if let Some(v) = ¶ms.teams { pairs.append_pair("teams", v); } - if let Some(v) = params.date { + 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::*; @@ -127,7 +143,7 @@ mod tests { #[test] fn test_build_url_with_date() { let options = WorldCupQueryParams { - date: Some("2026-06-15"), + date: Some("2026-06-15".to_string()), ..WorldCupQueryParams::default() }; let url = build_url(base_url(), &options); @@ -139,7 +155,7 @@ mod tests { let options = WorldCupQueryParams { limit: Some(5), teams: Some("FRA,ENG".to_string()), - date: Some("2026-06-20"), + date: Some("2026-06-20".to_string()), }; let url = build_url(base_url(), &options); assert!(has_param(&url, "limit", "5")); @@ -159,7 +175,7 @@ mod tests { let options = WorldCupQueryParams { limit: Some(3), teams: Some("FRA".to_string()), - date: Some("2026-06-25"), + date: Some("2026-06-25".to_string()), }; let url = build_url(base_url(), &options); assert_eq!( @@ -171,30 +187,3 @@ mod tests { ); } } - -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(), - }), - } -} diff --git a/components/merino/src/worldcup/mod.rs b/components/merino/src/worldcup/mod.rs index c735af00bf..ef57868bef 100644 --- a/components/merino/src/worldcup/mod.rs +++ b/components/merino/src/worldcup/mod.rs @@ -116,7 +116,7 @@ impl WorldCupClientInner { } impl WorldCupClientInner { - fn params(options: &WorldCupOptions) -> http::WorldCupQueryParams<'_> { + fn params(options: WorldCupOptions) -> http::WorldCupQueryParams { let teams = options .teams .as_ref() @@ -125,7 +125,7 @@ impl WorldCupClientInner { http::WorldCupQueryParams { limit: options.limit, teams, - date: options.date.as_deref(), + date: options.date, } } @@ -135,7 +135,7 @@ impl WorldCupClientInner { options: WorldCupOptions, ) -> Result> { self.http_client - .make_teams_request(url, Self::params(&options)) + .make_teams_request(url, Self::params(options)) } pub fn get_matches( @@ -144,7 +144,7 @@ impl WorldCupClientInner { options: WorldCupOptions, ) -> Result> { self.http_client - .make_matches_request(url, Self::params(&options)) + .make_matches_request(url, Self::params(options)) } pub fn get_groups( @@ -153,7 +153,7 @@ impl WorldCupClientInner { options: WorldCupOptions, ) -> Result> { self.http_client - .make_groups_request(url, Self::params(&options)) + .make_groups_request(url, Self::params(options)) } pub fn get_live( @@ -162,7 +162,7 @@ impl WorldCupClientInner { options: WorldCupOptions, ) -> Result> { self.http_client - .make_live_request(url, Self::params(&options)) + .make_live_request(url, Self::params(options)) } } diff --git a/components/merino/src/worldcup/tests.rs b/components/merino/src/worldcup/tests.rs index 0d15f1286e..a6b56358f6 100644 --- a/components/merino/src/worldcup/tests.rs +++ b/components/merino/src/worldcup/tests.rs @@ -510,28 +510,28 @@ impl http::HttpClientTrait for FakeHttpClientSuccess { fn make_teams_request( &self, url: Url, - _params: http::WorldCupQueryParams<'_>, + _params: http::WorldCupQueryParams, ) -> Result> { Ok(Some(make_response(200, TEAMS_RESPONSE, url))) } fn make_matches_request( &self, url: Url, - _params: http::WorldCupQueryParams<'_>, + _params: http::WorldCupQueryParams, ) -> Result> { Ok(Some(make_response(200, MATCH_RESPONSE, url))) } fn make_groups_request( &self, url: Url, - _params: http::WorldCupQueryParams<'_>, + _params: http::WorldCupQueryParams, ) -> Result> { Ok(Some(make_response(200, GROUP_RESPONSE, url))) } fn make_live_request( &self, url: Url, - _params: http::WorldCupQueryParams<'_>, + _params: http::WorldCupQueryParams, ) -> Result> { Ok(Some(make_response(200, LIVE_RESPONSE, url))) } @@ -543,28 +543,28 @@ impl http::HttpClientTrait for FakeHttpClientNoContent { fn make_teams_request( &self, _url: Url, - _params: http::WorldCupQueryParams<'_>, + _params: http::WorldCupQueryParams, ) -> Result> { Ok(None) } fn make_matches_request( &self, _url: Url, - _params: http::WorldCupQueryParams<'_>, + _params: http::WorldCupQueryParams, ) -> Result> { Ok(None) } fn make_groups_request( &self, _url: Url, - _params: http::WorldCupQueryParams<'_>, + _params: http::WorldCupQueryParams, ) -> Result> { Ok(None) } fn make_live_request( &self, _url: Url, - _params: http::WorldCupQueryParams<'_>, + _params: http::WorldCupQueryParams, ) -> Result> { Ok(None) } @@ -576,7 +576,7 @@ impl http::HttpClientTrait for FakeHttpClientServerError { fn make_teams_request( &self, _url: Url, - _params: http::WorldCupQueryParams<'_>, + _params: http::WorldCupQueryParams, ) -> Result> { Err(Error::Server { code: 500, @@ -586,7 +586,7 @@ impl http::HttpClientTrait for FakeHttpClientServerError { fn make_matches_request( &self, _url: Url, - _params: http::WorldCupQueryParams<'_>, + _params: http::WorldCupQueryParams, ) -> Result> { Err(Error::Server { code: 500, @@ -596,7 +596,7 @@ impl http::HttpClientTrait for FakeHttpClientServerError { fn make_groups_request( &self, _url: Url, - _params: http::WorldCupQueryParams<'_>, + _params: http::WorldCupQueryParams, ) -> Result> { Err(Error::Server { code: 500, @@ -606,7 +606,7 @@ impl http::HttpClientTrait for FakeHttpClientServerError { fn make_live_request( &self, _url: Url, - _params: http::WorldCupQueryParams<'_>, + _params: http::WorldCupQueryParams, ) -> Result> { Err(Error::Server { code: 500, @@ -623,7 +623,7 @@ impl http::HttpClientTrait for FakeCapturingClient { fn make_teams_request( &self, url: Url, - _params: http::WorldCupQueryParams<'_>, + _params: http::WorldCupQueryParams, ) -> Result> { *self.captured_url.lock().unwrap() = Some(url.clone()); Ok(Some(make_response(200, "{}", url))) @@ -631,7 +631,7 @@ impl http::HttpClientTrait for FakeCapturingClient { fn make_matches_request( &self, url: Url, - params: http::WorldCupQueryParams<'_>, + params: http::WorldCupQueryParams, ) -> Result> { *self.captured_url.lock().unwrap() = Some(http::build_url(url.clone(), ¶ms)); Ok(Some(make_response(200, "{}", url))) @@ -639,7 +639,7 @@ impl http::HttpClientTrait for FakeCapturingClient { fn make_groups_request( &self, url: Url, - _params: http::WorldCupQueryParams<'_>, + _params: http::WorldCupQueryParams, ) -> Result> { *self.captured_url.lock().unwrap() = Some(url.clone()); Ok(Some(make_response(200, "{}", url))) @@ -647,7 +647,7 @@ impl http::HttpClientTrait for FakeCapturingClient { fn make_live_request( &self, url: Url, - _params: http::WorldCupQueryParams<'_>, + _params: http::WorldCupQueryParams, ) -> Result> { *self.captured_url.lock().unwrap() = Some(url.clone()); Ok(Some(make_response(200, "{}", url))) From e4e6d782dd270ebdf6621ef9ff3f20df617cd765 Mon Sep 17 00:00:00 2001 From: Tif Tran Date: Thu, 30 Apr 2026 21:15:50 -0700 Subject: [PATCH 3/9] remove groups --- components/merino/src/worldcup/http.rs | 13 ------ components/merino/src/worldcup/mod.rs | 17 ------- components/merino/src/worldcup/tests.rs | 59 ------------------------- 3 files changed, 89 deletions(-) diff --git a/components/merino/src/worldcup/http.rs b/components/merino/src/worldcup/http.rs index 80f0806081..7ec088a5e1 100644 --- a/components/merino/src/worldcup/http.rs +++ b/components/merino/src/worldcup/http.rs @@ -24,11 +24,6 @@ pub trait HttpClientTrait { url: Url, params: WorldCupQueryParams, ) -> Result>; - fn make_groups_request( - &self, - url: Url, - params: WorldCupQueryParams, - ) -> Result>; fn make_live_request(&self, url: Url, params: WorldCupQueryParams) -> Result>; } @@ -49,14 +44,6 @@ impl HttpClientTrait for HttpClient { send_get(build_url(url, ¶ms)) } - fn make_groups_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)) } diff --git a/components/merino/src/worldcup/mod.rs b/components/merino/src/worldcup/mod.rs index ef57868bef..64939fd8c6 100644 --- a/components/merino/src/worldcup/mod.rs +++ b/components/merino/src/worldcup/mod.rs @@ -90,14 +90,6 @@ impl WorldCupClient { Ok(response.map(|r| r.text().to_string())) } - #[handle_error(Error)] - /// Fetches groups from merino wcs endpoint - pub fn get_groups(&self, options: WorldCupOptions) -> ApiResult> { - let url = self.base_url.join("groups")?; - let response = self.inner.get_groups(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> { @@ -147,15 +139,6 @@ impl WorldCupClientInner { .make_matches_request(url, Self::params(options)) } - pub fn get_groups( - &self, - url: Url, - options: WorldCupOptions, - ) -> Result> { - self.http_client - .make_groups_request(url, Self::params(options)) - } - pub fn get_live( &self, url: Url, diff --git a/components/merino/src/worldcup/tests.rs b/components/merino/src/worldcup/tests.rs index a6b56358f6..1eba4b52ce 100644 --- a/components/merino/src/worldcup/tests.rs +++ b/components/merino/src/worldcup/tests.rs @@ -521,13 +521,6 @@ impl http::HttpClientTrait for FakeHttpClientSuccess { ) -> Result> { Ok(Some(make_response(200, MATCH_RESPONSE, url))) } - fn make_groups_request( - &self, - url: Url, - _params: http::WorldCupQueryParams, - ) -> Result> { - Ok(Some(make_response(200, GROUP_RESPONSE, url))) - } fn make_live_request( &self, url: Url, @@ -554,13 +547,6 @@ impl http::HttpClientTrait for FakeHttpClientNoContent { ) -> Result> { Ok(None) } - fn make_groups_request( - &self, - _url: Url, - _params: http::WorldCupQueryParams, - ) -> Result> { - Ok(None) - } fn make_live_request( &self, _url: Url, @@ -593,16 +579,6 @@ impl http::HttpClientTrait for FakeHttpClientServerError { message: "Internal server error".to_string(), }) } - fn make_groups_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, @@ -636,14 +612,6 @@ impl http::HttpClientTrait for FakeCapturingClient { *self.captured_url.lock().unwrap() = Some(http::build_url(url.clone(), ¶ms)); Ok(Some(make_response(200, "{}", url))) } - fn make_groups_request( - &self, - url: Url, - _params: http::WorldCupQueryParams, - ) -> Result> { - *self.captured_url.lock().unwrap() = Some(url.clone()); - Ok(Some(make_response(200, "{}", url))) - } fn make_live_request( &self, url: Url, @@ -670,14 +638,6 @@ fn test_get_matches_success() { assert_eq!(result.unwrap().unwrap().text(), MATCH_RESPONSE); } -#[test] -fn test_get_groups_success() { - let client = WorldCupClientInner::new_with_client(FakeHttpClientSuccess); - let result = client.get_groups(base_url().join("groups").unwrap(), default_options()); - assert!(result.is_ok()); - assert_eq!(result.unwrap().unwrap().text(), GROUP_RESPONSE); -} - #[test] fn test_get_live_success() { let client = WorldCupClientInner::new_with_client(FakeHttpClientSuccess); @@ -698,10 +658,6 @@ fn test_no_content_returns_none() { assert!(result_matches.is_ok()); assert!(result_matches.unwrap().is_none()); - let result_groups = client.get_groups(base_url().join("groups").unwrap(), default_options()); - assert!(result_groups.is_ok()); - assert!(result_groups.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()); @@ -776,21 +732,6 @@ fn test_matches_endpoint_url_with_limit() { ); } -#[test] -fn test_groups_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_groups(client.base_url.join("groups").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/groups" - ); -} - #[test] fn test_live_endpoint_url() { let captured_url = std::sync::Arc::new(std::sync::Mutex::new(None::)); From 16cca9dd2eeed1fa835b39dc36b51359a10cb8bc Mon Sep 17 00:00:00 2001 From: Tif Tran Date: Thu, 30 Apr 2026 21:26:29 -0700 Subject: [PATCH 4/9] merino cli --- examples/merino-cli/src/main.rs | 66 +++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) 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(()) From 3292fb68cd70e907b293c8fcc8195827c815344e Mon Sep 17 00:00:00 2001 From: Tif Tran Date: Fri, 1 May 2026 09:29:21 -0700 Subject: [PATCH 5/9] clippy fix and feedback changes --- components/merino/src/worldcup/http.rs | 8 ++--- components/merino/src/worldcup/tests.rs | 45 ------------------------- examples/merino-cli/src/main.rs | 17 ---------- 3 files changed, 4 insertions(+), 66 deletions(-) diff --git a/components/merino/src/worldcup/http.rs b/components/merino/src/worldcup/http.rs index 7ec088a5e1..90370a69e3 100644 --- a/components/merino/src/worldcup/http.rs +++ b/components/merino/src/worldcup/http.rs @@ -3,7 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ use url::Url; -use viaduct::{Client, ClientSettings, Request, Response}; +use viaduct::{Request, Response}; use super::error::{Error, Result}; @@ -67,9 +67,9 @@ pub fn build_url(endpoint_url: Url, params: &WorldCupQueryParams) -> 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 response = Request::get(url) + .header("accept", "application/json")? + .send()?; let status = response.status; match status { 200 => Ok(Some(response)), diff --git a/components/merino/src/worldcup/tests.rs b/components/merino/src/worldcup/tests.rs index 1eba4b52ce..419569af29 100644 --- a/components/merino/src/worldcup/tests.rs +++ b/components/merino/src/worldcup/tests.rs @@ -29,51 +29,6 @@ const TEAMS_RESPONSE: &str = r#"{ }] }"#; -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": [ { diff --git a/examples/merino-cli/src/main.rs b/examples/merino-cli/src/main.rs index ffac6707bf..d25a14d54a 100644 --- a/examples/merino-cli/src/main.rs +++ b/examples/merino-cli/src/main.rs @@ -88,14 +88,6 @@ enum Commands { }, /// 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, @@ -191,20 +183,11 @@ fn main() -> Result<()> { } } 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 { From 988b6773c36d3f5a9e66e89a2f9eb9b17130ab4e Mon Sep 17 00:00:00 2001 From: Tif Tran Date: Fri, 1 May 2026 12:50:39 -0700 Subject: [PATCH 6/9] remove date param --- components/merino/src/worldcup/http.rs | 20 +------------------- components/merino/src/worldcup/mod.rs | 1 - components/merino/src/worldcup/schema.rs | 2 -- components/merino/src/worldcup/tests.rs | 2 -- examples/merino-cli/src/main.rs | 7 +------ 5 files changed, 2 insertions(+), 30 deletions(-) diff --git a/components/merino/src/worldcup/http.rs b/components/merino/src/worldcup/http.rs index 90370a69e3..e91c98108d 100644 --- a/components/merino/src/worldcup/http.rs +++ b/components/merino/src/worldcup/http.rs @@ -13,7 +13,6 @@ pub struct HttpClient; pub struct WorldCupQueryParams { pub limit: Option, pub teams: Option, - pub date: Option, } pub trait HttpClientTrait { @@ -59,9 +58,6 @@ pub fn build_url(endpoint_url: Url, params: &WorldCupQueryParams) -> Url { if let Some(v) = ¶ms.teams { pairs.append_pair("teams", v); } - if let Some(v) = ¶ms.date { - pairs.append_pair("date", v); - } } url } @@ -127,27 +123,15 @@ mod tests { 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] @@ -162,15 +146,13 @@ mod tests { 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" + &teams=FRA" ); } } diff --git a/components/merino/src/worldcup/mod.rs b/components/merino/src/worldcup/mod.rs index 64939fd8c6..ccc02640c0 100644 --- a/components/merino/src/worldcup/mod.rs +++ b/components/merino/src/worldcup/mod.rs @@ -117,7 +117,6 @@ impl WorldCupClientInner { http::WorldCupQueryParams { limit: options.limit, teams, - date: options.date, } } diff --git a/components/merino/src/worldcup/schema.rs b/components/merino/src/worldcup/schema.rs index 918c5ae67b..3f1a8f86d7 100644 --- a/components/merino/src/worldcup/schema.rs +++ b/components/merino/src/worldcup/schema.rs @@ -8,6 +8,4 @@ pub struct WorldCupOptions { 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 index 419569af29..7ed9a93f6c 100644 --- a/components/merino/src/worldcup/tests.rs +++ b/components/merino/src/worldcup/tests.rs @@ -451,7 +451,6 @@ fn default_options() -> WorldCupOptions { WorldCupOptions { limit: None, teams: None, - date: None, } } @@ -677,7 +676,6 @@ fn test_matches_endpoint_url_with_limit() { WorldCupOptions { limit: Some(2), teams: None, - date: None, }, ); let captured = captured_url.lock().unwrap(); diff --git a/examples/merino-cli/src/main.rs b/examples/merino-cli/src/main.rs index d25a14d54a..b2dd5e941b 100644 --- a/examples/merino-cli/src/main.rs +++ b/examples/merino-cli/src/main.rs @@ -96,10 +96,6 @@ enum Commands { #[arg(long)] teams: Option>, - /// Filter by date (e.g. 2026-06-15) - #[arg(long)] - date: Option, - #[command(subcommand)] endpoint: WorldCupEndpoint, }, @@ -185,11 +181,10 @@ fn main() -> Result<()> { Commands::WorldCup { limit, teams, - date, endpoint, } => { let client = WorldCupClient::new(cli.base_host)?; - let options = WorldCupOptions { limit, teams, date }; + let options = WorldCupOptions { limit, teams }; let result = match endpoint { WorldCupEndpoint::Teams => client.get_teams(options), WorldCupEndpoint::Matches => client.get_matches(options), From 72052f225ba7541ff7e60966d3402d04c00dd2fd Mon Sep 17 00:00:00 2001 From: Tif Tran Date: Mon, 4 May 2026 21:14:33 -0700 Subject: [PATCH 7/9] feedback changes --- .../merino/src/worldcup/fixtures/live.json | 60 ++ .../merino/src/worldcup/fixtures/matches.json | 346 ++++++++++ .../merino/src/worldcup/fixtures/teams.json | 23 + components/merino/src/worldcup/http.rs | 30 +- components/merino/src/worldcup/mod.rs | 48 +- components/merino/src/worldcup/schema.rs | 5 + components/merino/src/worldcup/tests.rs | 615 ++---------------- examples/merino-cli/src/main.rs | 6 +- 8 files changed, 516 insertions(+), 617 deletions(-) create mode 100644 components/merino/src/worldcup/fixtures/live.json create mode 100644 components/merino/src/worldcup/fixtures/matches.json create mode 100644 components/merino/src/worldcup/fixtures/teams.json diff --git a/components/merino/src/worldcup/fixtures/live.json b/components/merino/src/worldcup/fixtures/live.json new file mode 100644 index 0000000000..b176483375 --- /dev/null +++ b/components/merino/src/worldcup/fixtures/live.json @@ -0,0 +1,60 @@ +{ + "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" + } + ] +} \ No newline at end of file diff --git a/components/merino/src/worldcup/fixtures/matches.json b/components/merino/src/worldcup/fixtures/matches.json new file mode 100644 index 0000000000..fd75eb07b5 --- /dev/null +++ b/components/merino/src/worldcup/fixtures/matches.json @@ -0,0 +1,346 @@ +{ + "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" + } + ] +} \ No newline at end of file diff --git a/components/merino/src/worldcup/fixtures/teams.json b/components/merino/src/worldcup/fixtures/teams.json new file mode 100644 index 0000000000..39c1721737 --- /dev/null +++ b/components/merino/src/worldcup/fixtures/teams.json @@ -0,0 +1,23 @@ +{ + "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 + }] +} \ No newline at end of file diff --git a/components/merino/src/worldcup/http.rs b/components/merino/src/worldcup/http.rs index e91c98108d..e2331745f7 100644 --- a/components/merino/src/worldcup/http.rs +++ b/components/merino/src/worldcup/http.rs @@ -16,39 +16,19 @@ pub struct WorldCupQueryParams { } 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>; + fn make_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> { + fn make_request(&self, url: Url, params: WorldCupQueryParams) -> Result> { send_get(build_url(url, ¶ms)) } } pub fn build_url(endpoint_url: Url, params: &WorldCupQueryParams) -> Url { + if params.limit.is_none() && params.teams.is_none() { + return endpoint_url; + } let mut url = endpoint_url; { let mut pairs = url.query_pairs_mut(); diff --git a/components/merino/src/worldcup/mod.rs b/components/merino/src/worldcup/mod.rs index ccc02640c0..fa3f383d31 100644 --- a/components/merino/src/worldcup/mod.rs +++ b/components/merino/src/worldcup/mod.rs @@ -12,9 +12,9 @@ use error_support::handle_error; use url::Url; pub use error::{ApiResult, Error, MerinoWorldCupApiError, Result}; -pub use schema::WorldCupOptions; +pub use schema::{WorldCupConfig, WorldCupOptions}; -const DEFAULT_BASE_HOST: &str = "https://merino.services.mozilla.com"; +pub(crate) const DEFAULT_BASE_URL: &str = "https://merino.services.mozilla.com/api/v1/wcs/"; /// A client for the merino wcs endpoint. /// @@ -31,7 +31,7 @@ struct WorldCupClientInner { } #[derive(Default)] -pub struct WorldCupClientBuilder { +struct WorldCupClientBuilder { base_host: Option, } @@ -46,11 +46,10 @@ impl WorldCupClientBuilder { } 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))?; + let base_url = match self.base_host { + Some(host) => Url::parse(&format!("{}/api/v1/wcs/", host))?, + None => Url::parse(DEFAULT_BASE_URL)?, + }; Ok(WorldCupClient { inner: WorldCupClientInner::new()?, @@ -64,10 +63,10 @@ impl WorldCupClient { /// Creates a new `WorldCupClient` from the given configuration. #[uniffi::constructor] #[handle_error(Error)] - pub fn new(base_host: Option) -> ApiResult { + pub fn new(config: WorldCupConfig) -> ApiResult { let mut builder = WorldCupClientBuilder::new(); - if let Some(host) = base_host { + if let Some(host) = config.base_host { builder = builder.base_host(host); } @@ -78,7 +77,7 @@ impl WorldCupClient { /// 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)?; + let response = self.inner.make_request(url, options)?; Ok(response.map(|r| r.text().to_string())) } @@ -86,7 +85,7 @@ impl WorldCupClient { /// 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)?; + let response = self.inner.make_request(url, options)?; Ok(response.map(|r| r.text().to_string())) } @@ -94,7 +93,7 @@ impl WorldCupClient { /// 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)?; + let response = self.inner.make_request(url, options)?; Ok(response.map(|r| r.text().to_string())) } } @@ -120,31 +119,12 @@ impl WorldCupClientInner { } } - 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( + pub fn make_request( &self, url: Url, options: WorldCupOptions, ) -> Result> { - self.http_client - .make_live_request(url, Self::params(options)) + self.http_client.make_request(url, Self::params(options)) } } diff --git a/components/merino/src/worldcup/schema.rs b/components/merino/src/worldcup/schema.rs index 3f1a8f86d7..2bf39e44c0 100644 --- a/components/merino/src/worldcup/schema.rs +++ b/components/merino/src/worldcup/schema.rs @@ -1,5 +1,10 @@ use uniffi::Record; +#[derive(Clone, Debug, Record)] +pub struct WorldCupConfig { + pub base_host: Option, +} + /// Options for world cup endpoint requests. /// All fields are optional — omitted fields are not sent to merino. #[derive(Clone, Debug, Record)] diff --git a/components/merino/src/worldcup/tests.rs b/components/merino/src/worldcup/tests.rs index 7ed9a93f6c..618842603b 100644 --- a/components/merino/src/worldcup/tests.rs +++ b/components/merino/src/worldcup/tests.rs @@ -5,437 +5,10 @@ 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 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" - } - ] -}"#; +const TEAMS_RESPONSE: &str = include_str!("fixtures/teams.json"); +const MATCH_RESPONSE: &str = include_str!("fixtures/matches.json"); +const LIVE_RESPONSE: &str = include_str!("fixtures/live.json"); fn make_response(status: u16, body: &str, url: Url) -> Response { Response { @@ -455,93 +28,18 @@ fn default_options() -> WorldCupOptions { } 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))) - } + Url::parse(DEFAULT_BASE_URL).unwrap() } -struct FakeHttpClientNoContent; +struct FakeHttpClient(fn() -> Result>); -impl http::HttpClientTrait for FakeHttpClientNoContent { - fn make_teams_request( +impl http::HttpClientTrait for FakeHttpClient { + fn make_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(), - }) + (self.0)() } } @@ -550,15 +48,7 @@ struct FakeCapturingClient { } 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( + fn make_request( &self, url: Url, params: http::WorldCupQueryParams, @@ -566,61 +56,80 @@ impl http::HttpClientTrait for FakeCapturingClient { *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()); + fn response() -> Result> { + Ok(Some(make_response( + 200, + TEAMS_RESPONSE, + Url::parse("https://merino.services.mozilla.com/api/v1/wcs/").unwrap(), + ))) + } + let client = WorldCupClientInner::new_with_client(FakeHttpClient(response)); + let result = client.make_request(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()); + fn response() -> Result> { + Ok(Some(make_response( + 200, + MATCH_RESPONSE, + Url::parse("https://merino.services.mozilla.com/api/v1/wcs/").unwrap(), + ))) + } + let client = WorldCupClientInner::new_with_client(FakeHttpClient(response)); + let result = client.make_request(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()); + fn response() -> Result> { + Ok(Some(make_response( + 200, + LIVE_RESPONSE, + Url::parse("https://merino.services.mozilla.com/api/v1/wcs/").unwrap(), + ))) + } + let client = WorldCupClientInner::new_with_client(FakeHttpClient(response)); + let result = client.make_request(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); + fn response() -> Result> { + Ok(None) + } + let client = WorldCupClientInner::new_with_client(FakeHttpClient(response)); - 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 = client.make_request(base_url().join("teams").unwrap(), default_options()); + assert!(result.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 = client.make_request(base_url().join("matches").unwrap(), default_options()); + assert!(result.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()); + let result = client.make_request(base_url().join("live").unwrap(), default_options()); + assert!(result.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()); + fn response() -> Result> { + Err(Error::Server { + code: 500, + message: "Internal server error".to_string(), + }) + } + let client = WorldCupClientInner::new_with_client(FakeHttpClient(response)); + let result = client.make_request(base_url().join("teams").unwrap(), default_options()); assert!(result.is_err()); assert!(matches!( result.unwrap_err(), @@ -631,10 +140,7 @@ fn test_server_error() { #[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/" - ); + assert_eq!(client.base_url.as_str(), DEFAULT_BASE_URL); } #[test] @@ -655,8 +161,7 @@ fn test_teams_endpoint_url() { 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 _ = client_inner.make_request(base_url().join("teams").unwrap(), default_options()); let captured = captured_url.lock().unwrap(); assert_eq!( captured.as_ref().unwrap().as_str(), @@ -670,9 +175,8 @@ fn test_matches_endpoint_url_with_limit() { 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(), + let _ = client_inner.make_request( + base_url().join("matches").unwrap(), WorldCupOptions { limit: Some(2), teams: None, @@ -691,8 +195,7 @@ fn test_live_endpoint_url() { 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 _ = client_inner.make_request(base_url().join("live").unwrap(), default_options()); let captured = captured_url.lock().unwrap(); assert_eq!( captured.as_ref().unwrap().as_str(), diff --git a/examples/merino-cli/src/main.rs b/examples/merino-cli/src/main.rs index b2dd5e941b..c0c8f0a08f 100644 --- a/examples/merino-cli/src/main.rs +++ b/examples/merino-cli/src/main.rs @@ -11,7 +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 merino::worldcup::{WorldCupClient, WorldCupConfig, WorldCupOptions}; use viaduct::{configure_ohttp_channel, OhttpConfig}; #[derive(Debug, Parser)] @@ -183,7 +183,9 @@ fn main() -> Result<()> { teams, endpoint, } => { - let client = WorldCupClient::new(cli.base_host)?; + let client = WorldCupClient::new(WorldCupConfig { + base_host: cli.base_host, + })?; let options = WorldCupOptions { limit, teams }; let result = match endpoint { WorldCupEndpoint::Teams => client.get_teams(options), From e3c2c65abe4616d6ee22494b9df0e268e63721b5 Mon Sep 17 00:00:00 2001 From: Tif Tran Date: Tue, 5 May 2026 13:47:06 -0700 Subject: [PATCH 8/9] add language param --- components/merino/src/worldcup/http.rs | 11 +++++++++-- components/merino/src/worldcup/mod.rs | 1 + components/merino/src/worldcup/schema.rs | 2 ++ components/merino/src/worldcup/tests.rs | 2 ++ examples/merino-cli/src/main.rs | 11 ++++++++++- 5 files changed, 24 insertions(+), 3 deletions(-) diff --git a/components/merino/src/worldcup/http.rs b/components/merino/src/worldcup/http.rs index e2331745f7..e455ac8cf2 100644 --- a/components/merino/src/worldcup/http.rs +++ b/components/merino/src/worldcup/http.rs @@ -13,6 +13,7 @@ pub struct HttpClient; pub struct WorldCupQueryParams { pub limit: Option, pub teams: Option, + pub accept_language: Option, } pub trait HttpClientTrait { @@ -26,7 +27,7 @@ impl HttpClientTrait for HttpClient { } pub fn build_url(endpoint_url: Url, params: &WorldCupQueryParams) -> Url { - if params.limit.is_none() && params.teams.is_none() { + if params.limit.is_none() && params.teams.is_none() && params.accept_language.is_none() { return endpoint_url; } let mut url = endpoint_url; @@ -38,6 +39,9 @@ pub fn build_url(endpoint_url: Url, params: &WorldCupQueryParams) -> Url { if let Some(v) = ¶ms.teams { pairs.append_pair("teams", v); } + if let Some(v) = ¶ms.accept_language { + pairs.append_pair("language", v); + } } url } @@ -108,6 +112,7 @@ mod tests { let options = WorldCupQueryParams { limit: Some(5), teams: Some("FRA,ENG".to_string()), + accept_language: Some("en-GB".to_string()), }; let url = build_url(base_url(), &options); assert!(has_param(&url, "limit", "5")); @@ -126,13 +131,15 @@ mod tests { let options = WorldCupQueryParams { limit: Some(3), teams: Some("FRA".to_string()), + accept_language: Some("en-US".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" + &teams=FRA\ + &language=en-US" ); } } diff --git a/components/merino/src/worldcup/mod.rs b/components/merino/src/worldcup/mod.rs index fa3f383d31..cc078718ed 100644 --- a/components/merino/src/worldcup/mod.rs +++ b/components/merino/src/worldcup/mod.rs @@ -116,6 +116,7 @@ impl WorldCupClientInner { http::WorldCupQueryParams { limit: options.limit, teams, + accept_language: options.accept_language, } } diff --git a/components/merino/src/worldcup/schema.rs b/components/merino/src/worldcup/schema.rs index 2bf39e44c0..2adb01a061 100644 --- a/components/merino/src/worldcup/schema.rs +++ b/components/merino/src/worldcup/schema.rs @@ -13,4 +13,6 @@ pub struct WorldCupOptions { pub limit: Option, /// Filter results by team(s) (e.g. `["FRA", "ENG"]`). pub teams: Option>, + /// Language for results (e.g. `"en-US"`). + pub accept_language: Option, } diff --git a/components/merino/src/worldcup/tests.rs b/components/merino/src/worldcup/tests.rs index 618842603b..9d313cc7bb 100644 --- a/components/merino/src/worldcup/tests.rs +++ b/components/merino/src/worldcup/tests.rs @@ -24,6 +24,7 @@ fn default_options() -> WorldCupOptions { WorldCupOptions { limit: None, teams: None, + accept_language: None, } } @@ -180,6 +181,7 @@ fn test_matches_endpoint_url_with_limit() { WorldCupOptions { limit: Some(2), teams: None, + accept_language: None, }, ); let captured = captured_url.lock().unwrap(); diff --git a/examples/merino-cli/src/main.rs b/examples/merino-cli/src/main.rs index c0c8f0a08f..486788bcc5 100644 --- a/examples/merino-cli/src/main.rs +++ b/examples/merino-cli/src/main.rs @@ -96,6 +96,10 @@ enum Commands { #[arg(long)] teams: Option>, + /// Language for results (e.g. "en-US") + #[arg(long)] + accept_language: Option, + #[command(subcommand)] endpoint: WorldCupEndpoint, }, @@ -181,12 +185,17 @@ fn main() -> Result<()> { Commands::WorldCup { limit, teams, + accept_language, endpoint, } => { let client = WorldCupClient::new(WorldCupConfig { base_host: cli.base_host, })?; - let options = WorldCupOptions { limit, teams }; + let options = WorldCupOptions { + limit, + teams, + accept_language, + }; let result = match endpoint { WorldCupEndpoint::Teams => client.get_teams(options), WorldCupEndpoint::Matches => client.get_matches(options), From c97926ed32d2358ff1fc510834e23052a4e6114a Mon Sep 17 00:00:00 2001 From: Tif Tran Date: Tue, 5 May 2026 14:39:03 -0700 Subject: [PATCH 9/9] accept lang header --- components/merino/src/worldcup/http.rs | 12 +++--- components/merino/src/worldcup/tests.rs | 52 +++++++++++++++++++------ 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/components/merino/src/worldcup/http.rs b/components/merino/src/worldcup/http.rs index e455ac8cf2..d25f7e2432 100644 --- a/components/merino/src/worldcup/http.rs +++ b/components/merino/src/worldcup/http.rs @@ -22,7 +22,7 @@ pub trait HttpClientTrait { impl HttpClientTrait for HttpClient { fn make_request(&self, url: Url, params: WorldCupQueryParams) -> Result> { - send_get(build_url(url, ¶ms)) + send_get(build_url(url, ¶ms), params.accept_language) } } @@ -46,10 +46,12 @@ pub fn build_url(endpoint_url: Url, params: &WorldCupQueryParams) -> Url { url } -fn send_get(url: Url) -> Result> { - let response = Request::get(url) - .header("accept", "application/json")? - .send()?; +fn send_get(url: Url, accept_language: Option) -> Result> { + let mut request = Request::get(url).header("accept", "application/json")?; + if let Some(lang) = accept_language { + request = request.header("accept-language", lang)?; + } + let response = request.send()?; let status = response.status; match status { 200 => Ok(Some(response)), diff --git a/components/merino/src/worldcup/tests.rs b/components/merino/src/worldcup/tests.rs index 9d313cc7bb..199842be74 100644 --- a/components/merino/src/worldcup/tests.rs +++ b/components/merino/src/worldcup/tests.rs @@ -10,12 +10,16 @@ const TEAMS_RESPONSE: &str = include_str!("fixtures/teams.json"); const MATCH_RESPONSE: &str = include_str!("fixtures/matches.json"); const LIVE_RESPONSE: &str = include_str!("fixtures/live.json"); -fn make_response(status: u16, body: &str, url: Url) -> Response { +fn make_response(status: u16, body: &str, url: Url, accept_language: Option) -> Response { + let mut headers = Headers::new(); + if let Some(lang) = accept_language { + headers.insert("accept-language", lang).unwrap(); + } Response { request_method: Method::Get, url, status, - headers: Headers::new(), + headers, body: body.as_bytes().to_vec(), } } @@ -32,15 +36,15 @@ fn base_url() -> Url { Url::parse(DEFAULT_BASE_URL).unwrap() } -struct FakeHttpClient(fn() -> Result>); +struct FakeHttpClient(fn(http::WorldCupQueryParams) -> Result>); impl http::HttpClientTrait for FakeHttpClient { fn make_request( &self, _url: Url, - _params: http::WorldCupQueryParams, + params: http::WorldCupQueryParams, ) -> Result> { - (self.0)() + (self.0)(params) } } @@ -55,17 +59,18 @@ impl http::HttpClientTrait for FakeCapturingClient { params: http::WorldCupQueryParams, ) -> Result> { *self.captured_url.lock().unwrap() = Some(http::build_url(url.clone(), ¶ms)); - Ok(Some(make_response(200, "{}", url))) + Ok(Some(make_response(200, "{}", url, None))) } } #[test] fn test_get_teams_success() { - fn response() -> Result> { + fn response(_params: http::WorldCupQueryParams) -> Result> { Ok(Some(make_response( 200, TEAMS_RESPONSE, Url::parse("https://merino.services.mozilla.com/api/v1/wcs/").unwrap(), + None, ))) } let client = WorldCupClientInner::new_with_client(FakeHttpClient(response)); @@ -76,11 +81,12 @@ fn test_get_teams_success() { #[test] fn test_get_matches_success() { - fn response() -> Result> { + fn response(_params: http::WorldCupQueryParams) -> Result> { Ok(Some(make_response( 200, MATCH_RESPONSE, Url::parse("https://merino.services.mozilla.com/api/v1/wcs/").unwrap(), + None, ))) } let client = WorldCupClientInner::new_with_client(FakeHttpClient(response)); @@ -91,11 +97,12 @@ fn test_get_matches_success() { #[test] fn test_get_live_success() { - fn response() -> Result> { + fn response(_params: http::WorldCupQueryParams) -> Result> { Ok(Some(make_response( 200, LIVE_RESPONSE, Url::parse("https://merino.services.mozilla.com/api/v1/wcs/").unwrap(), + None, ))) } let client = WorldCupClientInner::new_with_client(FakeHttpClient(response)); @@ -106,7 +113,7 @@ fn test_get_live_success() { #[test] fn test_no_content_returns_none() { - fn response() -> Result> { + fn response(_params: http::WorldCupQueryParams) -> Result> { Ok(None) } let client = WorldCupClientInner::new_with_client(FakeHttpClient(response)); @@ -123,7 +130,7 @@ fn test_no_content_returns_none() { #[test] fn test_server_error() { - fn response() -> Result> { + fn response(_params: http::WorldCupQueryParams) -> Result> { Err(Error::Server { code: 500, message: "Internal server error".to_string(), @@ -138,6 +145,29 @@ fn test_server_error() { )); } +#[test] +fn test_accept_language_is_passed_as_header() { + fn response(params: http::WorldCupQueryParams) -> Result> { + Ok(Some(make_response( + 200, + "", + Url::parse("https://example.com").unwrap(), + params.accept_language, + ))) + } + let client = WorldCupClientInner::new_with_client(FakeHttpClient(response)); + let result = client.make_request( + base_url().join("teams").unwrap(), + WorldCupOptions { + limit: None, + teams: None, + accept_language: Some("en-US".to_string()), + }, + ); + let response = result.unwrap().unwrap(); + assert_eq!(response.headers.get("accept-language"), Some("en-US")); +} + #[test] fn test_builder_uses_default_base_host() { let client = WorldCupClientBuilder::new().build().unwrap();