Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions components/merino/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@

pub mod curated_recommendations;
pub mod suggest;
pub mod worldcup;
uniffi::setup_scaffolding!("merino");
77 changes: 77 additions & 0 deletions components/merino/src/worldcup/error.rs
Original file line number Diff line number Diff line change
@@ -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<T> = std::result::Result<T, Error>;
pub type ApiResult<T> = std::result::Result<T, MerinoWorldCupApiError>;

#[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<u16>, 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<Self::ExternalError> {
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"),
}
}
}
176 changes: 176 additions & 0 deletions components/merino/src/worldcup/http.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

use url::Url;
use viaduct::{Client, ClientSettings, Request, Response};

use super::error::{Error, Result};

pub struct HttpClient;

#[derive(Default)]
pub struct WorldCupQueryParams {
pub limit: Option<u32>,
pub teams: Option<String>,
pub date: Option<String>,
}

pub trait HttpClientTrait {
fn make_teams_request(&self, url: Url, params: WorldCupQueryParams)
-> Result<Option<Response>>;
fn make_matches_request(
&self,
url: Url,
params: WorldCupQueryParams,
) -> Result<Option<Response>>;
fn make_live_request(&self, url: Url, params: WorldCupQueryParams) -> Result<Option<Response>>;
}

impl HttpClientTrait for HttpClient {
fn make_teams_request(
&self,
url: Url,
params: WorldCupQueryParams,
) -> Result<Option<Response>> {
send_get(build_url(url, &params))
}

fn make_matches_request(
&self,
url: Url,
params: WorldCupQueryParams,
) -> Result<Option<Response>> {
send_get(build_url(url, &params))
}

fn make_live_request(&self, url: Url, params: WorldCupQueryParams) -> Result<Option<Response>> {
send_get(build_url(url, &params))
}
}

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) = &params.teams {
pairs.append_pair("teams", v);
}
if let Some(v) = &params.date {
pairs.append_pair("date", v);
}
}
url
}

fn send_get(url: Url) -> Result<Option<Response>> {
let client = Client::with_ohttp_channel("merino", ClientSettings::default())?;
let request = Request::get(url).header("accept", "application/json")?;
let response = client.send_sync(request)?;
let status = response.status;
match status {
200 => Ok(Some(response)),
204 => Ok(None),
400 => Err(Error::BadRequest {
code: status,
message: response.text().to_string(),
}),
422 => Err(Error::Validation {
code: status,
message: response.text().to_string(),
}),
500..=599 => Err(Error::Server {
code: status,
message: response.text().to_string(),
}),
_ => Err(Error::Unexpected {
code: status,
message: response.text().to_string(),
}),
}
}

#[cfg(test)]
mod tests {
use super::*;

const BASE_URL: &str = "https://merino.services.mozilla.com/api/v1/wcs/teams";

fn base_url() -> Url {
Url::parse(BASE_URL).unwrap()
}

fn has_param(url: &Url, key: &str, value: &str) -> bool {
url.query_pairs().any(|(k, v)| k == key && v == value)
}

#[test]
fn test_build_url_with_params() {
let options = WorldCupQueryParams {
limit: Some(10),
..WorldCupQueryParams::default()
};
let url = build_url(base_url(), &options);
assert!(has_param(&url, "limit", "10"));
}

#[test]
fn test_build_url_with_teams() {
let options = WorldCupQueryParams {
teams: Some("FRA,ENG".to_string()),
..WorldCupQueryParams::default()
};
let url = build_url(base_url(), &options);
assert!(has_param(&url, "teams", "FRA,ENG"));
}

#[test]
fn test_build_url_with_date() {
let options = WorldCupQueryParams {
date: Some("2026-06-15".to_string()),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

non blocking but lets update these dates to use something like Duration::from_secs

..WorldCupQueryParams::default()
};
let url = build_url(base_url(), &options);
assert!(has_param(&url, "date", "2026-06-15"));
}

#[test]
fn test_build_url_with_all_options() {
let options = WorldCupQueryParams {
limit: Some(5),
teams: Some("FRA,ENG".to_string()),
date: Some("2026-06-20".to_string()),
};
let url = build_url(base_url(), &options);
assert!(has_param(&url, "limit", "5"));
assert!(has_param(&url, "teams", "FRA,ENG"));
assert!(has_param(&url, "date", "2026-06-20"));
}

#[test]
fn test_build_url_omits_none_options() {
let url = build_url(base_url(), &WorldCupQueryParams::default());
let keys: Vec<_> = url.query_pairs().map(|(k, _)| k.into_owned()).collect();
assert_eq!(keys.len(), 0);
}

#[test]
fn test_build_url_full_string() {
let options = WorldCupQueryParams {
limit: Some(3),
teams: Some("FRA".to_string()),
date: Some("2026-06-25".to_string()),
};
let url = build_url(base_url(), &options);
assert_eq!(
url.to_string(),
"https://merino.services.mozilla.com/api/v1/wcs/teams\
?limit=3\
&teams=FRA\
&date=2026-06-25"
);
}
}
Loading