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:
101
internal/query/convert.go
Normal file
101
internal/query/convert.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
"github.com/AmanTahiliani/box-box/internal/store"
|
||||
)
|
||||
|
||||
func meetingToModel(m store.Meeting) models.Meeting {
|
||||
return models.Meeting{
|
||||
MeetingKey: int32(m.MeetingKey),
|
||||
MeetingName: m.MeetingName,
|
||||
MeetingOfficialName: m.MeetingOfficialName,
|
||||
Location: m.Location,
|
||||
CountryCode: m.CountryCode,
|
||||
CountryName: m.CountryName,
|
||||
Circuit: models.Circuit{
|
||||
CircuitKey: m.CircuitKey,
|
||||
CircuitShortName: m.CircuitShortName,
|
||||
},
|
||||
GMTOffset: m.GMTOffset,
|
||||
DateStart: m.DateStart,
|
||||
DateEnd: m.DateEnd,
|
||||
Year: m.Year,
|
||||
}
|
||||
}
|
||||
|
||||
func sessionToModel(s store.Session) models.Session {
|
||||
return models.Session{
|
||||
SessionKey: s.SessionKey,
|
||||
MeetingKey: s.MeetingKey,
|
||||
SessionName: s.SessionName,
|
||||
SessionType: s.SessionType,
|
||||
CircuitKey: s.CircuitKey,
|
||||
DateStart: s.DateStart,
|
||||
DateEnd: s.DateEnd,
|
||||
GMTOffset: s.GMTOffset,
|
||||
}
|
||||
}
|
||||
|
||||
func driverToModel(sessionKey, meetingKey int, sd store.SessionDriver, d store.Driver) models.Driver {
|
||||
teamName := sd.TeamName
|
||||
if teamName == "" {
|
||||
teamName = d.TeamName
|
||||
}
|
||||
teamColour := sd.TeamColour
|
||||
if teamColour == "" {
|
||||
teamColour = d.TeamColour
|
||||
}
|
||||
return models.Driver{
|
||||
BroadcastName: d.BroadcastName,
|
||||
DriverNumber: sd.DriverNumber,
|
||||
FirstName: d.FirstName,
|
||||
FullName: d.FullName,
|
||||
HeadshotURL: d.HeadshotURL,
|
||||
LastName: d.LastName,
|
||||
MeetingKey: meetingKey,
|
||||
NameAcronym: d.NameAcronym,
|
||||
SessionKey: sessionKey,
|
||||
TeamColour: teamColour,
|
||||
TeamName: teamName,
|
||||
}
|
||||
}
|
||||
|
||||
func resultToModel(r store.SessionResult) models.SessionResult {
|
||||
return models.SessionResult{
|
||||
DNF: r.DNF,
|
||||
DNS: r.DNS,
|
||||
DSQ: r.DSQ,
|
||||
DriverNumber: r.DriverNumber,
|
||||
Duration: parseJSONValue(r.DurationJSON),
|
||||
GapToLeader: parseJSONValue(r.GapToLeaderJSON),
|
||||
NumberOfLaps: r.NumberOfLaps,
|
||||
MeetingKey: r.MeetingKey,
|
||||
Points: r.Points,
|
||||
Position: r.Position,
|
||||
SessionKey: r.SessionKey,
|
||||
}
|
||||
}
|
||||
|
||||
func gridToModel(g store.StartingGridEntry) models.StartingGrid {
|
||||
return models.StartingGrid{
|
||||
DriverNumber: g.DriverNumber,
|
||||
LapDuration: g.LapDuration,
|
||||
MeetingKey: g.MeetingKey,
|
||||
Position: g.Position,
|
||||
SessionKey: g.SessionKey,
|
||||
}
|
||||
}
|
||||
|
||||
func parseJSONValue(raw string) interface{} {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
var v interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &v); err != nil {
|
||||
return raw
|
||||
}
|
||||
return v
|
||||
}
|
||||
73
internal/query/local.go
Normal file
73
internal/query/local.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
)
|
||||
|
||||
// ListMeetingsByYear returns ingested meetings for a season.
|
||||
func (s *Service) ListMeetingsByYear(year int) ([]models.Meeting, error) {
|
||||
rows, err := s.store.ListMeetingsByYear(year)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]models.Meeting, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, meetingToModel(row))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListSessionsByMeeting returns ingested sessions for a meeting.
|
||||
func (s *Service) ListSessionsByMeeting(meetingKey int) ([]models.Session, error) {
|
||||
rows, err := s.store.ListSessionsByMeeting(meetingKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]models.Session, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, sessionToModel(row))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListDrivers returns ingested drivers for a session.
|
||||
func (s *Service) ListDrivers(sessionKey int) ([]models.Driver, error) {
|
||||
sess, err := s.store.GetSession(sessionKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
driverLinks, err := s.store.ListSessionDrivers(sessionKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]models.Driver, 0, len(driverLinks))
|
||||
for _, link := range driverLinks {
|
||||
d, err := s.store.GetDriver(link.DriverNumber)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, driverToModel(sessionKey, sess.MeetingKey, link, d))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListResults returns ingested session results enriched with driver fields.
|
||||
func (s *Service) ListResults(sessionKey int) ([]EnrichedResult, error) {
|
||||
hub, err := s.GetRaceHub(sessionKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return hub.Results, nil
|
||||
}
|
||||
|
||||
// ListStartingGrid returns ingested starting grid rows enriched with driver fields.
|
||||
func (s *Service) ListStartingGrid(sessionKey int) ([]EnrichedGrid, error) {
|
||||
hub, err := s.GetRaceHub(sessionKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return hub.StartingGrid, nil
|
||||
}
|
||||
66
internal/query/metadata.go
Normal file
66
internal/query/metadata.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package query
|
||||
|
||||
const (
|
||||
DatasetStatusAvailable = "available"
|
||||
DatasetStatusMissing = "missing"
|
||||
|
||||
DataSourceLocal = "local"
|
||||
DataSourceNone = "none"
|
||||
DataSourceOpenF1 = "openf1"
|
||||
|
||||
ResponseSourceLocal = "local"
|
||||
ResponseSourceNone = "none"
|
||||
ResponseSourcePartial = "partial"
|
||||
)
|
||||
|
||||
// DatasetInfo describes availability of a single dataset.
|
||||
type DatasetInfo struct {
|
||||
Status string `json:"status"`
|
||||
Source string `json:"source"`
|
||||
Count int `json:"count,omitempty"`
|
||||
}
|
||||
|
||||
func availableLocal(count int) DatasetInfo {
|
||||
return DatasetInfo{
|
||||
Status: DatasetStatusAvailable,
|
||||
Source: DataSourceLocal,
|
||||
Count: count,
|
||||
}
|
||||
}
|
||||
|
||||
func missingDataset() DatasetInfo {
|
||||
return DatasetInfo{
|
||||
Status: DatasetStatusMissing,
|
||||
Source: DataSourceNone,
|
||||
Count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
func responseSource(datasets map[string]DatasetInfo) string {
|
||||
if len(datasets) == 0 {
|
||||
return ResponseSourceNone
|
||||
}
|
||||
|
||||
hasLocal := false
|
||||
allMissing := true
|
||||
for _, info := range datasets {
|
||||
if info.Status == DatasetStatusAvailable && info.Source == DataSourceLocal {
|
||||
hasLocal = true
|
||||
allMissing = false
|
||||
} else if info.Status == DatasetStatusAvailable {
|
||||
allMissing = false
|
||||
}
|
||||
}
|
||||
if allMissing {
|
||||
return ResponseSourceNone
|
||||
}
|
||||
if hasLocal {
|
||||
for _, info := range datasets {
|
||||
if info.Status == DatasetStatusMissing {
|
||||
return ResponseSourcePartial
|
||||
}
|
||||
}
|
||||
return ResponseSourceLocal
|
||||
}
|
||||
return ResponseSourceNone
|
||||
}
|
||||
198
internal/query/query_test.go
Normal file
198
internal/query/query_test.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/store"
|
||||
)
|
||||
|
||||
func openTestService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "test.db")
|
||||
|
||||
st, err := store.Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open() error = %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
return NewService(st)
|
||||
}
|
||||
|
||||
func seedRaceHubData(t *testing.T, st *store.Store) {
|
||||
t.Helper()
|
||||
|
||||
meetingKey := 1229
|
||||
sessionKey := 9472
|
||||
|
||||
if err := st.UpsertMeeting(store.Meeting{
|
||||
MeetingKey: meetingKey,
|
||||
MeetingName: "Monaco",
|
||||
MeetingOfficialName: "FORMULA 1 GRAND PRIX DE MONACO 2025",
|
||||
Location: "Monaco",
|
||||
CountryCode: "MON",
|
||||
CountryName: "Monaco",
|
||||
CircuitKey: 10,
|
||||
CircuitShortName: "Monaco",
|
||||
Year: 2025,
|
||||
DateStart: "2025-05-23T00:00:00+00:00",
|
||||
DateEnd: "2025-05-25T00:00:00+00:00",
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertMeeting() error = %v", err)
|
||||
}
|
||||
if err := st.UpsertSession(store.Session{
|
||||
SessionKey: sessionKey,
|
||||
MeetingKey: meetingKey,
|
||||
SessionName: "Race",
|
||||
SessionType: "Race",
|
||||
CircuitKey: 10,
|
||||
DateStart: "2025-05-25T13:00:00+00:00",
|
||||
DateEnd: "2025-05-25T15:00:00+00:00",
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertSession() error = %v", err)
|
||||
}
|
||||
if err := st.UpsertDriver(store.Driver{
|
||||
DriverNumber: 1,
|
||||
FullName: "Max Verstappen",
|
||||
NameAcronym: "VER",
|
||||
TeamName: "Red Bull Racing",
|
||||
TeamColour: "3671C6",
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertDriver() error = %v", err)
|
||||
}
|
||||
if err := st.UpsertSessionDriver(store.SessionDriver{
|
||||
SessionKey: sessionKey,
|
||||
DriverNumber: 1,
|
||||
MeetingKey: meetingKey,
|
||||
TeamName: "Red Bull Racing",
|
||||
TeamColour: "3671C6",
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertSessionDriver() error = %v", err)
|
||||
}
|
||||
if err := st.UpsertStartingGridEntry(store.StartingGridEntry{
|
||||
SessionKey: sessionKey,
|
||||
DriverNumber: 1,
|
||||
MeetingKey: meetingKey,
|
||||
Position: 1,
|
||||
LapDuration: 71.234,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertStartingGridEntry() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRaceHubMissingSession(t *testing.T) {
|
||||
svc := openTestService(t)
|
||||
|
||||
hub, err := svc.GetRaceHub(9472)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRaceHub() error = %v", err)
|
||||
}
|
||||
if hub.Source != ResponseSourceNone {
|
||||
t.Fatalf("Source = %q, want %q", hub.Source, ResponseSourceNone)
|
||||
}
|
||||
if hub.Datasets["session"].Status != DatasetStatusMissing {
|
||||
t.Fatalf("session status = %q, want %q", hub.Datasets["session"].Status, DatasetStatusMissing)
|
||||
}
|
||||
if hub.Meeting != nil || hub.Session != nil {
|
||||
t.Fatal("expected no meeting/session for missing session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRaceHubPartialData(t *testing.T) {
|
||||
svc := openTestService(t)
|
||||
seedRaceHubData(t, svc.store)
|
||||
|
||||
hub, err := svc.GetRaceHub(9472)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRaceHub() error = %v", err)
|
||||
}
|
||||
if hub.Source != ResponseSourcePartial {
|
||||
t.Fatalf("Source = %q, want %q", hub.Source, ResponseSourcePartial)
|
||||
}
|
||||
if hub.Session == nil || hub.Meeting == nil {
|
||||
t.Fatal("expected meeting and session")
|
||||
}
|
||||
if len(hub.Drivers) != 1 || hub.Drivers[0].NameAcronym != "VER" {
|
||||
t.Fatalf("Drivers = %+v, want one VER entry", hub.Drivers)
|
||||
}
|
||||
if hub.Datasets["results"].Status != DatasetStatusMissing {
|
||||
t.Fatalf("results status = %q, want %q", hub.Datasets["results"].Status, DatasetStatusMissing)
|
||||
}
|
||||
if len(hub.StartingGrid) != 1 {
|
||||
t.Fatalf("StartingGrid len = %d, want 1", len(hub.StartingGrid))
|
||||
}
|
||||
if hub.StartingGrid[0].NameAcronym != "VER" {
|
||||
t.Fatalf("StartingGrid driver = %+v, want enriched VER", hub.StartingGrid[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRaceHubCompleteData(t *testing.T) {
|
||||
svc := openTestService(t)
|
||||
seedRaceHubData(t, svc.store)
|
||||
|
||||
if err := svc.store.UpsertSessionResult(store.SessionResult{
|
||||
SessionKey: 9472,
|
||||
DriverNumber: 1,
|
||||
MeetingKey: 1229,
|
||||
Position: 1,
|
||||
Points: 25,
|
||||
NumberOfLaps: 78,
|
||||
DurationJSON: "5234.567",
|
||||
GapToLeaderJSON: "0",
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertSessionResult() error = %v", err)
|
||||
}
|
||||
|
||||
hub, err := svc.GetRaceHub(9472)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRaceHub() error = %v", err)
|
||||
}
|
||||
if hub.Source != ResponseSourceLocal {
|
||||
t.Fatalf("Source = %q, want %q", hub.Source, ResponseSourceLocal)
|
||||
}
|
||||
if len(hub.Results) != 1 {
|
||||
t.Fatalf("Results len = %d, want 1", len(hub.Results))
|
||||
}
|
||||
if hub.Results[0].FullName != "Max Verstappen" {
|
||||
t.Fatalf("Results[0].FullName = %q, want Max Verstappen", hub.Results[0].FullName)
|
||||
}
|
||||
if hub.Datasets["results"].Count != 1 {
|
||||
t.Fatalf("results count = %d, want 1", hub.Datasets["results"].Count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMeetingsByYear(t *testing.T) {
|
||||
svc := openTestService(t)
|
||||
seedRaceHubData(t, svc.store)
|
||||
|
||||
meetings, err := svc.ListMeetingsByYear(2025)
|
||||
if err != nil {
|
||||
t.Fatalf("ListMeetingsByYear() error = %v", err)
|
||||
}
|
||||
if len(meetings) != 1 || meetings[0].MeetingName != "Monaco" {
|
||||
t.Fatalf("ListMeetingsByYear() = %+v, want Monaco meeting", meetings)
|
||||
}
|
||||
|
||||
empty, err := svc.ListMeetingsByYear(2024)
|
||||
if err != nil {
|
||||
t.Fatalf("ListMeetingsByYear(2024) error = %v", err)
|
||||
}
|
||||
if len(empty) != 0 {
|
||||
t.Fatalf("ListMeetingsByYear(2024) len = %d, want 0", len(empty))
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDriversRequiresSession(t *testing.T) {
|
||||
svc := openTestService(t)
|
||||
|
||||
_, err := svc.ListDrivers(9472)
|
||||
if err == nil {
|
||||
t.Fatal("ListDrivers() error = nil, want sql.ErrNoRows")
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
t.Fatalf("ListDrivers() error = %v, want sql.ErrNoRows", err)
|
||||
}
|
||||
}
|
||||
155
internal/query/racehub.go
Normal file
155
internal/query/racehub.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package query
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"github.com/AmanTahiliani/box-box/internal/models"
|
||||
"github.com/AmanTahiliani/box-box/internal/store"
|
||||
)
|
||||
|
||||
// Service assembles store-backed read models.
|
||||
type Service struct {
|
||||
store *store.Store
|
||||
}
|
||||
|
||||
// NewService creates a query service over a domain store.
|
||||
func NewService(st *store.Store) *Service {
|
||||
return &Service{store: st}
|
||||
}
|
||||
|
||||
// EnrichedResult is a session result with driver identity fields.
|
||||
type EnrichedResult struct {
|
||||
models.SessionResult
|
||||
NameAcronym string `json:"name_acronym"`
|
||||
FullName string `json:"full_name"`
|
||||
TeamName string `json:"team_name"`
|
||||
TeamColour string `json:"team_colour"`
|
||||
}
|
||||
|
||||
// EnrichedGrid is a starting grid row with driver identity fields.
|
||||
type EnrichedGrid struct {
|
||||
models.StartingGrid
|
||||
NameAcronym string `json:"name_acronym"`
|
||||
FullName string `json:"full_name"`
|
||||
TeamName string `json:"team_name"`
|
||||
TeamColour string `json:"team_colour"`
|
||||
}
|
||||
|
||||
// RaceHub is the local-first Race Hub read model for one session.
|
||||
type RaceHub struct {
|
||||
Source string `json:"source"`
|
||||
SessionKey int `json:"session_key"`
|
||||
Datasets map[string]DatasetInfo `json:"datasets"`
|
||||
Meeting *models.Meeting `json:"meeting,omitempty"`
|
||||
Session *models.Session `json:"session,omitempty"`
|
||||
Drivers []models.Driver `json:"drivers"`
|
||||
Results []EnrichedResult `json:"results"`
|
||||
StartingGrid []EnrichedGrid `json:"starting_grid"`
|
||||
}
|
||||
|
||||
// GetRaceHub loads ingested Race Hub datasets for a session from the local store.
|
||||
func (s *Service) GetRaceHub(sessionKey int) (RaceHub, error) {
|
||||
hub := RaceHub{
|
||||
SessionKey: sessionKey,
|
||||
Datasets: map[string]DatasetInfo{
|
||||
"meeting": missingDataset(),
|
||||
"session": missingDataset(),
|
||||
"drivers": missingDataset(),
|
||||
"results": missingDataset(),
|
||||
"starting_grid": missingDataset(),
|
||||
},
|
||||
Drivers: []models.Driver{},
|
||||
Results: []EnrichedResult{},
|
||||
StartingGrid: []EnrichedGrid{},
|
||||
}
|
||||
|
||||
sess, err := s.store.GetSession(sessionKey)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
hub.Source = ResponseSourceNone
|
||||
return hub, nil
|
||||
}
|
||||
return RaceHub{}, err
|
||||
}
|
||||
|
||||
sessionModel := sessionToModel(sess)
|
||||
hub.Session = &sessionModel
|
||||
hub.Datasets["session"] = availableLocal(1)
|
||||
|
||||
meeting, err := s.store.GetMeeting(sess.MeetingKey)
|
||||
if err != nil {
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return RaceHub{}, err
|
||||
}
|
||||
} else {
|
||||
meetingModel := meetingToModel(meeting)
|
||||
hub.Meeting = &meetingModel
|
||||
hub.Datasets["meeting"] = availableLocal(1)
|
||||
}
|
||||
|
||||
driverLinks, err := s.store.ListSessionDrivers(sessionKey)
|
||||
if err != nil {
|
||||
return RaceHub{}, err
|
||||
}
|
||||
if len(driverLinks) > 0 {
|
||||
drivers := make([]models.Driver, 0, len(driverLinks))
|
||||
for _, link := range driverLinks {
|
||||
d, err := s.store.GetDriver(link.DriverNumber)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return RaceHub{}, err
|
||||
}
|
||||
drivers = append(drivers, driverToModel(sessionKey, sess.MeetingKey, link, d))
|
||||
}
|
||||
hub.Drivers = drivers
|
||||
hub.Datasets["drivers"] = availableLocal(len(drivers))
|
||||
}
|
||||
|
||||
driverByNumber := make(map[int]models.Driver, len(hub.Drivers))
|
||||
for _, d := range hub.Drivers {
|
||||
driverByNumber[d.DriverNumber] = d
|
||||
}
|
||||
|
||||
results, err := s.store.ListSessionResults(sessionKey)
|
||||
if err != nil {
|
||||
return RaceHub{}, err
|
||||
}
|
||||
if len(results) > 0 {
|
||||
enriched := make([]EnrichedResult, 0, len(results))
|
||||
for _, r := range results {
|
||||
e := EnrichedResult{SessionResult: resultToModel(r)}
|
||||
if d, ok := driverByNumber[r.DriverNumber]; ok {
|
||||
e.NameAcronym = d.NameAcronym
|
||||
e.FullName = d.FullName
|
||||
e.TeamName = d.TeamName
|
||||
e.TeamColour = d.TeamColour
|
||||
}
|
||||
enriched = append(enriched, e)
|
||||
}
|
||||
hub.Results = enriched
|
||||
hub.Datasets["results"] = availableLocal(len(enriched))
|
||||
}
|
||||
|
||||
grid, err := s.store.ListStartingGrid(sessionKey)
|
||||
if err != nil {
|
||||
return RaceHub{}, err
|
||||
}
|
||||
if len(grid) > 0 {
|
||||
enriched := make([]EnrichedGrid, 0, len(grid))
|
||||
for _, g := range grid {
|
||||
e := EnrichedGrid{StartingGrid: gridToModel(g)}
|
||||
if d, ok := driverByNumber[g.DriverNumber]; ok {
|
||||
e.NameAcronym = d.NameAcronym
|
||||
e.FullName = d.FullName
|
||||
e.TeamName = d.TeamName
|
||||
e.TeamColour = d.TeamColour
|
||||
}
|
||||
enriched = append(enriched, e)
|
||||
}
|
||||
hub.StartingGrid = enriched
|
||||
hub.Datasets["starting_grid"] = availableLocal(len(enriched))
|
||||
}
|
||||
|
||||
hub.Source = responseSource(hub.Datasets)
|
||||
return hub, nil
|
||||
}
|
||||
@@ -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