1use reqwest::{header, Method};
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use std::{collections::BTreeMap, env};
11use thiserror::Error;
12use url::Url;
13
14pub const DEFAULT_BASE_URL: &str = "https://api.odds-api.net/v1";
15
16pub type QueryParams = BTreeMap<String, String>;
17pub type Response = Value;
18pub type Result<T> = std::result::Result<T, OddsApiError>;
19
20#[derive(Clone, Debug)]
21pub struct OddsApiClient {
22 api_key: Option<String>,
23 bearer_token: Option<String>,
24 base_url: Url,
25 http: reqwest::Client,
26}
27
28#[derive(Clone, Debug, Default)]
29pub struct OddsApiClientBuilder {
30 api_key: Option<String>,
31 bearer_token: Option<String>,
32 base_url: Option<String>,
33 http: Option<reqwest::Client>,
34}
35
36#[derive(Debug, Error)]
37pub enum OddsApiError {
38 #[error("Odds API authentication failed with status {status}")]
39 AuthError { status: u16, body: Value },
40 #[error("Odds API rate limit exceeded")]
41 RateLimitError { status: u16, body: Value },
42 #[error("Odds API validation failed with status {status}")]
43 ValidationError { status: u16, body: Value },
44 #[error("Odds API server error with status {status}")]
45 ServerError { status: u16, body: Value },
46 #[error("Odds API request failed with status {status}")]
47 ApiError { status: u16, body: Value },
48 #[error("invalid SDK configuration: {0}")]
49 Config(String),
50 #[error(transparent)]
51 Http(#[from] reqwest::Error),
52 #[error(transparent)]
53 Url(#[from] url::ParseError),
54 #[error(transparent)]
55 Json(#[from] serde_json::Error),
56}
57
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
59pub struct BestOdds {
60 pub selection_key: String,
61 pub bookmaker: String,
62 pub odds: f64,
63 pub market_key: String,
64 pub odd: Value,
65}
66
67impl OddsApiClient {
68 pub fn new(api_key: impl Into<String>) -> Result<Self> {
69 Self::builder().api_key(api_key).build()
70 }
71
72 pub fn from_env() -> Result<Self> {
73 Self::builder()
74 .api_key(env::var("ODDS_API_KEY").unwrap_or_default())
75 .base_url(
76 env::var("ODDS_API_BASE_URL").unwrap_or_else(|_| DEFAULT_BASE_URL.to_string()),
77 )
78 .build()
79 }
80
81 pub fn builder() -> OddsApiClientBuilder {
82 OddsApiClientBuilder::default()
83 }
84
85 pub fn api_key(&self) -> Option<&str> {
86 self.api_key.as_deref()
87 }
88
89 pub fn base_url(&self) -> &Url {
90 &self.base_url
91 }
92
93 pub async fn request(
94 &self,
95 method: Method,
96 path: &str,
97 params: Option<&QueryParams>,
98 body: Option<&Value>,
99 ) -> Result<Value> {
100 let url = self.url(path, params)?;
101 let mut request = self
102 .http
103 .request(method, url)
104 .header(header::ACCEPT, "application/json");
105
106 if let Some(api_key) = &self.api_key {
107 if !api_key.is_empty() {
108 request = request.header("X-API-Key", api_key);
109 }
110 }
111
112 if let Some(token) = &self.bearer_token {
113 if !token.is_empty() {
114 request = request.bearer_auth(token);
115 }
116 }
117
118 if let Some(payload) = body {
119 request = request.json(payload);
120 }
121
122 let response = request.send().await?;
123 let status = response.status();
124 let body = response.text().await?;
125 let parsed = parse_json(&body);
126
127 if status.is_success() {
128 return Ok(parsed);
129 }
130
131 Err(match status.as_u16() {
132 401 | 403 => OddsApiError::AuthError {
133 status: status.as_u16(),
134 body: parsed,
135 },
136 429 => OddsApiError::RateLimitError {
137 status: status.as_u16(),
138 body: parsed,
139 },
140 400 | 422 => OddsApiError::ValidationError {
141 status: status.as_u16(),
142 body: parsed,
143 },
144 500..=599 => OddsApiError::ServerError {
145 status: status.as_u16(),
146 body: parsed,
147 },
148 _ => OddsApiError::ApiError {
149 status: status.as_u16(),
150 body: parsed,
151 },
152 })
153 }
154
155 pub async fn get(&self, path: &str, params: Option<&QueryParams>) -> Result<Value> {
156 self.request(Method::GET, path, params, None).await
157 }
158
159 pub async fn post(
160 &self,
161 path: &str,
162 params: Option<&QueryParams>,
163 body: &Value,
164 ) -> Result<Value> {
165 self.request(Method::POST, path, params, Some(body)).await
166 }
167
168 pub async fn list_sports(&self) -> Result<Response> {
169 self.get("/sports", None).await
170 }
171
172 pub async fn get_api_metadata(&self) -> Result<Response> {
173 self.get("/", None).await
174 }
175
176 pub async fn get_me(&self) -> Result<Response> {
177 self.get("/me", None).await
178 }
179
180 pub async fn get_usage(&self) -> Result<Response> {
181 self.get("/usage", None).await
182 }
183
184 pub async fn get_limits(&self) -> Result<Response> {
185 self.get("/limits", None).await
186 }
187
188 pub async fn list_bookmakers(&self, params: Option<&QueryParams>) -> Result<Response> {
189 self.get("/bookmakers", params).await
190 }
191
192 pub async fn list_bookmaker_countries(&self) -> Result<Response> {
193 self.get("/bookmakers/countries", None).await
194 }
195
196 pub async fn list_leagues(&self, params: Option<&QueryParams>) -> Result<Response> {
197 self.get("/leagues", params).await
198 }
199
200 pub async fn search_events(&self, params: Option<&QueryParams>) -> Result<Response> {
201 self.get("/events", params).await
202 }
203
204 pub async fn get_event(&self, event_id: &str) -> Result<Response> {
205 self.get(&format!("/events/{}", escape(event_id)), None)
206 .await
207 }
208
209 pub async fn get_event_bookmakers(&self, event_id: &str) -> Result<Response> {
210 self.get(&format!("/events/{}/bookmakers", escape(event_id)), None)
211 .await
212 }
213
214 pub async fn get_odds_snapshot(
215 &self,
216 event_id: &str,
217 params: Option<&QueryParams>,
218 ) -> Result<Response> {
219 self.get(
220 &format!("/events/{}/odds/snapshot", escape(event_id)),
221 params,
222 )
223 .await
224 }
225
226 pub async fn get_odds_history(
227 &self,
228 event_id: &str,
229 params: Option<&QueryParams>,
230 ) -> Result<Response> {
231 self.get(
232 &format!("/events/{}/odds/history", escape(event_id)),
233 params,
234 )
235 .await
236 }
237
238 pub async fn get_line_movement(
239 &self,
240 event_id: &str,
241 selection_key: &str,
242 params: Option<&QueryParams>,
243 ) -> Result<Response> {
244 let mut query = params.cloned().unwrap_or_default();
245 query.insert("selection_key".to_string(), selection_key.to_string());
246 self.get_odds_history(event_id, Some(&query)).await
247 }
248
249 pub async fn get_bets_snapshot(&self, params: Option<&QueryParams>) -> Result<Response> {
250 self.get("/bets/snapshot", params).await
251 }
252
253 pub async fn find_positive_ev(&self, params: Option<&QueryParams>) -> Result<Response> {
254 let mut query = params.cloned().unwrap_or_default();
255 query.insert("strategies".to_string(), "pos_ev".to_string());
256 self.get_bets_snapshot(Some(&query)).await
257 }
258
259 pub async fn find_arbitrage(&self, params: Option<&QueryParams>) -> Result<Response> {
260 let mut query = params.cloned().unwrap_or_default();
261 query.insert("strategies".to_string(), "arbitrage".to_string());
262 self.get_bets_snapshot(Some(&query)).await
263 }
264
265 pub async fn get_results(&self, event_id: &str) -> Result<Response> {
266 self.get(&format!("/events/{}/results", escape(event_id)), None)
267 .await
268 }
269
270 pub async fn search_racing_events(&self, params: Option<&QueryParams>) -> Result<Response> {
271 self.get("/racing/events", params).await
272 }
273
274 pub async fn get_racing_event(&self, event_id: &str) -> Result<Response> {
275 self.get(&format!("/racing/events/{}", escape(event_id)), None)
276 .await
277 }
278
279 pub async fn get_racing_odds(
280 &self,
281 event_id: &str,
282 params: Option<&QueryParams>,
283 ) -> Result<Response> {
284 self.get(&format!("/racing/events/{}/odds", escape(event_id)), params)
285 .await
286 }
287
288 pub async fn find_best_odds(
289 &self,
290 event_id: &str,
291 params: Option<&QueryParams>,
292 ) -> Result<Vec<BestOdds>> {
293 let snapshot = self.get_odds_snapshot(event_id, params).await?;
294 let Some(items) = snapshot.get("items").and_then(Value::as_array) else {
295 return Ok(Vec::new());
296 };
297
298 let mut best: BTreeMap<String, BestOdds> = BTreeMap::new();
299 for odd in items {
300 if odd
301 .get("is_available")
302 .and_then(Value::as_bool)
303 .is_some_and(|available| !available)
304 {
305 continue;
306 }
307
308 let Some(price) = odd.get("odds").and_then(Value::as_f64) else {
309 continue;
310 };
311
312 let selection_key = odd
313 .get("selection_key")
314 .and_then(Value::as_str)
315 .map(str::to_string)
316 .unwrap_or_else(|| {
317 format!(
318 "{}:{}:{}",
319 value_string(odd.get("market_key")),
320 value_string(odd.get("side")),
321 value_string(odd.get("line"))
322 )
323 });
324
325 let candidate = BestOdds {
326 selection_key: selection_key.clone(),
327 bookmaker: value_string(odd.get("bookmaker")),
328 odds: price,
329 market_key: value_string(odd.get("market_key")),
330 odd: odd.clone(),
331 };
332
333 let should_replace = match best.get(&selection_key) {
334 Some(current) => candidate.odds > current.odds,
335 None => true,
336 };
337
338 if should_replace {
339 best.insert(selection_key, candidate);
340 }
341 }
342
343 Ok(best.into_values().collect())
344 }
345
346 pub async fn compare_bookmakers(
347 &self,
348 event_id: &str,
349 bookmakers: &[&str],
350 params: Option<&QueryParams>,
351 ) -> Result<Response> {
352 let mut query = params.cloned().unwrap_or_default();
353 query.insert("bookmakers".to_string(), bookmakers.join(","));
354 self.get_odds_snapshot(event_id, Some(&query)).await
355 }
356
357 pub fn get_market_schema(&self) -> BTreeMap<&'static str, &'static str> {
358 BTreeMap::from([
359 ("event_id", "Canonical sports event identifier."),
360 (
361 "selection_key",
362 "Stable selection identifier used for odds history.",
363 ),
364 ("market_key", "Normalized market key."),
365 ("type", "Normalized market type."),
366 ("period", "Normalized period integer."),
367 ("odds", "Bookmaker decimal odds."),
368 ("odds_no_vig", "No-vig reference price when available."),
369 (
370 "is_available",
371 "False when a line is unavailable or suspended.",
372 ),
373 ])
374 }
375
376 pub fn url(&self, path: &str, params: Option<&QueryParams>) -> Result<Url> {
377 let mut url = self.base_url.clone();
378 let base_path = url.path().trim_end_matches('/');
379 let clean_path = path.trim_start_matches('/');
380 url.set_path(&format!("{base_path}/{clean_path}"));
381
382 if let Some(params) = params {
383 let mut query = url.query_pairs_mut();
384 for (key, value) in params {
385 query.append_pair(key, value);
386 }
387 }
388
389 Ok(url)
390 }
391}
392
393impl OddsApiClientBuilder {
394 pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
395 self.api_key = Some(api_key.into());
396 self
397 }
398
399 pub fn bearer_token(mut self, bearer_token: impl Into<String>) -> Self {
400 self.bearer_token = Some(bearer_token.into());
401 self
402 }
403
404 pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
405 self.base_url = Some(base_url.into());
406 self
407 }
408
409 pub fn http_client(mut self, http: reqwest::Client) -> Self {
410 self.http = Some(http);
411 self
412 }
413
414 pub fn build(self) -> Result<OddsApiClient> {
415 let base_url = self
416 .base_url
417 .unwrap_or_else(|| DEFAULT_BASE_URL.to_string());
418 Ok(OddsApiClient {
419 api_key: self.api_key.or_else(|| env::var("ODDS_API_KEY").ok()),
420 bearer_token: self.bearer_token,
421 base_url: Url::parse(base_url.trim_end_matches('/'))?,
422 http: self.http.unwrap_or_default(),
423 })
424 }
425}
426
427pub fn query_params<K, V, I>(items: I) -> QueryParams
428where
429 K: Into<String>,
430 V: ToString,
431 I: IntoIterator<Item = (K, V)>,
432{
433 items
434 .into_iter()
435 .map(|(key, value)| (key.into(), value.to_string()))
436 .collect()
437}
438
439fn parse_json(body: &str) -> Value {
440 if body.is_empty() {
441 Value::Null
442 } else {
443 serde_json::from_str(body).unwrap_or_else(|_| Value::String(body.to_string()))
444 }
445}
446
447fn escape(value: &str) -> String {
448 url::form_urlencoded::byte_serialize(value.as_bytes()).collect()
449}
450
451fn value_string(value: Option<&Value>) -> String {
452 match value {
453 Some(Value::String(text)) => text.clone(),
454 Some(Value::Number(number)) => number.to_string(),
455 Some(Value::Bool(value)) => value.to_string(),
456 _ => String::new(),
457 }
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463 use httpmock::{Method::GET, MockServer};
464 use serde_json::json;
465
466 #[test]
467 fn builds_default_client() {
468 let client = OddsApiClient::builder()
469 .api_key("test-key")
470 .build()
471 .unwrap();
472
473 assert_eq!(client.api_key(), Some("test-key"));
474 assert_eq!(client.base_url().as_str(), "https://api.odds-api.net/v1");
475 }
476
477 #[test]
478 fn builds_endpoint_urls_with_query_params() {
479 let client = OddsApiClient::builder()
480 .base_url("https://example.test/api/")
481 .build()
482 .unwrap();
483 let params = query_params([("sport", "rugby-league"), ("league", "NRL")]);
484
485 let url = client.url("/events", Some(¶ms)).unwrap();
486
487 assert_eq!(
488 url.as_str(),
489 "https://example.test/api/events?league=NRL&sport=rugby-league"
490 );
491 }
492
493 #[tokio::test]
494 async fn sends_api_key_header() {
495 let server = MockServer::start();
496 let mock = server.mock(|when, then| {
497 when.method(GET)
498 .path("/sports")
499 .header("X-API-Key", "test-key");
500 then.status(200).json_body(json!({ "items": [] }));
501 });
502 let client = OddsApiClient::builder()
503 .api_key("test-key")
504 .base_url(server.url(""))
505 .build()
506 .unwrap();
507
508 let response = client.list_sports().await.unwrap();
509
510 mock.assert();
511 assert_eq!(response["items"], json!([]));
512 }
513
514 #[tokio::test]
515 async fn maps_auth_errors() {
516 let server = MockServer::start();
517 server.mock(|when, then| {
518 when.method(GET).path("/me");
519 then.status(401)
520 .json_body(json!({ "error": "unauthorized" }));
521 });
522 let client = OddsApiClient::builder()
523 .base_url(server.url(""))
524 .build()
525 .unwrap();
526
527 let err = client.get_me().await.unwrap_err();
528
529 assert!(matches!(err, OddsApiError::AuthError { status: 401, .. }));
530 }
531
532 #[tokio::test]
533 async fn maps_rate_limit_validation_and_server_errors() {
534 let cases = [
535 ("/rate", 429, "rate"),
536 ("/validation", 422, "validation"),
537 ("/server", 500, "server"),
538 ];
539 let server = MockServer::start();
540 for (path, status, _) in cases {
541 server.mock(move |when, then| {
542 when.method(GET).path(path);
543 then.status(status).json_body(json!({ "error": status }));
544 });
545 }
546 let client = OddsApiClient::builder()
547 .base_url(server.url(""))
548 .build()
549 .unwrap();
550
551 assert!(matches!(
552 client.get("/rate", None).await.unwrap_err(),
553 OddsApiError::RateLimitError { .. }
554 ));
555 assert!(matches!(
556 client.get("/validation", None).await.unwrap_err(),
557 OddsApiError::ValidationError { .. }
558 ));
559 assert!(matches!(
560 client.get("/server", None).await.unwrap_err(),
561 OddsApiError::ServerError { .. }
562 ));
563 }
564
565 #[tokio::test]
566 async fn finds_best_odds_from_snapshot_items() {
567 let server = MockServer::start();
568 server.mock(|when, then| {
569 when.method(GET).path("/events/event-1/odds/snapshot");
570 then.status(200).json_body(json!({
571 "items": [
572 { "selection_key": "home", "bookmaker": "A", "market_key": "h2h", "odds": 1.80 },
573 { "selection_key": "home", "bookmaker": "B", "market_key": "h2h", "odds": 1.95 },
574 { "selection_key": "away", "bookmaker": "C", "market_key": "h2h", "odds": 2.10, "is_available": false }
575 ]
576 }));
577 });
578 let client = OddsApiClient::builder()
579 .base_url(server.url(""))
580 .build()
581 .unwrap();
582
583 let best = client.find_best_odds("event-1", None).await.unwrap();
584
585 assert_eq!(best.len(), 1);
586 assert_eq!(best[0].bookmaker, "B");
587 assert_eq!(best[0].odds, 1.95);
588 }
589}