Open F1 API calls

This commit is contained in:
2026-03-03 00:20:09 -05:00
commit cc44f96127
7 changed files with 835 additions and 0 deletions

18
internal/api/client.go Normal file
View File

@@ -0,0 +1,18 @@
package api
import (
"net/http"
"time"
)
type OpenF1Client struct {
url string
httpClient *http.Client
}
func NewOpenF1Client(url string, timeout time.Duration) *OpenF1Client {
return &OpenF1Client{
url: url,
httpClient: &http.Client{Timeout: timeout},
}
}

343
internal/api/openf1.go Normal file
View File

@@ -0,0 +1,343 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"github.com/AmanTahiliani/box-box/internal/models"
)
// get performs a GET request and returns the response body, or an error if the
// status code is not 200 OK.
func (c *OpenF1Client) get(url string) (io.ReadCloser, error) {
resp, err := c.httpClient.Get(url)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("openf1 API returned status %d for %s", resp.StatusCode, url)
}
return resp.Body, nil
}
func (c *OpenF1Client) GetMeetingsForYear(year int) ([]models.Meeting, error) {
if year < 2023 {
return nil, errors.New("Invalid year: " + strconv.Itoa(year) + ". Year must be 2023 or later.")
}
body, err := c.get(fmt.Sprintf("%s/v1/meetings?year=%d", c.url, year))
if err != nil {
return nil, err
}
defer body.Close()
var result []models.Meeting
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
func (c *OpenF1Client) GetSessionsForMeeting(meetingKey int) ([]models.Session, error) {
body, err := c.get(fmt.Sprintf("%s/v1/sessions?meeting_key=%d", c.url, meetingKey))
if err != nil {
return nil, err
}
defer body.Close()
var result []models.Session
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
func (c *OpenF1Client) GetDriversForSession(sessionKey int) ([]models.Driver, error) {
body, err := c.get(fmt.Sprintf("%s/v1/drivers?session_key=%d", c.url, sessionKey))
if err != nil {
return nil, err
}
defer body.Close()
var result []models.Driver
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
func (c *OpenF1Client) GetDriver(sessionKey, driverNumber int) (*models.Driver, error) {
body, err := c.get(fmt.Sprintf("%s/v1/drivers?session_key=%d&driver_number=%d", c.url, sessionKey, driverNumber))
if err != nil {
return nil, err
}
defer body.Close()
var result []models.Driver
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, err
}
if len(result) == 0 {
return nil, nil
}
return &result[0], nil
}
func (c *OpenF1Client) GetDriverChampionship(sessionKey int) ([]models.ChampionshipDriver, error) {
body, err := c.get(fmt.Sprintf("%s/v1/championship_drivers?session_key=%d", c.url, sessionKey))
if err != nil {
return nil, err
}
defer body.Close()
var result []models.ChampionshipDriver
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
func (c *OpenF1Client) GetTeamChampionship(sessionKey int) ([]models.ChampionshipTeam, error) {
body, err := c.get(fmt.Sprintf("%s/v1/championship_teams?session_key=%d", c.url, sessionKey))
if err != nil {
return nil, err
}
defer body.Close()
var result []models.ChampionshipTeam
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
// GetLatestDriverChampionship returns championship standings for the most recent
// race session. Note: requires the caller to supply a recent race session_key;
// the OpenF1 API does not support a "latest" shortcut for this endpoint.
func (c *OpenF1Client) GetLatestDriverChampionship() ([]models.ChampionshipDriver, error) {
body, err := c.get(fmt.Sprintf("%s/v1/championship_drivers?session_key=latest", c.url))
if err != nil {
return nil, err
}
defer body.Close()
var result []models.ChampionshipDriver
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
func (c *OpenF1Client) GetLatestTeamChampionship() ([]models.ChampionshipTeam, error) {
body, err := c.get(fmt.Sprintf("%s/v1/championship_teams?session_key=latest", c.url))
if err != nil {
return nil, err
}
defer body.Close()
var result []models.ChampionshipTeam
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
func (c *OpenF1Client) GetSessionResult(sessionKey int) ([]models.SessionResult, error) {
body, err := c.get(fmt.Sprintf("%s/v1/session_result?session_key=%d", c.url, sessionKey))
if err != nil {
return nil, err
}
defer body.Close()
var result []models.SessionResult
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
func (c *OpenF1Client) GetStartingGrid(sessionKey int) ([]models.StartingGrid, error) {
body, err := c.get(fmt.Sprintf("%s/v1/starting_grid?session_key=%d", c.url, sessionKey))
if err != nil {
return nil, err
}
defer body.Close()
var result []models.StartingGrid
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
func (c *OpenF1Client) GetLapsForSession(sessionKey int) ([]models.Lap, error) {
body, err := c.get(fmt.Sprintf("%s/v1/laps?session_key=%d", c.url, sessionKey))
if err != nil {
return nil, err
}
defer body.Close()
var result []models.Lap
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
func (c *OpenF1Client) GetLapsForDriver(sessionKey, driverNumber int) ([]models.Lap, error) {
body, err := c.get(fmt.Sprintf("%s/v1/laps?session_key=%d&driver_number=%d", c.url, sessionKey, driverNumber))
if err != nil {
return nil, err
}
defer body.Close()
var result []models.Lap
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
func (c *OpenF1Client) GetStintsForSession(sessionKey int) ([]models.Stint, error) {
body, err := c.get(fmt.Sprintf("%s/v1/stints?session_key=%d", c.url, sessionKey))
if err != nil {
return nil, err
}
defer body.Close()
var result []models.Stint
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
func (c *OpenF1Client) GetPitStopsForSession(sessionKey int) ([]models.Pit, error) {
body, err := c.get(fmt.Sprintf("%s/v1/pit?session_key=%d", c.url, sessionKey))
if err != nil {
return nil, err
}
defer body.Close()
var result []models.Pit
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
func (c *OpenF1Client) GetOvertakesForSession(sessionKey int) ([]models.Overtake, error) {
body, err := c.get(fmt.Sprintf("%s/v1/overtakes?session_key=%d", c.url, sessionKey))
if err != nil {
return nil, err
}
defer body.Close()
var result []models.Overtake
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
func (c *OpenF1Client) GetPositions(sessionKey, driverNumber int) ([]models.Position, error) {
body, err := c.get(fmt.Sprintf("%s/v1/position?session_key=%d&driver_number=%d", c.url, sessionKey, driverNumber))
if err != nil {
return nil, err
}
defer body.Close()
var result []models.Position
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
func (c *OpenF1Client) GetIntervals(sessionKey, driverNumber int) ([]models.Interval, error) {
body, err := c.get(fmt.Sprintf("%s/v1/intervals?session_key=%d&driver_number=%d", c.url, sessionKey, driverNumber))
if err != nil {
return nil, err
}
defer body.Close()
var result []models.Interval
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
func (c *OpenF1Client) GetRaceControl(sessionKey int) ([]models.RaceControl, error) {
body, err := c.get(fmt.Sprintf("%s/v1/race_control?session_key=%d", c.url, sessionKey))
if err != nil {
return nil, err
}
defer body.Close()
var result []models.RaceControl
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
func (c *OpenF1Client) GetWeather(sessionKey int) ([]models.Weather, error) {
body, err := c.get(fmt.Sprintf("%s/v1/weather?session_key=%d", c.url, sessionKey))
if err != nil {
return nil, err
}
defer body.Close()
var result []models.Weather
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
func (c *OpenF1Client) GetCarData(sessionKey, driverNumber int) ([]models.CarData, error) {
body, err := c.get(fmt.Sprintf("%s/v1/car_data?session_key=%d&driver_number=%d", c.url, sessionKey, driverNumber))
if err != nil {
return nil, err
}
defer body.Close()
var result []models.CarData
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
func (c *OpenF1Client) GetLocation(sessionKey, driverNumber int) ([]models.Location, error) {
body, err := c.get(fmt.Sprintf("%s/v1/location?session_key=%d&driver_number=%d", c.url, sessionKey, driverNumber))
if err != nil {
return nil, err
}
defer body.Close()
var result []models.Location
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
func (c *OpenF1Client) GetTeamRadio(sessionKey, driverNumber int) ([]models.TeamRadio, error) {
body, err := c.get(fmt.Sprintf("%s/v1/team_radio?session_key=%d&driver_number=%d", c.url, sessionKey, driverNumber))
if err != nil {
return nil, err
}
defer body.Close()
var result []models.TeamRadio
if err := json.NewDecoder(body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}

211
internal/api/openf1_test.go Normal file
View File

@@ -0,0 +1,211 @@
package api
import (
"strings"
"testing"
"time"
)
const (
testBaseURL = "https://api.openf1.org"
testTimeout = 10 * time.Second
abuDhabiRace = 9662 // 2024 Abu Dhabi Grand Prix Race session
)
func newTestClient() *OpenF1Client {
return NewOpenF1Client(testBaseURL, testTimeout)
}
// skipOnRateLimit skips the test if err indicates the API rate-limited us (HTTP 429).
func skipOnRateLimit(t *testing.T, err error) {
t.Helper()
if err != nil && strings.Contains(err.Error(), "status 429") {
t.Skip("skipping: OpenF1 API rate-limited this test run")
}
}
func TestGetMeetingsForYear(t *testing.T) {
client := newTestClient()
meetings, err := client.GetMeetingsForYear(2024)
skipOnRateLimit(t, err)
if err != nil {
t.Fatal(err)
}
if len(meetings) == 0 {
t.Errorf("Expected meetings for 2024, got none")
}
for _, meeting := range meetings {
if meeting.Year != 2024 {
t.Errorf("Expected meeting year to be 2024, got %d", meeting.Year)
}
}
}
func TestGetMeetingsForYearInvalid(t *testing.T) {
client := newTestClient()
_, err := client.GetMeetingsForYear(-1)
if err == nil {
t.Errorf("Expected error for invalid year, got none")
}
}
func TestGetSessionsForMeeting(t *testing.T) {
client := newTestClient()
sessions, err := client.GetSessionsForMeeting(1304)
skipOnRateLimit(t, err)
if err != nil {
t.Fatal(err)
}
if len(sessions) == 0 {
t.Errorf("Expected sessions for meeting key 1304, got none")
}
for _, session := range sessions {
if session.MeetingKey != 1304 {
t.Errorf("Expected session meeting key to be 1304, got %d", session.MeetingKey)
}
}
}
func TestGetDriversForSession(t *testing.T) {
client := newTestClient()
drivers, err := client.GetDriversForSession(abuDhabiRace)
skipOnRateLimit(t, err)
if err != nil {
t.Fatal(err)
}
if len(drivers) == 0 {
t.Errorf("Expected drivers for session %d, got none", abuDhabiRace)
}
for _, d := range drivers {
if d.SessionKey != abuDhabiRace {
t.Errorf("Expected session_key %d, got %d", abuDhabiRace, d.SessionKey)
}
if d.DriverNumber == 0 {
t.Errorf("Expected non-zero driver_number")
}
}
}
func TestGetSessionResult(t *testing.T) {
client := newTestClient()
results, err := client.GetSessionResult(abuDhabiRace)
skipOnRateLimit(t, err)
if err != nil {
t.Fatal(err)
}
if len(results) == 0 {
t.Errorf("Expected results for session %d, got none", abuDhabiRace)
}
foundFinisher := false
for _, r := range results {
if r.SessionKey != abuDhabiRace {
t.Errorf("Expected session_key %d, got %d", abuDhabiRace, r.SessionKey)
}
if r.Position == 1 {
foundFinisher = true
// Winner should not be DNF/DNS/DSQ
if r.DNF || r.DNS || r.DSQ {
t.Errorf("Race winner (position 1) should not have DNF/DNS/DSQ set")
}
// Winner should have points
if r.Points <= 0 {
t.Errorf("Race winner should have positive points, got %f", r.Points)
}
}
}
if !foundFinisher {
t.Errorf("Expected at least one driver in position 1")
}
}
func TestGetStintsForSession(t *testing.T) {
client := newTestClient()
stints, err := client.GetStintsForSession(abuDhabiRace)
skipOnRateLimit(t, err)
if err != nil {
t.Fatal(err)
}
if len(stints) == 0 {
t.Errorf("Expected stints for session %d, got none", abuDhabiRace)
}
validCompounds := map[string]bool{
"SOFT": true, "MEDIUM": true, "HARD": true,
"INTERMEDIATE": true, "WET": true, "UNKNOWN": true,
}
for _, s := range stints {
if s.SessionKey != abuDhabiRace {
t.Errorf("Expected session_key %d, got %d", abuDhabiRace, s.SessionKey)
}
if !validCompounds[string(s.Compound)] {
t.Errorf("Unexpected tyre compound: %q", s.Compound)
}
}
}
func TestGetDriverChampionship(t *testing.T) {
client := newTestClient()
standings, err := client.GetDriverChampionship(abuDhabiRace)
skipOnRateLimit(t, err)
if err != nil {
t.Fatal(err)
}
if len(standings) == 0 {
t.Errorf("Expected driver championship standings for session %d, got none", abuDhabiRace)
}
for _, s := range standings {
if s.DriverNumber == 0 {
t.Errorf("Expected non-zero driver_number in championship standings")
}
if s.PositionCurrent == 0 {
t.Errorf("Expected non-zero position_current in championship standings")
}
if s.PointsCurrent < 0 {
t.Errorf("Expected non-negative points_current, got %f", s.PointsCurrent)
}
}
}
func TestGetRaceControl(t *testing.T) {
client := newTestClient()
messages, err := client.GetRaceControl(abuDhabiRace)
skipOnRateLimit(t, err)
if err != nil {
t.Fatal(err)
}
if len(messages) == 0 {
t.Errorf("Expected race control messages for session %d, got none", abuDhabiRace)
}
for _, m := range messages {
if m.SessionKey != abuDhabiRace {
t.Errorf("Expected session_key %d, got %d", abuDhabiRace, m.SessionKey)
}
if m.Message == "" {
t.Errorf("Expected non-empty message in race control entry")
}
// DriverNumber is a pointer — just verify it doesn't panic (nil is valid)
_ = m.DriverNumber
}
}