openf1 0.1.0

Unofficial Rust client for the OpenF1 API
Documentation
mod keys;
mod models;
mod params;
mod query;
mod resource;

pub use keys::OpenF1Key;
pub use models::{
    CarData, ChampionshipDriver, ChampionshipTeam, Driver, Interval, Lap, Location, Meeting,
    Overtake, Pit, Position, RaceControl, Session, SessionResult, StartingGrid, Stint, TeamRadio,
    Weather,
};
pub use params::{
    CarDataListParams, ChampionshipDriversListParams, ChampionshipTeamsListParams,
    DriversListParams, IntervalsListParams, LapsListParams, LocationListParams, MeetingsListParams,
    OvertakesListParams, PitListParams, PositionListParams, QueryFilter, RaceControlListParams,
    SessionResultListParams, SessionsListParams, StartingGridListParams, StintsListParams,
    TeamRadioListParams, WeatherListParams,
};
pub use query::{build_query_params, QueryParams, QueryValue};
pub use resource::ResourceClient;
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
use serde::de::DeserializeOwned;
use serde_json::Value;
use std::time::Duration;
use thiserror::Error;

pub const BASE_URL: &str = "https://api.openf1.org/v1";
pub const TOKEN_URL: &str = "https://api.openf1.org/token";

pub const ENDPOINTS: &[&str] = &[
    "car_data", "championship_drivers", "championship_teams", "drivers", "intervals", "laps",
    "location", "meetings", "overtakes", "pit", "position", "race_control", "sessions",
    "session_result", "starting_grid", "stints", "team_radio", "weather",
];

#[derive(Debug, Error)]
pub enum OpenF1Error {
    #[error("http error: {0}")]
    Http(#[from] reqwest::Error),
    #[error("json error: {0}")]
    Json(#[from] serde_json::Error),
    #[error("api error ({status}): {body}")]
    Api { status: u16, body: String },
}

pub struct OpenF1Client {
    http: reqwest::Client,
    base_url: String,
    token: Option<String>,
}

impl OpenF1Client {
    pub fn new(token: Option<String>) -> Self {
        Self {
            http: reqwest::Client::builder()
                .timeout(Duration::from_secs(30))
                .build()
                .expect("build reqwest client"),
            base_url: BASE_URL.to_string(),
            token,
        }
    }

    pub fn car_data(&self) -> ResourceClient<'_, CarData> {
        ResourceClient::new(self, "car_data")
    }

    pub fn championship_drivers(&self) -> ResourceClient<'_, ChampionshipDriver> {
        ResourceClient::new(self, "championship_drivers")
    }

