mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
Add local-first race hub API
This commit is contained in:
@@ -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
46
internal/web/racehub.go
Normal 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{},
|
||||
}
|
||||
}
|
||||
152
internal/web/racehub_test.go
Normal file
152
internal/web/racehub_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
24
internal/web/source.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user