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()));
}
}