Skip to main content

odds_api/
lib.rs

1//! Official Rust SDK for [odds-api.net](https://odds-api.net).
2//!
3//! The crate exposes an async [`OddsApiClient`] with helper methods for the
4//! main public API endpoints plus a generic [`OddsApiClient::get`] method for
5//! unsupported endpoints.
6
7use 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(&params)).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}