    pub fn championship_teams(&self) -> ResourceClient<'_, ChampionshipTeam> {
        ResourceClient::new(self, "championship_teams")
    }

    pub fn drivers(&self) -> ResourceClient<'_, Driver> {
        ResourceClient::new(self, "drivers")
    }

    pub fn intervals(&self) -> ResourceClient<'_, Interval> {
        ResourceClient::new(self, "intervals")
    }

    pub fn laps(&self) -> ResourceClient<'_, Lap> {
        ResourceClient::new(self, "laps")
    }

    pub fn location(&self) -> ResourceClient<'_, Location> {
        ResourceClient::new(self, "location")
    }

    pub fn meetings(&self) -> ResourceClient<'_, Meeting> {
        ResourceClient::new(self, "meetings")
    }

    pub fn overtakes(&self) -> ResourceClient<'_, Overtake> {
        ResourceClient::new(self, "overtakes")
    }

    pub fn pit(&self) -> ResourceClient<'_, Pit> {
        ResourceClient::new(self, "pit")
    }

    pub fn position(&self) -> ResourceClient<'_, Position> {
        ResourceClient::new(self, "position")
    }

    pub fn race_control(&self) -> ResourceClient<'_, RaceControl> {
        ResourceClient::new(self, "race_control")
    }

    pub fn sessions(&self) -> ResourceClient<'_, Session> {
        ResourceClient::new(self, "sessions")
    }

    pub fn session_result(&self) -> ResourceClient<'_, SessionResult> {
        ResourceClient::new(self, "session_result")
    }

    pub fn starting_grid(&self) -> ResourceClient<'_, StartingGrid> {
        ResourceClient::new(self, "starting_grid")
    }

    pub fn stints(&self) -> ResourceClient<'_, Stint> {
        ResourceClient::new(self, "stints")
    }

    pub fn team_radio(&self) -> ResourceClient<'_, TeamRadio> {
        ResourceClient::new(self, "team_radio")
    }

    pub fn weather(&self) -> ResourceClient<'_, Weather> {
        ResourceClient::new(self, "weather")
    }

    pub async fn list(
        &self,
        resource: &str,
        params: QueryParams,
    ) -> Result<Vec<Value>, OpenF1Error> {
        let body = self.request(resource, params, false).await?;
        serde_json::from_str(&body).map_err(OpenF1Error::Json)
    }

    pub async fn list_typed<T: DeserializeOwned>(
        &self,
        resource: &str,
        params: QueryParams,
    ) -> Result<Vec<T>, OpenF1Error> {
        let body = self.request(resource, params, false).await?;
        serde_json::from_str(&body).map_err(OpenF1Error::Json)
    }

    pub async fn list_csv(
        &self,
        resource: &str,
        params: QueryParams,
    ) -> Result<String, OpenF1Error> {
        self.request(resource, params, true).await
    }

    async fn request(
        &self,
        resource: &str,
        mut params: QueryParams,
        csv: bool,
    ) -> Result<String, OpenF1Error> {
        const MAX_RETRIES: u32 = 4;

        if csv {
            params.insert("csv".to_string(), QueryValue::Bool(true));
        }

        let built = build_query_params(params);
        let url = format!("{}/{}", self.base_url.trim_end_matches('/'), resource);

        let mut headers = HeaderMap::new();
        if csv {
            headers.insert("Accept", HeaderValue::from_static("text/csv"));
        }
        if let Some(token) = &self.token {
            headers.insert(
                AUTHORIZATION,
                HeaderValue::from_str(&format!("Bearer {token}"))
                    .expect("valid bearer header"),
            );
        }

        for attempt in 0..=MAX_RETRIES {
            let response = self
                .http
                .get(&url)
                .headers(headers.clone())
                .query(&built)
                .send()
                .await?;

            let status = response.status();
            let body = response.text().await?;

            if status.as_u16() == 429 && attempt < MAX_RETRIES {
                let delay_ms = 500 * 2u64.pow(attempt);
                tokio::time::sleep(Duration::from_millis(delay_ms)).await;
                continue;
            }

            if status.is_success() {
                return Ok(body);
            }

            return Err(OpenF1Error::Api {
                status: status.as_u16(),
                body,
            });
        }

        unreachable!("request retry loop exits via return")
    }
}

#[cfg(test)]
mod models_tests {
    use super::models::*;
    use std::fs;

    fn fixture(name: &str) -> String {
        fs::read_to_string(format!(
            "{}/../../../specs/fixtures/{name}.json",
            env!("CARGO_MANIFEST_DIR")
        ))
        .unwrap_or_else(|error| panic!("read fixture {name}: {error}"))
    }

    macro_rules! fixture_test {
        ($name:ident, $ty:ty) => {
            #[test]
            fn $name() {
                let raw = fixture(stringify!($name));
                let rows: Vec<$ty> =
                    serde_json::from_str(&raw).expect(concat!("deserialize ", stringify!($name)));
                assert!(!rows.is_empty(), "{}", stringify!($name));
            }
        };
    }

    fixture_test!(car_data, CarData);
    fixture_test!(championship_drivers, ChampionshipDriver);
    fixture_test!(championship_teams, ChampionshipTeam);
    fixture_test!(drivers, Driver);
    fixture_test!(intervals, Interval);
    fixture_test!(laps, Lap);
    fixture_test!(location, Location);
    fixture_test!(meetings, Meeting);
    fixture_test!(overtakes, Overtake);
    fixture_test!(pit, Pit);
    fixture_test!(position, Position);
    fixture_test!(race_control, RaceControl);
    fixture_test!(session_result, SessionResult);
    fixture_test!(sessions, Session);
    fixture_test!(starting_grid, StartingGrid);
    fixture_test!(stints, Stint);
    fixture_test!(team_radio, TeamRadio);
    fixture_test!(weather, Weather);

