Skip to main content

openf1/
lib.rs

1mod keys;
2mod models;
3mod params;
4mod query;
5mod resource;
6
7pub use keys::OpenF1Key;
8pub use models::{
9    CarData, ChampionshipDriver, ChampionshipTeam, Driver, Interval, Lap, Location, Meeting,
10    Overtake, Pit, Position, RaceControl, Session, SessionResult, StartingGrid, Stint, TeamRadio,
11    Weather,
12};
13pub use params::{
14    CarDataListParams, ChampionshipDriversListParams, ChampionshipTeamsListParams,
15    DriversListParams, IntervalsListParams, LapsListParams, LocationListParams, MeetingsListParams,
16    OvertakesListParams, PitListParams, PositionListParams, QueryFilter, RaceControlListParams,
17    SessionResultListParams, SessionsListParams, StartingGridListParams, StintsListParams,
18    TeamRadioListParams, WeatherListParams,
19};
20pub use query::{build_query_params, QueryParams, QueryValue};
21pub use resource::ResourceClient;
22use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
23use serde::de::DeserializeOwned;
24use serde_json::Value;
25use std::time::Duration;
26use thiserror::Error;
27
28pub const BASE_URL: &str = "https://api.openf1.org/v1";
29pub const TOKEN_URL: &str = "https://api.openf1.org/token";
30
31pub const ENDPOINTS: &[&str] = &[
32    "car_data", "championship_drivers", "championship_teams", "drivers", "intervals", "laps",
33    "location", "meetings", "overtakes", "pit", "position", "race_control", "sessions",
34    "session_result", "starting_grid", "stints", "team_radio", "weather",
35];
36
37#[derive(Debug, Error)]
38pub enum OpenF1Error {
39    #[error("http error: {0}")]
40    Http(#[from] reqwest::Error),
41    #[error("json error: {0}")]
42    Json(#[from] serde_json::Error),
43    #[error("api error ({status}): {body}")]
44    Api { status: u16, body: String },
45}
46
47pub struct OpenF1Client {
48    http: reqwest::Client,
49    base_url: String,
50    token: Option<String>,
51}
52
53impl OpenF1Client {
54    pub fn new(token: Option<String>) -> Self {
55        Self {
56            http: reqwest::Client::builder()
57                .timeout(Duration::from_secs(30))
58                .build()
59                .expect("build reqwest client"),
60            base_url: BASE_URL.to_string(),
61            token,
62        }
63    }
64
65    pub fn car_data(&self) -> ResourceClient<'_, CarData> {
66        ResourceClient::new(self, "car_data")
67    }
68
69    pub fn championship_drivers(&self) -> ResourceClient<'_, ChampionshipDriver> {
70        ResourceClient::new(self, "championship_drivers")
71    }
72
73    pub fn championship_teams(&self) -> ResourceClient<'_, ChampionshipTeam> {
74        ResourceClient::new(self, "championship_teams")
75    }
76
77    pub fn drivers(&self) -> ResourceClient<'_, Driver> {
78        ResourceClient::new(self, "drivers")
79    }
80
81    pub fn intervals(&self) -> ResourceClient<'_, Interval> {
82        ResourceClient::new(self, "intervals")
83    }
84
85    pub fn laps(&self) -> ResourceClient<'_, Lap> {
86        ResourceClient::new(self, "laps")
87    }
88
89    pub fn location(&self) -> ResourceClient<'_, Location> {
90        ResourceClient::new(self, "location")
91    }
92
93    pub fn meetings(&self) -> ResourceClient<'_, Meeting> {
94        ResourceClient::new(self, "meetings")
95    }
96
97    pub fn overtakes(&self) -> ResourceClient<'_, Overtake> {
98        ResourceClient::new(self, "overtakes")
99    }
100
101    pub fn pit(&self) -> ResourceClient<'_, Pit> {
102        ResourceClient::new(self, "pit")
103    }
104
105    pub fn position(&self) -> ResourceClient<'_, Position> {
106        ResourceClient::new(self, "position")
107    }
108
109    pub fn race_control(&self) -> ResourceClient<'_, RaceControl> {
110        ResourceClient::new(self, "race_control")
111    }
112
113    pub fn sessions(&self) -> ResourceClient<'_, Session> {
114        ResourceClient::new(self, "sessions")
115    }
116
117    pub fn session_result(&self) -> ResourceClient<'_, SessionResult> {
118        ResourceClient::new(self, "session_result")
119    }
120
121    pub fn starting_grid(&self) -> ResourceClient<'_, StartingGrid> {
122        ResourceClient::new(self, "starting_grid")
123    }
124
125    pub fn stints(&self) -> ResourceClient<'_, Stint> {
126        ResourceClient::new(self, "stints")
127    }
128
129    pub fn team_radio(&self) -> ResourceClient<'_, TeamRadio> {
130        ResourceClient::new(self, "team_radio")
131    }
132
133    pub fn weather(&self) -> ResourceClient<'_, Weather> {
134        ResourceClient::new(self, "weather")
135    }
136
137    pub async fn list(
138        &self,
139        resource: &str,
140        params: QueryParams,
141    ) -> Result<Vec<Value>, OpenF1Error> {
142        let body = self.request(resource, params, false).await?;
143        serde_json::from_str(&body).map_err(OpenF1Error::Json)
144    }
145
146    pub async fn list_typed<T: DeserializeOwned>(
147        &self,
148        resource: &str,
149        params: QueryParams,
150    ) -> Result<Vec<T>, OpenF1Error> {
151        let body = self.request(resource, params, false).await?;
152        serde_json::from_str(&body).map_err(OpenF1Error::Json)
153    }
154
155    pub async fn list_csv(
156        &self,
157        resource: &str,
158        params: QueryParams,
159    ) -> Result<String, OpenF1Error> {
160        self.request(resource, params, true).await
161    }
162
163    async fn request(
164        &self,
165        resource: &str,
166        mut params: QueryParams,
167        csv: bool,
168    ) -> Result<String, OpenF1Error> {
169        const MAX_RETRIES: u32 = 4;
170
171        if csv {
172            params.insert("csv".to_string(), QueryValue::Bool(true));
173        }
174
175        let built = build_query_params(params);
176        let url = format!("{}/{}", self.base_url.trim_end_matches('/'), resource);
177
178        let mut headers = HeaderMap::new();
179        if csv {
180            headers.insert("Accept", HeaderValue::from_static("text/csv"));
181        }
182        if let Some(token) = &self.token {
183            headers.insert(
184                AUTHORIZATION,
185                HeaderValue::from_str(&format!("Bearer {token}"))
186                    .expect("valid bearer header"),
187            );
188        }
189
190        for attempt in 0..=MAX_RETRIES {
191            let response = self
192                .http
193                .get(&url)
194                .headers(headers.clone())
195                .query(&built)
196                .send()
197                .await?;
198
199            let status = response.status();
200            let body = response.text().await?;
201
202            if status.as_u16() == 429 && attempt < MAX_RETRIES {
203                let delay_ms = 500 * 2u64.pow(attempt);
204                tokio::time::sleep(Duration::from_millis(delay_ms)).await;
205                continue;
206            }
207
208            if status.is_success() {
209                return Ok(body);
210            }
211
212            return Err(OpenF1Error::Api {
213                status: status.as_u16(),
214                body,
215            });
216        }
217
218        unreachable!("request retry loop exits via return")
219    }
220}
221
222#[cfg(test)]
223mod models_tests {
224    use super::models::*;
225    use std::fs;
226
227    fn fixture(name: &str) -> String {
228        fs::read_to_string(format!(
229            "{}/../../../specs/fixtures/{name}.json",
230            env!("CARGO_MANIFEST_DIR")
231        ))
232        .unwrap_or_else(|error| panic!("read fixture {name}: {error}"))
233    }
234
235    macro_rules! fixture_test {
236        ($name:ident, $ty:ty) => {
237            #[test]
238            fn $name() {
239                let raw = fixture(stringify!($name));
240                let rows: Vec<$ty> =
241                    serde_json::from_str(&raw).expect(concat!("deserialize ", stringify!($name)));
242                assert!(!rows.is_empty(), "{}", stringify!($name));
243            }
244        };
245    }
246
247    fixture_test!(car_data, CarData);
248    fixture_test!(championship_drivers, ChampionshipDriver);
249    fixture_test!(championship_teams, ChampionshipTeam);
250    fixture_test!(drivers, Driver);
251    fixture_test!(intervals, Interval);
252    fixture_test!(laps, Lap);
253    fixture_test!(location, Location);
254    fixture_test!(meetings, Meeting);
255    fixture_test!(overtakes, Overtake);
256    fixture_test!(pit, Pit);
257    fixture_test!(position, Position);
258    fixture_test!(race_control, RaceControl);
259    fixture_test!(session_result, SessionResult);
260    fixture_test!(sessions, Session);
261    fixture_test!(starting_grid, StartingGrid);
262    fixture_test!(stints, Stint);
263    fixture_test!(team_radio, TeamRadio);
264    fixture_test!(weather, Weather);
265
266    #[test]
267    fn session_result_accepts_null_laps_and_position() {
268        let raw = r#"[
269            {
270                "position": null,
271                "driver_number": 16,
272                "number_of_laps": null,
273                "points": 0.0,
274                "dnf": false,
275                "dns": false,
276                "dsq": true,
277                "gap_to_leader": null,
278                "duration": null,
279                "meeting_key": 1255,
280                "session_key": 9998
281            }
282        ]"#;
283        let rows: Vec<SessionResult> = serde_json::from_str(raw).expect("deserialize live shape");
284        assert_eq!(rows[0].number_of_laps, 0);
285        assert_eq!(rows[0].position, None);
286    }
287}
288
289#[cfg(test)]
290mod params_tests {
291    use super::params::*;
292    use super::query::QueryValue;
293    use super::OpenF1Key;
294    use std::fs;
295
296    #[test]
297    fn list_params_conformance_matches_typescript() {
298        #[derive(serde::Deserialize)]
299        struct Manifest {
300            params: std::collections::HashMap<String, Vec<String>>,
301        }
302
303        let raw = fs::read_to_string(format!(
304            "{}/../../../specs/conformance/list-params.json",
305            env!("CARGO_MANIFEST_DIR")
306        ))
307        .expect("read list-params manifest");
308        let manifest: Manifest = serde_json::from_str(&raw).expect("parse manifest");
309
310        let rust_params: std::collections::HashMap<&str, &[&str]> = [
311            ("CarDataListParams", &["driver_number", "session_key", "speed"][..]),
312            (
313                "ChampionshipDriversListParams",
314                &["driver_number", "session_key"][..],
315            ),
316            (
317                "ChampionshipTeamsListParams",
318                &["session_key", "team_name"][..],
319            ),
320            ("DriversListParams", &["driver_number", "session_key"][..]),
321            ("IntervalsListParams", &["interval", "session_key"][..]),
322            ("LapsListParams", &["driver_number", "lap_number", "session_key"][..]),
323            ("LocationListParams", &["date", "driver_number", "session_key"][..]),
324            ("MeetingsListParams", &["country_name", "year"][..]),
325            ("OvertakesListParams", &["session_key"][..]),
326            ("PitListParams", &["session_key"][..]),
327            ("PositionListParams", &["driver_number", "session_key"][..]),
328            ("RaceControlListParams", &["session_key"][..]),
329            ("SessionsListParams", &["country_name", "session_name", "year"][..]),
330            ("SessionResultListParams", &["position", "session_key"][..]),
331            ("StartingGridListParams", &["session_key"][..]),
332            ("StintsListParams", &["driver_number", "session_key"][..]),
333            ("TeamRadioListParams", &["driver_number", "session_key"][..]),
334            ("WeatherListParams", &["session_key"][..]),
335        ]
336        .into_iter()
337        .collect();
338
339        for (name, fields) in rust_params {
340            let expected = manifest
341                .params
342                .get(name)
343                .unwrap_or_else(|| panic!("missing manifest entry for {name}"));
344            let mut got = fields.to_vec();
345            got.sort();
346            let mut want = expected.clone();
347            want.sort();
348            assert_eq!(got, want, "{name}");
349        }
350    }
351
352    #[test]
353    fn meetings_list_params_build_query() {
354        let params = MeetingsListParams {
355            year: Some(2026),
356            country_name: None,
357        }
358        .into();
359        assert_eq!(
360            super::build_query_params(params),
361            [("year".to_string(), "2026".to_string())]
362                .into_iter()
363                .collect()
364        );
365    }
366
367    #[test]
368    fn drivers_list_params_support_latest_session_key() {
369        let params = DriversListParams {
370            session_key: Some(OpenF1Key::Latest),
371            driver_number: Some(44),
372        }
373        .into();
374        let built = super::build_query_params(params);
375        assert_eq!(built.get("session_key").map(String::as_str), Some("latest"));
376        assert_eq!(built.get("driver_number").map(String::as_str), Some("44"));
377    }
378
379    #[test]
380    fn laps_list_params_support_filter_operators() {
381        let params = LapsListParams {
382            session_key: Some(OpenF1Key::Id(9161)),
383            driver_number: Some(63),
384            lap_number: Some(QueryFilter {
385                lte: Some(QueryValue::Number(3)),
386                ..QueryFilter::default()
387            }),
388        }
389        .into();
390        let built = super::build_query_params(params);
391        assert_eq!(built.get("lap_number<="), Some(&"3".to_string()));
392    }
393}