use reqwest::{header, Method};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{collections::BTreeMap, env};
use thiserror::Error;
use url::Url;
pub const DEFAULT_BASE_URL: &str = "https://api.odds-api.net/v1";
pub type QueryParams = BTreeMap<String, String>;
pub type Response = Value;
pub type Result<T> = std::result::Result<T, OddsApiError>;
#[derive(Clone, Debug)]
pub struct OddsApiClient {
api_key: Option<String>,
bearer_token: Option<String>,
base_url: Url,
http: reqwest::Client,
}
#[derive(Clone, Debug, Default)]
pub struct OddsApiClientBuilder {
api_key: Option<String>,
bearer_token: Option<String>,
base_url: Option<String>,
http: Option<reqwest::Client>,
}
#[derive(Debug, Error)]
pub enum OddsApiError {
#[error("Odds API authentication failed with status {status}")]
AuthError { status: u16, body: Value },
#[error("Odds API rate limit exceeded")]
RateLimitError { status: u16, body: Value },
#[error("Odds API validation failed with status {status}")]
ValidationError { status: u16, body: Value },
#[error("Odds API server error with status {status}")]
ServerError { status: u16, body: Value },
#[error("Odds API request failed with status {status}")]
ApiError { status: u16, body: Value },
#[error("invalid SDK configuration: {0}")]
Config(String),
#[error(transparent)]
Http(#[from] reqwest::Error),
#[error(transparent)]
Url(#[from] url::ParseError),
#[error(transparent)]
Json(#[from] serde_json::Error),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct BestOdds {
pub selection_key: String,
pub bookmaker: String,
pub odds: f64,
pub market_key: String,
pub odd: Value,
}
impl OddsApiClient {
pub fn new(api_key: impl Into<String>) -> Result<Self> {
Self::builder().api_key(api_key).build()
}
pub fn from_env() -> Result<Self> {
Self::builder()
.api_key(env::var("ODDS_API_KEY").unwrap_or_default())
.base_url(
env::var("ODDS_API_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string()),
)
.build()
}
pub fn builder() -> OddsApiClientBuilder {
OddsApiClientBuilder::default()
}
pub fn api_key(&self) -> Option<&str> {
self.api_key.as_deref()
}
pub fn base_url(&self) -> &Url {
&self.base_url
}
pub async fn request(
&self,
method: Method,
path: &str,
params: Option<&QueryParams>,
body: Option<&Value>,
) -> Result<Value> {
let url = self.url(path, params)?;
let mut request = self
.http
.request(method, url)
.header(header::ACCEPT, "application/json");
if let Some(api_key) = &self.api_key {
if !api_key.is_empty() {
request = request.header("X-API-Key", api_key);
}
}
if let Some(token) = &self.bearer_token {
if !token.is_empty() {
request = request.bearer_auth(token);
}
}
if let Some(payload) = body {
request = request.json(payload);
}
let response = request.send().await?;
let status = response.status();
let body = response.text().await?;
let parsed = parse_json(&body);
if status.is_success() {
return Ok(parsed);
}
Err(match status.as_u16() {
401 | 403 => OddsApiError::AuthError {
status: status.as_u16(),
body: parsed,
},
429 => OddsApiError::RateLimitError {
status: status.as_u16(),
body: parsed,
},
400 | 422 => OddsApiError::ValidationError {
status: status.as_u16(),
body: parsed,
},
500..=599 => OddsApiError::ServerError {
status: status.as_u16(),
body: parsed,
},
_ => OddsApiError::ApiError {
status: status.as_u16(),
body: parsed,
},
})
}
pub async fn get(&self, path: &str, params: Option<&QueryParams>) -> Result<Value> {
self.request(Method::GET, path, params, None).await
}
pub async fn post(
&self,
path: &str,
params: Option<&QueryParams>,
body: &Value,
) -> Result<Value> {
self.request(Method::POST, path, params, Some(body)).await
}
pub async fn list_sports(&self) -> Result<Response> {
self.get("/sports", None).await
}
pub async fn get_api_metadata(&self) -> Result<Response> {
self.get("/", None).await
}
pub async fn get_me(&self) -> Result<Response> {
self.get("/me", None).await
}
pub async fn get_usage(&self) -> Result<Response> {
self.get("/usage", None).await
}
pub async fn get_limits(&self) -> Result<Response> {
self.get("/limits", None).await
}
pub async fn list_bookmakers(&self, params: Option<&QueryParams>) -> Result<Response> {
self.get("/bookmakers", params).await
}
pub async fn list_bookmaker_countries(&self) -> Result<Response> {
self.get("/bookmakers/countries", None).await
}
pub async fn list_leagues(&self, params: Option<&QueryParams>) -> Result<Response> {
self.get("/leagues", params).await
}
pub async fn search_events(&self, params: Option<&QueryParams>) -> Result<Response> {
self.get("/events", params).await
}
pub async fn get_event(&self, event_id: &str) -> Result<Response> {
self.get(&format!("/events/{}", escape(event_id)), None)
.await
}
pub async fn get_event_bookmakers(&self, event_id: &str) -> Result<Response> {
self.get(&format!("/events/{}/bookmakers", escape(event_id)), None)
.await
}
pub async fn get_odds_snapshot(
&self,
event_id: &str,
params: Option<&QueryParams>,
) -> Result<Response> {
self.get(
&format!("/events/{}/odds/snapshot", escape(event_id)),
params,
)
.await
}
pub async fn get_odds_history(
&self,
event_id: &str,
params: Option<&QueryParams>,
) -> Result<Response> {
self.get(
&format!("/events/{}/odds/history", escape(event_id)),
params,
)
.await
}
pub async fn get_line_movement(
&self,
event_id: &str,
selection_key: &str,
params: Option<&QueryParams>,
) -> Result<Response> {
let mut query = params.cloned().unwrap_or_default();
query.insert("selection_key".to_string(), selection_key.to_string());
self.get_odds_history(event_id, Some(&query)).await
}
pub async fn get_bets_snapshot(&self, params: Option<&QueryParams>) -> Result<Response> {
self.get("/bets/snapshot", params).await
}
pub async fn find_positive_ev(&self, params: Option<&QueryParams>) -> Result<Response> {
let mut query = params.cloned().unwrap_or_default();
query.insert("strategies".to_string(), "pos_ev".to_string());
self.get_bets_snapshot(Some(&query)).await
}
pub async fn find_arbitrage(&self, params: Option<&QueryParams>) -> Result<Response> {
let mut query = params.cloned().unwrap_or_default();
query.insert("strategies".to_string(), "arbitrage".to_string());
self.get_bets_snapshot(Some(&query)).await
}
pub async fn get_results(&self, event_id: &str) -> Result<Response> {
self.get(&format!("/events/{}/results", escape(event_id)), None)
.await
}
pub async fn search_racing_events(&self, params: Option<&QueryParams>) -> Result<Response> {
self.get("/racing/events", params).await
}
pub async fn get_racing_event(&self, event_id: &str) -> Result<Response> {
self.get(&format!("/racing/events/{}", escape(event_id)), None)
.await
}
pub async fn get_racing_odds(
&self,
event_id: &str,
params: Option<&QueryParams>,
) -> Result<Response> {
self.get(&format!("/racing/events/{}/odds", escape(event_id)), params)
.await
}
pub async fn find_best_odds(
&self,
event_id: &str,
params: Option<&QueryParams>,
) -> Result<Vec<BestOdds>> {
let snapshot = self.get_odds_snapshot(event_id, params).await?;
let Some(items) = snapshot.get("items").and_then(Value::as_array) else {
return Ok(Vec::new());
};
let mut best: BTreeMap<String, BestOdds> = BTreeMap::new();
for odd in items {
if odd
.get("is_available")
.and_then(Value::as_bool)
.is_some_and(|available| !available)
{
continue;
}
let Some(price) = odd.get("odds").and_then(Value::as_f64) else {
continue;
};
let selection_key = odd
.get("selection_key")
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| {
format!(
"{}:{}:{}",
value_string(odd.get("market_key")),
value_string(odd.get("side")),
value_string(odd.get("line"))
)
});
let candidate = BestOdds {
selection_key: selection_key.clone(),
bookmaker: value_string(odd.get("bookmaker")),
odds: price,
market_key: value_string(odd.get("market_key")),
odd: odd.clone(),
};
let should_replace = match best.get(&selection_key) {
Some(current) => candidate.odds > current.odds,
None => true,
};
if should_replace {
best.insert(selection_key, candidate);
}
}
Ok(best.into_values().collect())
}
pub async fn compare_bookmakers(
&self,
event_id: &str,
bookmakers: &[&str],
params: Option<&QueryParams>,
) -> Result<Response> {
let mut query = params.cloned().unwrap_or_default();
query.insert("bookmakers".to_string(), bookmakers.join(","));
self.get_odds_snapshot(event_id, Some(&query)).await
}
pub fn get_market_schema(&self) -> BTreeMap<&'static str, &'static str> {
BTreeMap::from([
("event_id", "Canonical sports event identifier."),
(
"selection_key",
"Stable selection identifier used for odds history.",
),
("market_key", "Normalized market key."),
("type", "Normalized market type."),
("period", "Normalized period integer."),
("odds", "Bookmaker decimal odds."),
("odds_no_vig", "No-vig reference price when available."),
(
"is_available",
"False when a line is unavailable or suspended.",
),
])
}
pub fn url(&self, path: &str, params: Option<&QueryParams>) -> Result<Url> {
let mut url = self.base_url.clone();
let base_path = url.path().trim_end_matches('/');
let clean_path = path.trim_start_matches('/');
url.set_path(&format!("{base_path}/{clean_path}"));
if let Some(params) = params {
let mut query = url.query_pairs_mut();
for (key, value) in params {
query.append_pair(key, value);
}
}
Ok(url)
}
}
impl OddsApiClientBuilder {
pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
self.api_key = Some(api_key.into());
self
}
pub fn bearer_token(mut self, bearer_token: impl Into<String>) -> Self {
self.bearer_token = Some(bearer_token.into());
self
}
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = Some(base_url.into());
self
}
pub fn http_client(mut self, http: reqwest::Client) -> Self {
self.http = Some(http);
self
}
pub fn build(self) -> Result<OddsApiClient> {
let base_url = self
.base_url
.unwrap_or_else(|| DEFAULT_BASE_URL.to_string());
Ok(OddsApiClient {
api_key: self.api_key.or_else(|| env::var("ODDS_API_KEY").ok()),
bearer_token: self.bearer_token,
base_url: Url::parse(base_url.trim_end_matches('/'))?,
http: self.http.unwrap_or_default(),
})
}
}
pub fn query_params<K, V, I>(items: I) -> QueryParams
where
K: Into<String>,
V: ToString,
I: IntoIterator<Item = (K, V)>,
{
items
.into_iter()
.map(|(key, value)| (key.into(), value.to_string()))
.collect()
}
fn parse_json(body: &str) -> Value {
if body.is_empty() {
Value::Null
} else {
serde_json::from_str(body).unwrap_or_else(|_| Value::String(body.to_string()))
}
}
fn escape(value: &str) -> String {
url::form_urlencoded::byte_serialize(value.as_bytes()).collect()
}
fn value_string(value: Option<&Value>) -> String {
match value {
Some(Value::String(text)) => text.clone(),
Some(Value::Number(number)) => number.to_string(),
Some(Value::Bool(value)) => value.to_string(),
_ => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use httpmock::{Method::GET, MockServer};
use serde_json::json;
#[test]
fn builds_default_client() {
let client = OddsApiClient::builder()
.api_key("test-key")
.build()
.unwrap();
assert_eq!(client.api_key(), Some("test-key"));
assert_eq!(client.base_url().as_str(), "https://api.odds-api.net/v1");
}
#[test]
fn builds_endpoint_urls_with_query_params() {
let client = OddsApiClient::builder()
.base_url("https://example.test/api/")
.build()
.unwrap();
let params = query_params([("sport", "rugby-league"), ("league", "NRL")]);
let url = client.url("/events", Some(¶ms)).unwrap();
assert_eq!(
url.as_str(),
"https://example.test/api/events?league=NRL&sport=rugby-league"
);
}
#[tokio::test]
async fn sends_api_key_header() {
let server = MockServer::start();
let mock = server.mock(|when, then| {
when.method(GET)
.path("/sports")
.header("X-API-Key", "test-key");
then.status(200).json_body(json!({ "items": [] }));
});
let client = OddsApiClient::builder()
.api_key("test-key")
.base_url(server.url(""))
.build()
.unwrap();
let response = client.list_sports().await.unwrap();
mock.assert();
assert_eq!(response["items"], json!([]));
}
#[tokio::test]
async fn maps_auth_errors() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/me");
then.status(401)
.json_body(json!({ "error": "unauthorized" }));
});
let client = OddsApiClient::builder()
.base_url(server.url(""))
.build()
.unwrap();
let err = client.get_me().await.unwrap_err();
assert!(matches!(err, OddsApiError::AuthError { status: 401, .. }));
}
#[tokio::test]
async fn maps_rate_limit_validation_and_server_errors() {
let cases = [
("/rate", 429, "rate"),
("/validation", 422, "validation"),
("/server", 500, "server"),
];
let server = MockServer::start();
for (path, status, _) in cases {
server.mock(move |when, then| {
when.method(GET).path(path);
then.status(status).json_body(json!({ "error": status }));
});
}
let client = OddsApiClient::builder()
.base_url(server.url(""))
.build()
.unwrap();
assert!(matches!(
client.get("/rate", None).await.unwrap_err(),
OddsApiError::RateLimitError { .. }
));
assert!(matches!(
client.get("/validation", None).await.unwrap_err(),
OddsApiError::ValidationError { .. }
));
assert!(matches!(
client.get("/server", None).await.unwrap_err(),
OddsApiError::ServerError { .. }
));
}
#[tokio::test]
async fn finds_best_odds_from_snapshot_items() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET).path("/events/event-1/odds/snapshot");
then.status(200).json_body(json!({
"items": [
{ "selection_key": "home", "bookmaker": "A", "market_key": "h2h", "odds": 1.80 },
{ "selection_key": "home", "bookmaker": "B", "market_key": "h2h", "odds": 1.95 },
{ "selection_key": "away", "bookmaker": "C", "market_key": "h2h", "odds": 2.10, "is_available": false }
]
}));
});
let client = OddsApiClient::builder()
.base_url(server.url(""))
.build()
.unwrap();
let best = client.find_best_odds("event-1", None).await.unwrap();
assert_eq!(best.len(), 1);
assert_eq!(best[0].bookmaker, "B");
assert_eq!(best[0].odds, 1.95);
}
}