    #[test]
    fn session_result_accepts_null_laps_and_position() {
        let raw = r#"[
            {
                "position": null,
                "driver_number": 16,
                "number_of_laps": null,
                "points": 0.0,
                "dnf": false,
                "dns": false,
                "dsq": true,
                "gap_to_leader": null,
                "duration": null,
                "meeting_key": 1255,
                "session_key": 9998
            }
        ]"#;
        let rows: Vec<SessionResult> = serde_json::from_str(raw).expect("deserialize live shape");
        assert_eq!(rows[0].number_of_laps, 0);
        assert_eq!(rows[0].position, None);
    }
}

#[cfg(test)]
mod params_tests {
    use super::params::*;
    use super::query::QueryValue;
    use super::OpenF1Key;
    use std::fs;

    #[test]
    fn list_params_conformance_matches_typescript() {
        #[derive(serde::Deserialize)]
        struct Manifest {
            params: std::collections::HashMap<String, Vec<String>>,
        }

        let raw = fs::read_to_string(format!(
            "{}/../../../specs/conformance/list-params.json",
            env!("CARGO_MANIFEST_DIR")
        ))
        .expect("read list-params manifest");
        let manifest: Manifest = serde_json::from_str(&raw).expect("parse manifest");

        let rust_params: std::collections::HashMap<&str, &[&str]> = [
            ("CarDataListParams", &["driver_number", "session_key", "speed"][..]),
            (
                "ChampionshipDriversListParams",
                &["driver_number", "session_key"][..],
            ),
            (
                "ChampionshipTeamsListParams",
                &["session_key", "team_name"][..],
            ),
            ("DriversListParams", &["driver_number", "session_key"][..]),
            ("IntervalsListParams", &["interval", "session_key"][..]),
            ("LapsListParams", &["driver_number", "lap_number", "session_key"][..]),
            ("LocationListParams", &["date", "driver_number", "session_key"][..]),
            ("MeetingsListParams", &["country_name", "year"][..]),
            ("OvertakesListParams", &["session_key"][..]),
            ("PitListParams", &["session_key"][..]),
            ("PositionListParams", &["driver_number", "session_key"][..]),
            ("RaceControlListParams", &["session_key"][..]),
            ("SessionsListParams", &["country_name", "session_name", "year"][..]),
            ("SessionResultListParams", &["position", "session_key"][..]),
            ("StartingGridListParams", &["session_key"][..]),
            ("StintsListParams", &["driver_number", "session_key"][..]),
            ("TeamRadioListParams", &["driver_number", "session_key"][..]),
            ("WeatherListParams", &["session_key"][..]),
        ]
        .into_iter()
        .collect();

        for (name, fields) in rust_params {
            let expected = manifest
                .params
                .get(name)
                .unwrap_or_else(|| panic!("missing manifest entry for {name}"));
            let mut got = fields.to_vec();
            got.sort();
            let mut want = expected.clone();
            want.sort();
            assert_eq!(got, want, "{name}");
        }
    }

    #[test]
    fn meetings_list_params_build_query() {
        let params = MeetingsListParams {
            year: Some(2026),
            country_name: None,
        }
        .into();
        assert_eq!(
            super::build_query_params(params),
            [("year".to_string(), "2026".to_string())]
                .into_iter()
                .collect()
        );
    }

    #[test]
    fn drivers_list_params_support_latest_session_key() {
        let params = DriversListParams {
            session_key: Some(OpenF1Key::Latest),
            driver_number: Some(44),
        }
        .into();
        let built = super::build_query_params(params);
        assert_eq!(built.get("session_key").map(String::as_str), Some("latest"));
        assert_eq!(built.get("driver_number").map(String::as_str), Some("44"));
    }

    #[test]
    fn laps_list_params_support_filter_operators() {
        let params = LapsListParams {
            session_key: Some(OpenF1Key::Id(9161)),
            driver_number: Some(63),
            lap_number: Some(QueryFilter {
                lte: Some(QueryValue::Number(3)),
                ..QueryFilter::default()
            }),
        }
        .into();
        let built = super::build_query_params(params);
        assert_eq!(built.get("lap_number<="), Some(&"3".to_string()));
    }
}