Add local-first race hub API

This commit is contained in:
2026-05-25 00:49:13 -04:00
parent 517c6b987b
commit e08255db70
16 changed files with 1231 additions and 118 deletions

View File

@@ -1,7 +1,9 @@
package web
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
"sort"
"strconv"
@@ -10,6 +12,7 @@ import (
"time"
"github.com/AmanTahiliani/box-box/internal/models"
"github.com/AmanTahiliani/box-box/internal/query"
)
// writeJSON writes v as JSON with status 200.
@@ -32,6 +35,34 @@ func (s *Server) handleMeetings(w http.ResponseWriter, r *http.Request) {
if year == 0 {
year = time.Now().Year()
}
switch parseSourceMode(r) {
case sourceLocal:
if !s.hasLocalQuery() {
writeJSON(w, []models.Meeting{})
return
}
meetings, err := s.query.ListMeetingsByYear(year)
if err != nil {
writeError(w, err, http.StatusInternalServerError, false)
return
}
writeJSON(w, meetings)
return
case sourceAuto:
if s.hasLocalQuery() {
meetings, err := s.query.ListMeetingsByYear(year)
if err != nil {
writeError(w, err, http.StatusInternalServerError, false)
return
}
if len(meetings) > 0 {
writeJSON(w, meetings)
return
}
}
}
meetings, err := s.client.GetMeetingsForYear(year)
if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
@@ -48,6 +79,34 @@ func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) {
http.Error(w, "meeting_key required", http.StatusBadRequest)
return
}
switch parseSourceMode(r) {
case sourceLocal:
if !s.hasLocalQuery() {
writeJSON(w, []models.Session{})
return
}
sessions, err := s.query.ListSessionsByMeeting(meetingKey)
if err != nil {
writeError(w, err, http.StatusInternalServerError, false)
return
}
writeJSON(w, sessions)
return
case sourceAuto:
if s.hasLocalQuery() {
sessions, err := s.query.ListSessionsByMeeting(meetingKey)
if err != nil {
writeError(w, err, http.StatusInternalServerError, false)
return
}
if len(sessions) > 0 {
writeJSON(w, sessions)
return
}
}
}
sessions, err := s.client.GetSessionsForMeeting(meetingKey)
if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
@@ -64,6 +123,38 @@ func (s *Server) handleDrivers(w http.ResponseWriter, r *http.Request) {
http.Error(w, "session_key required", http.StatusBadRequest)
return
}
switch parseSourceMode(r) {
case sourceLocal:
if !s.hasLocalQuery() {
writeJSON(w, []models.Driver{})
return
}
drivers, err := s.query.ListDrivers(sessionKey)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeJSON(w, []models.Driver{})
return
}
writeError(w, err, http.StatusInternalServerError, false)
return
}
writeJSON(w, drivers)
return
case sourceAuto:
if s.hasLocalQuery() {
drivers, err := s.query.ListDrivers(sessionKey)
if err == nil && len(drivers) > 0 {
writeJSON(w, drivers)
return
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
writeError(w, err, http.StatusInternalServerError, false)
return
}
}
}
drivers, err := s.client.GetDriversForSession(sessionKey)
if err != nil {
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
@@ -89,6 +180,33 @@ func (s *Server) handleResults(w http.ResponseWriter, r *http.Request) {
return
}
switch parseSourceMode(r) {
case sourceLocal:
if !s.hasLocalQuery() {
writeJSON(w, []resultWithDriver{})
return
}
results, err := s.query.ListResults(sessionKey)
if err != nil {
writeError(w, err, http.StatusInternalServerError, false)
return
}
writeJSON(w, enrichedResultsToAPI(results))
return
case sourceAuto:
if s.hasLocalQuery() {
results, err := s.query.ListResults(sessionKey)
if err == nil && len(results) > 0 {
writeJSON(w, enrichedResultsToAPI(results))
return
}
if err != nil {
writeError(w, err, http.StatusInternalServerError, false)
return
}
}
}
var (
results []models.SessionResult
drivers []models.Driver
@@ -137,6 +255,33 @@ func (s *Server) handleGrid(w http.ResponseWriter, r *http.Request) {
return
}
switch parseSourceMode(r) {
case sourceLocal:
if !s.hasLocalQuery() {
writeJSON(w, []gridWithDriver{})
return
}
grid, err := s.query.ListStartingGrid(sessionKey)
if err != nil {
writeError(w, err, http.StatusInternalServerError, false)
return
}
writeJSON(w, enrichedGridToAPI(grid))
return
case sourceAuto:
if s.hasLocalQuery() {
grid, err := s.query.ListStartingGrid(sessionKey)
if err == nil && len(grid) > 0 {
writeJSON(w, enrichedGridToAPI(grid))
return
}
if err != nil {
writeError(w, err, http.StatusInternalServerError, false)
return
}
}
}
var (
grid []models.StartingGrid
drivers []models.Driver
@@ -667,9 +812,9 @@ type comparisonDriver struct {
}
type lapsComparisonResponse struct {
SessionKey int `json:"session_key"`
SCPeriods []scPeriod `json:"sc_periods"`
PitLaps map[string][]int `json:"pit_laps"`
SessionKey int `json:"session_key"`
SCPeriods []scPeriod `json:"sc_periods"`
PitLaps map[string][]int `json:"pit_laps"`
Drivers []comparisonDriver `json:"drivers"`
}
@@ -784,3 +929,31 @@ func buildDriverMap(drivers []models.Driver) map[int]models.Driver {
}
return m
}
func enrichedResultsToAPI(results []query.EnrichedResult) []resultWithDriver {
out := make([]resultWithDriver, 0, len(results))
for _, res := range results {
out = append(out, resultWithDriver{
SessionResult: res.SessionResult,
NameAcronym: res.NameAcronym,
FullName: res.FullName,
TeamName: res.TeamName,
TeamColour: res.TeamColour,
})
}
return out
}
func enrichedGridToAPI(grid []query.EnrichedGrid) []gridWithDriver {
out := make([]gridWithDriver, 0, len(grid))
for _, g := range grid {
out = append(out, gridWithDriver{
StartingGrid: g.StartingGrid,
NameAcronym: g.NameAcronym,
FullName: g.FullName,
TeamName: g.TeamName,
TeamColour: g.TeamColour,
})
}
return out
}

46
internal/web/racehub.go Normal file
View File

@@ -0,0 +1,46 @@
package web
import (
"net/http"
"strconv"
"github.com/AmanTahiliani/box-box/internal/models"
"github.com/AmanTahiliani/box-box/internal/query"
)
func (s *Server) handleRaceHub(w http.ResponseWriter, r *http.Request) {
sessionKey, err := strconv.Atoi(r.URL.Query().Get("session_key"))
if err != nil || sessionKey == 0 {
http.Error(w, "session_key required", http.StatusBadRequest)
return
}
if !s.hasLocalQuery() {
writeJSON(w, emptyRaceHub(sessionKey))
return
}
hub, err := s.query.GetRaceHub(sessionKey)
if err != nil {
writeError(w, err, http.StatusInternalServerError, false)
return
}
writeJSON(w, hub)
}
func emptyRaceHub(sessionKey int) query.RaceHub {
return query.RaceHub{
Source: query.ResponseSourceNone,
SessionKey: sessionKey,
Datasets: map[string]query.DatasetInfo{
"meeting": query.DatasetInfo{Status: query.DatasetStatusMissing, Source: query.DataSourceNone, Count: 0},
"session": query.DatasetInfo{Status: query.DatasetStatusMissing, Source: query.DataSourceNone, Count: 0},
"drivers": query.DatasetInfo{Status: query.DatasetStatusMissing, Source: query.DataSourceNone, Count: 0},
"results": query.DatasetInfo{Status: query.DatasetStatusMissing, Source: query.DataSourceNone, Count: 0},
"starting_grid": query.DatasetInfo{Status: query.DatasetStatusMissing, Source: query.DataSourceNone, Count: 0},
},
Drivers: []models.Driver{},
Results: []query.EnrichedResult{},
StartingGrid: []query.EnrichedGrid{},
}
}

View File

@@ -0,0 +1,152 @@
package web
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/query"
"github.com/AmanTahiliani/box-box/internal/store"
)
func testServer(t *testing.T, st *store.Store) *Server {
t.Helper()
client := api.NewOpenF1Client("https://api.openf1.org", 15*time.Second)
t.Cleanup(func() { _ = client.Close() })
return NewServer(client, 8080, st)
}
func openTestStore(t *testing.T) *store.Store {
t.Helper()
path := filepath.Join(t.TempDir(), "test.db")
st, err := store.Open(path)
if err != nil {
t.Fatalf("store.Open() error = %v", err)
}
t.Cleanup(func() { _ = st.Close() })
return st
}
func seedRaceHubStore(t *testing.T, st *store.Store) {
t.Helper()
meetingKey := 1229
sessionKey := 9472
if err := st.UpsertMeeting(store.Meeting{
MeetingKey: meetingKey,
MeetingName: "Monaco",
Year: 2025,
}); err != nil {
t.Fatalf("UpsertMeeting() error = %v", err)
}
if err := st.UpsertSession(store.Session{
SessionKey: sessionKey,
MeetingKey: meetingKey,
SessionName: "Race",
SessionType: "Race",
}); err != nil {
t.Fatalf("UpsertSession() error = %v", err)
}
if err := st.UpsertDriver(store.Driver{
DriverNumber: 1,
FullName: "Max Verstappen",
NameAcronym: "VER",
}); err != nil {
t.Fatalf("UpsertDriver() error = %v", err)
}
if err := st.UpsertSessionDriver(store.SessionDriver{
SessionKey: sessionKey,
DriverNumber: 1,
MeetingKey: meetingKey,
}); err != nil {
t.Fatalf("UpsertSessionDriver() error = %v", err)
}
}
func TestHandleRaceHubWithoutStore(t *testing.T) {
srv := testServer(t, nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/race-hub?session_key=9472", nil)
rec := httptest.NewRecorder()
srv.handleRaceHub(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var hub query.RaceHub
if err := json.Unmarshal(rec.Body.Bytes(), &hub); err != nil {
t.Fatalf("decode response: %v", err)
}
if hub.Source != query.ResponseSourceNone {
t.Fatalf("source = %q, want %q", hub.Source, query.ResponseSourceNone)
}
if hub.Datasets["session"].Status != query.DatasetStatusMissing {
t.Fatalf("session dataset = %+v, want missing", hub.Datasets["session"])
}
}
func TestHandleRaceHubWithLocalData(t *testing.T) {
st := openTestStore(t)
seedRaceHubStore(t, st)
srv := testServer(t, st)
req := httptest.NewRequest(http.MethodGet, "/api/v1/race-hub?session_key=9472", nil)
rec := httptest.NewRecorder()
srv.handleRaceHub(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var hub query.RaceHub
if err := json.Unmarshal(rec.Body.Bytes(), &hub); err != nil {
t.Fatalf("decode response: %v", err)
}
if hub.Session == nil || hub.Meeting == nil {
t.Fatal("expected meeting and session in response")
}
if hub.Datasets["drivers"].Status != query.DatasetStatusAvailable {
t.Fatalf("drivers dataset = %+v, want available", hub.Datasets["drivers"])
}
if hub.Datasets["results"].Status != query.DatasetStatusMissing {
t.Fatalf("results dataset = %+v, want missing", hub.Datasets["results"])
}
}
func TestHandleMeetingsSourceLocal(t *testing.T) {
st := openTestStore(t)
seedRaceHubStore(t, st)
srv := testServer(t, st)
req := httptest.NewRequest(http.MethodGet, "/api/v1/meetings?year=2025&source=local", nil)
rec := httptest.NewRecorder()
srv.handleMeetings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var meetings []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &meetings); err != nil {
t.Fatalf("decode response: %v", err)
}
if len(meetings) != 1 {
t.Fatalf("meetings len = %d, want 1", len(meetings))
}
}
func TestHandleRaceHubRequiresSessionKey(t *testing.T) {
srv := testServer(t, nil)
req := httptest.NewRequest(http.MethodGet, "/api/v1/race-hub", nil)
rec := httptest.NewRecorder()
srv.handleRaceHub(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
}

View File

@@ -9,6 +9,8 @@ import (
"strings"
"github.com/AmanTahiliani/box-box/internal/api"
"github.com/AmanTahiliani/box-box/internal/query"
"github.com/AmanTahiliani/box-box/internal/store"
)
//go:embed assets
@@ -17,17 +19,23 @@ var assetsFS embed.FS
// Server is the box-box web companion HTTP server.
type Server struct {
client *api.OpenF1Client
query *query.Service
hub *SSEHub
addr string
}
// NewServer creates a new Server. Call Start() to begin serving.
func NewServer(client *api.OpenF1Client, port int) *Server {
return &Server{
// When st is non-nil, local-first read models are available from the domain DB.
func NewServer(client *api.OpenF1Client, port int, st *store.Store) *Server {
s := &Server{
client: client,
hub: newSSEHub(),
addr: fmt.Sprintf(":%d", port),
}
if st != nil {
s.query = query.NewService(st)
}
return s
}
// Start registers routes, launches background goroutines, and begins serving.
@@ -36,6 +44,7 @@ func (s *Server) Start() error {
// REST API — /api/v1/laps/comparison must be registered before /api/v1/laps
// because Go's ServeMux uses longest-prefix matching.
mux.HandleFunc("/api/v1/race-hub", s.handleRaceHub)
mux.HandleFunc("/api/v1/meetings", s.handleMeetings)
mux.HandleFunc("/api/v1/sessions", s.handleSessions)
mux.HandleFunc("/api/v1/drivers", s.handleDrivers)

24
internal/web/source.go Normal file
View File

@@ -0,0 +1,24 @@
package web
import "net/http"
const (
sourceOpenF1 = "openf1"
sourceLocal = "local"
sourceAuto = "auto"
)
func parseSourceMode(r *http.Request) string {
switch r.URL.Query().Get("source") {
case sourceLocal:
return sourceLocal
case sourceAuto:
return sourceAuto
default:
return sourceOpenF1
}
}
func (s *Server) hasLocalQuery() bool {
return s.query != nil
}