mirror of
https://github.com/AmanTahiliani/box-box.git
synced 2026-08-07 19:56:18 -04:00
fix(#76): report request-scoped data freshness
This commit is contained in:
@@ -164,8 +164,8 @@ func (c *Cache) Get(key string) ([]byte, bool) {
|
|||||||
if ttl > 0 {
|
if ttl > 0 {
|
||||||
age := time.Since(time.Unix(createdAt, 0))
|
age := time.Since(time.Unix(createdAt, 0))
|
||||||
if age > ttl {
|
if age > ttl {
|
||||||
// Expired — delete and return miss.
|
// Expired entries remain stored so get() can use them as a stale
|
||||||
_, _ = c.db.Exec(`DELETE FROM cache WHERE key = ?`, key)
|
// fallback if the live request fails. Prune() owns physical cleanup.
|
||||||
atomic.AddInt64(&c.stats.Misses, 1)
|
atomic.AddInt64(&c.stats.Misses, 1)
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,26 @@ type OpenF1Client struct {
|
|||||||
staleFlag int32
|
staleFlag int32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scoped returns a lightweight request-scoped view of the client. Network,
|
||||||
|
// pacing and cache resources are shared, while the stale fallback indicator is
|
||||||
|
// deliberately not shared. Web handlers use this view so a stale fallback in
|
||||||
|
// one concurrent HTTP request can never mark an unrelated response as stale.
|
||||||
|
//
|
||||||
|
// The legacy client-wide stale flag remains available for the TUI, whose loads
|
||||||
|
// are intentionally aggregated into one navigation-level notice.
|
||||||
|
func (c *OpenF1Client) Scoped() *OpenF1Client {
|
||||||
|
if c == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &OpenF1Client{
|
||||||
|
url: c.url,
|
||||||
|
apiKey: c.apiKey,
|
||||||
|
httpClient: c.httpClient,
|
||||||
|
cache: c.cache,
|
||||||
|
pacer: c.pacer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func NewOpenF1Client(url string, timeout time.Duration) *OpenF1Client {
|
func NewOpenF1Client(url string, timeout time.Duration) *OpenF1Client {
|
||||||
return &OpenF1Client{
|
return &OpenF1Client{
|
||||||
url: url,
|
url: url,
|
||||||
|
|||||||
123
internal/api/freshness_test.go
Normal file
123
internal/api/freshness_test.go
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func expireCacheEntry(t *testing.T, c *OpenF1Client, key string) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := c.cache.db.Exec(`UPDATE cache SET created_at = ? WHERE key = ?`, time.Now().Add(-48*time.Hour).Unix(), key); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScopedClientReportsStaleFallbackWithoutMutatingParent(t *testing.T) {
|
||||||
|
year := time.Now().Year()
|
||||||
|
var fail atomic.Bool
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if fail.Load() {
|
||||||
|
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = fmt.Fprintf(w, `[{
|
||||||
|
"meeting_key": 1,
|
||||||
|
"meeting_name": "British Grand Prix",
|
||||||
|
"year": %d
|
||||||
|
}]`, year)
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
client := NewOpenF1Client(upstream.URL, time.Second)
|
||||||
|
defer client.Close()
|
||||||
|
client.pacer.interval = 0
|
||||||
|
key := fmt.Sprintf("%s/v1/meetings?year=%d", upstream.URL, year)
|
||||||
|
_, _ = client.cache.db.Exec(`DELETE FROM cache WHERE key = ?`, key)
|
||||||
|
defer func() { _, _ = client.cache.db.Exec(`DELETE FROM cache WHERE key = ?`, key) }()
|
||||||
|
|
||||||
|
if _, err := client.GetMeetingsForYear(year); err != nil {
|
||||||
|
t.Fatalf("prime cache: %v", err)
|
||||||
|
}
|
||||||
|
expireCacheEntry(t, client, key)
|
||||||
|
fail.Store(true)
|
||||||
|
|
||||||
|
scoped := client.Scoped()
|
||||||
|
meetings, err := scoped.GetMeetingsForYear(year)
|
||||||
|
if err != nil || len(meetings) != 1 {
|
||||||
|
t.Fatalf("stale fallback = (%+v, %v)", meetings, err)
|
||||||
|
}
|
||||||
|
if !scoped.LastResponseWasStale() {
|
||||||
|
t.Fatal("scoped request did not report its stale fallback")
|
||||||
|
}
|
||||||
|
if client.LastResponseWasStale() {
|
||||||
|
t.Fatal("request-scoped fallback leaked into the parent client")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScopedClientsDoNotLeakFreshnessAcrossConcurrentRequests(t *testing.T) {
|
||||||
|
year := time.Now().Year()
|
||||||
|
staleStarted := make(chan struct{})
|
||||||
|
releaseStale := make(chan struct{})
|
||||||
|
var failMeetings atomic.Bool
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/v1/meetings":
|
||||||
|
if failMeetings.Load() {
|
||||||
|
close(staleStarted)
|
||||||
|
<-releaseStale
|
||||||
|
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(w, `[{"meeting_key":1,"meeting_name":"British Grand Prix","year":%d}]`, year)
|
||||||
|
case "/v1/sessions":
|
||||||
|
_, _ = w.Write([]byte(`[{"session_key":11,"meeting_key":2,"session_name":"Race"}]`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
client := NewOpenF1Client(upstream.URL, 2*time.Second)
|
||||||
|
defer client.Close()
|
||||||
|
client.pacer.interval = 0
|
||||||
|
meetingKey := fmt.Sprintf("%s/v1/meetings?year=%d", upstream.URL, year)
|
||||||
|
sessionKey := upstream.URL + "/v1/sessions?meeting_key=2"
|
||||||
|
_, _ = client.cache.db.Exec(`DELETE FROM cache WHERE key IN (?, ?)`, meetingKey, sessionKey)
|
||||||
|
defer func() { _, _ = client.cache.db.Exec(`DELETE FROM cache WHERE key IN (?, ?)`, meetingKey, sessionKey) }()
|
||||||
|
if _, err := client.GetMeetingsForYear(year); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
expireCacheEntry(t, client, meetingKey)
|
||||||
|
failMeetings.Store(true)
|
||||||
|
|
||||||
|
staleClient := client.Scoped()
|
||||||
|
staleDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := staleClient.GetMeetingsForYear(year)
|
||||||
|
staleDone <- err
|
||||||
|
}()
|
||||||
|
<-staleStarted
|
||||||
|
|
||||||
|
freshClient := client.Scoped()
|
||||||
|
if _, err := freshClient.GetSessionsForMeeting(2); err != nil {
|
||||||
|
t.Fatalf("fresh concurrent request: %v", err)
|
||||||
|
}
|
||||||
|
if freshClient.LastResponseWasStale() {
|
||||||
|
t.Fatal("fresh request inherited concurrent request's stale state")
|
||||||
|
}
|
||||||
|
close(releaseStale)
|
||||||
|
if err := <-staleDone; err != nil {
|
||||||
|
t.Fatalf("stale request: %v", err)
|
||||||
|
}
|
||||||
|
if !staleClient.LastResponseWasStale() {
|
||||||
|
t.Fatal("stale request lost its own freshness state")
|
||||||
|
}
|
||||||
|
if freshClient.LastResponseWasStale() || client.LastResponseWasStale() {
|
||||||
|
t.Fatal("stale state leaked after concurrent requests completed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ type LiveEvidence struct {
|
|||||||
|
|
||||||
// ContextAvailability is structured source state for a referenced session.
|
// ContextAvailability is structured source state for a referenced session.
|
||||||
type ContextAvailability struct {
|
type ContextAvailability struct {
|
||||||
|
Source string `json:"source"`
|
||||||
Schedule string `json:"schedule"`
|
Schedule string `json:"schedule"`
|
||||||
LiveTransport string `json:"live_transport"`
|
LiveTransport string `json:"live_transport"`
|
||||||
LiveSession string `json:"live_session"`
|
LiveSession string `json:"live_session"`
|
||||||
@@ -298,21 +299,28 @@ func meetingModelByKey(meetings []store.Meeting, key int) *models.Meeting {
|
|||||||
func sessionRef(c contextCandidate, evidence LiveEvidence, now time.Time) *ContextSession {
|
func sessionRef(c contextCandidate, evidence LiveEvidence, now time.Time) *ContextSession {
|
||||||
session := sessionToModel(c.session)
|
session := sessionToModel(c.session)
|
||||||
meeting := meetingToModel(c.meeting)
|
meeting := meetingToModel(c.meeting)
|
||||||
availability := ContextAvailability{Schedule: "available", LiveSession: "inactive", Archive: "unavailable", Freshness: "fresh", Limitations: []string{}}
|
// Schedule and analysis are domain-store facts. Without an ingestion
|
||||||
|
// timestamp the resolver cannot honestly call them network-fresh, so local
|
||||||
|
// is the baseline freshness vocabulary exposed to clients.
|
||||||
|
availability := ContextAvailability{Source: "local", Schedule: "available", LiveSession: "inactive", Archive: "unavailable", Freshness: "local", Limitations: []string{}}
|
||||||
availability.LiveTransport = "unknown"
|
availability.LiveTransport = "unknown"
|
||||||
if c.session.SessionKey == 0 {
|
if c.session.SessionKey == 0 {
|
||||||
availability.Schedule = "unavailable"
|
availability.Schedule = "unavailable"
|
||||||
availability.Limitations = append(availability.Limitations, "schedule_identity_unmatched")
|
availability.Limitations = append(availability.Limitations, "schedule_identity_unmatched")
|
||||||
}
|
}
|
||||||
if evidence.Active && liveMatches(evidence, c.meeting, c.session) {
|
if evidence.Active && liveMatches(evidence, c.meeting, c.session) {
|
||||||
|
availability.Source = "mixed"
|
||||||
availability.LiveTransport = "connected"
|
availability.LiveTransport = "connected"
|
||||||
availability.LiveSession = "active"
|
availability.LiveSession = "active"
|
||||||
|
availability.Freshness = "live"
|
||||||
if !evidence.ObservedAt.IsZero() {
|
if !evidence.ObservedAt.IsZero() {
|
||||||
availability.ObservedAt = evidence.ObservedAt.Format(time.RFC3339)
|
availability.ObservedAt = evidence.ObservedAt.Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if c.archived {
|
if c.archived {
|
||||||
|
availability.Source = "mixed"
|
||||||
availability.Archive = "available"
|
availability.Archive = "available"
|
||||||
|
availability.Freshness = "archive"
|
||||||
if !evidence.ObservedAt.IsZero() {
|
if !evidence.ObservedAt.IsZero() {
|
||||||
availability.ObservedAt = evidence.ObservedAt.Format(time.RFC3339)
|
availability.ObservedAt = evidence.ObservedAt.Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
@@ -326,6 +334,12 @@ func sessionRef(c contextCandidate, evidence LiveEvidence, now time.Time) *Conte
|
|||||||
} else {
|
} else {
|
||||||
availability.LocalAnalysis = "pending"
|
availability.LocalAnalysis = "pending"
|
||||||
}
|
}
|
||||||
|
if availability.LocalAnalysis == "partial" && availability.Freshness == "local" {
|
||||||
|
availability.Freshness = "partial"
|
||||||
|
}
|
||||||
|
if c.session.SessionKey == 0 && availability.LiveSession == "active" {
|
||||||
|
availability.Source = "fia"
|
||||||
|
}
|
||||||
return &ContextSession{Session: session, Meeting: &meeting, Availability: availability}
|
return &ContextSession{Session: session, Meeting: &meeting, Availability: availability}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -160,6 +160,54 @@ func TestResolveWeekendContextPassedTimeDoesNotCompleteSession(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveWeekendContextAvailabilityUsesTruthfulSources(t *testing.T) {
|
||||||
|
now, _ := time.Parse(time.RFC3339, "2026-07-05T18:00:00Z")
|
||||||
|
svc := contextService(t, now)
|
||||||
|
addContextMeeting(t, svc, 1, "British Grand Prix", "2026-07-03T00:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 11, 1, "Race", "2026-07-05T14:00:00Z", "2026-07-05T16:00:00Z", false)
|
||||||
|
// Results without laps/stints/positions are meaningful but incomplete local
|
||||||
|
// analysis, so they must not be labelled universally fresh.
|
||||||
|
completeContextSession(t, svc, 11, 1)
|
||||||
|
addContextMeeting(t, svc, 2, "Belgian Grand Prix", "2026-07-17T00:00:00Z", "2026-07-19T16:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 21, 2, "Practice 1", "2026-07-17T09:00:00Z", "2026-07-17T10:00:00Z", false)
|
||||||
|
addContextSession(t, svc, 22, 2, "Race", "2026-07-19T14:00:00Z", "2026-07-19T16:00:00Z", false)
|
||||||
|
|
||||||
|
local, err := svc.ResolveWeekendContext(LiveEvidence{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := local.PreviousCompletedSession.Availability; got.Source != "local" || got.Freshness != "partial" || got.LocalAnalysis != "partial" {
|
||||||
|
t.Fatalf("partial local availability = %+v", got)
|
||||||
|
}
|
||||||
|
if got := local.NextSession.Availability; got.Source != "local" || got.Freshness != "local" {
|
||||||
|
t.Fatalf("future local availability = %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
liveContext, err := svc.ResolveWeekendContext(LiveEvidence{Active: true, MeetingName: "Belgian Grand Prix", CircuitName: "Belgian Grand Prix", SessionName: "Practice 1", SessionType: "Practice 1", ObservedAt: now})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := liveContext.ActiveSession.Availability; got.Source != "mixed" || got.Freshness != "live" || got.LiveSession != "active" {
|
||||||
|
t.Fatalf("FIA + local availability = %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
archiveContext, err := svc.ResolveWeekendContext(LiveEvidence{Final: true, MeetingName: "British Grand Prix", CircuitName: "British Grand Prix", SessionName: "Race", SessionType: "Race", ObservedAt: now})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := archiveContext.PreviousCompletedSession.Availability; got.Source != "mixed" || got.Freshness != "archive" || got.Archive != "available" {
|
||||||
|
t.Fatalf("FIA archive + local availability = %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
synthetic, err := svc.ResolveWeekendContext(LiveEvidence{Active: true, MeetingName: "Unscheduled Grand Prix", SessionName: "Race", SessionType: "Race", ObservedAt: now})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := synthetic.ActiveSession.Availability; got.Source != "fia" || got.Freshness != "live" || got.Schedule != "unavailable" {
|
||||||
|
t.Fatalf("synthetic FIA availability = %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveWeekendContextNeverUsesFutureAnalysis(t *testing.T) {
|
func TestResolveWeekendContextNeverUsesFutureAnalysis(t *testing.T) {
|
||||||
now, _ := time.Parse(time.RFC3339, "2026-07-01T12:00:00Z")
|
now, _ := time.Parse(time.RFC3339, "2026-07-01T12:00:00Z")
|
||||||
svc := contextService(t, now)
|
svc := contextService(t, now)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
|
|
||||||
readability "codeberg.org/readeck/go-readability/v2"
|
readability "codeberg.org/readeck/go-readability/v2"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/api"
|
||||||
"github.com/AmanTahiliani/box-box/internal/models"
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
"github.com/AmanTahiliani/box-box/internal/query"
|
"github.com/AmanTahiliani/box-box/internal/query"
|
||||||
)
|
)
|
||||||
@@ -43,6 +44,7 @@ func (s *Server) handleMeetings(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
switch parseSourceMode(r) {
|
switch parseSourceMode(r) {
|
||||||
case sourceLocal:
|
case sourceLocal:
|
||||||
|
markLocalResponse(w, false)
|
||||||
if !s.hasLocalQuery() {
|
if !s.hasLocalQuery() {
|
||||||
writeJSON(w, []models.Meeting{})
|
writeJSON(w, []models.Meeting{})
|
||||||
return
|
return
|
||||||
@@ -62,17 +64,20 @@ func (s *Server) handleMeetings(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(meetings) > 0 {
|
if len(meetings) > 0 {
|
||||||
|
markLocalResponse(w, false)
|
||||||
writeJSON(w, meetings)
|
writeJSON(w, meetings)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
meetings, err := s.client.GetMeetingsForYear(year)
|
client := s.client.Scoped()
|
||||||
|
meetings, err := client.GetMeetingsForYear(year)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, meetings)
|
writeJSON(w, meetings)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +92,7 @@ func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
switch parseSourceMode(r) {
|
switch parseSourceMode(r) {
|
||||||
case sourceLocal:
|
case sourceLocal:
|
||||||
|
markLocalResponse(w, false)
|
||||||
if !s.hasLocalQuery() {
|
if !s.hasLocalQuery() {
|
||||||
writeJSON(w, []models.Session{})
|
writeJSON(w, []models.Session{})
|
||||||
return
|
return
|
||||||
@@ -106,23 +112,27 @@ func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(sessions) > 0 {
|
if len(sessions) > 0 {
|
||||||
|
markLocalResponse(w, false)
|
||||||
writeJSON(w, sessions)
|
writeJSON(w, sessions)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sessions, err := s.client.GetSessionsForMeeting(meetingKey)
|
client := s.client.Scoped()
|
||||||
|
sessions, err := client.GetSessionsForMeeting(meetingKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, sessions)
|
writeJSON(w, sessions)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- /api/v1/news ---
|
// --- /api/v1/news ---
|
||||||
|
|
||||||
func (s *Server) handleNews(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleNews(w http.ResponseWriter, r *http.Request) {
|
||||||
|
markLocalResponse(w, false)
|
||||||
if !s.hasLocalQuery() {
|
if !s.hasLocalQuery() {
|
||||||
writeJSON(w, []query.NewsItem{})
|
writeJSON(w, []query.NewsItem{})
|
||||||
return
|
return
|
||||||
@@ -223,6 +233,7 @@ func (s *Server) handleDrivers(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
switch parseSourceMode(r) {
|
switch parseSourceMode(r) {
|
||||||
case sourceLocal:
|
case sourceLocal:
|
||||||
|
markLocalResponse(w, false)
|
||||||
if !s.hasLocalQuery() {
|
if !s.hasLocalQuery() {
|
||||||
writeJSON(w, []models.Driver{})
|
writeJSON(w, []models.Driver{})
|
||||||
return
|
return
|
||||||
@@ -242,6 +253,7 @@ func (s *Server) handleDrivers(w http.ResponseWriter, r *http.Request) {
|
|||||||
if s.hasLocalQuery() {
|
if s.hasLocalQuery() {
|
||||||
drivers, err := s.query.ListDrivers(sessionKey)
|
drivers, err := s.query.ListDrivers(sessionKey)
|
||||||
if err == nil && len(drivers) > 0 {
|
if err == nil && len(drivers) > 0 {
|
||||||
|
markLocalResponse(w, false)
|
||||||
writeJSON(w, drivers)
|
writeJSON(w, drivers)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -252,11 +264,13 @@ func (s *Server) handleDrivers(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
drivers, err := s.client.GetDriversForSession(sessionKey)
|
client := s.client.Scoped()
|
||||||
|
drivers, err := client.GetDriversForSession(sessionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, drivers)
|
writeJSON(w, drivers)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,6 +293,7 @@ func (s *Server) handleResults(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
switch parseSourceMode(r) {
|
switch parseSourceMode(r) {
|
||||||
case sourceLocal:
|
case sourceLocal:
|
||||||
|
markLocalResponse(w, false)
|
||||||
if !s.hasLocalQuery() {
|
if !s.hasLocalQuery() {
|
||||||
writeJSON(w, []resultWithDriver{})
|
writeJSON(w, []resultWithDriver{})
|
||||||
return
|
return
|
||||||
@@ -294,6 +309,7 @@ func (s *Server) handleResults(w http.ResponseWriter, r *http.Request) {
|
|||||||
if s.hasLocalQuery() {
|
if s.hasLocalQuery() {
|
||||||
results, err := s.query.ListResults(sessionKey)
|
results, err := s.query.ListResults(sessionKey)
|
||||||
if err == nil && len(results) > 0 {
|
if err == nil && len(results) > 0 {
|
||||||
|
markLocalResponse(w, false)
|
||||||
writeJSON(w, enrichedResultsToAPI(results))
|
writeJSON(w, enrichedResultsToAPI(results))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -310,13 +326,14 @@ func (s *Server) handleResults(w http.ResponseWriter, r *http.Request) {
|
|||||||
resultsErr error
|
resultsErr error
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
)
|
)
|
||||||
|
client := s.client.Scoped()
|
||||||
wg.Add(2)
|
wg.Add(2)
|
||||||
go func() { defer wg.Done(); results, resultsErr = s.client.GetSessionResult(sessionKey) }()
|
go func() { defer wg.Done(); results, resultsErr = client.GetSessionResult(sessionKey) }()
|
||||||
go func() { defer wg.Done(); drivers, _ = s.client.GetDriversForSession(sessionKey) }()
|
go func() { defer wg.Done(); drivers, _ = client.GetDriversForSession(sessionKey) }()
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
if resultsErr != nil {
|
if resultsErr != nil {
|
||||||
writeError(w, resultsErr, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, resultsErr, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,6 +349,7 @@ func (s *Server) handleResults(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
enriched = append(enriched, e)
|
enriched = append(enriched, e)
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, enriched)
|
writeJSON(w, enriched)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,6 +372,7 @@ func (s *Server) handleGrid(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
switch parseSourceMode(r) {
|
switch parseSourceMode(r) {
|
||||||
case sourceLocal:
|
case sourceLocal:
|
||||||
|
markLocalResponse(w, false)
|
||||||
if !s.hasLocalQuery() {
|
if !s.hasLocalQuery() {
|
||||||
writeJSON(w, []gridWithDriver{})
|
writeJSON(w, []gridWithDriver{})
|
||||||
return
|
return
|
||||||
@@ -369,6 +388,7 @@ func (s *Server) handleGrid(w http.ResponseWriter, r *http.Request) {
|
|||||||
if s.hasLocalQuery() {
|
if s.hasLocalQuery() {
|
||||||
grid, err := s.query.ListStartingGrid(sessionKey)
|
grid, err := s.query.ListStartingGrid(sessionKey)
|
||||||
if err == nil && len(grid) > 0 {
|
if err == nil && len(grid) > 0 {
|
||||||
|
markLocalResponse(w, false)
|
||||||
writeJSON(w, enrichedGridToAPI(grid))
|
writeJSON(w, enrichedGridToAPI(grid))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -385,13 +405,14 @@ func (s *Server) handleGrid(w http.ResponseWriter, r *http.Request) {
|
|||||||
gridErr error
|
gridErr error
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
)
|
)
|
||||||
|
client := s.client.Scoped()
|
||||||
wg.Add(2)
|
wg.Add(2)
|
||||||
go func() { defer wg.Done(); grid, gridErr = s.client.GetStartingGrid(sessionKey) }()
|
go func() { defer wg.Done(); grid, gridErr = client.GetStartingGrid(sessionKey) }()
|
||||||
go func() { defer wg.Done(); drivers, _ = s.client.GetDriversForSession(sessionKey) }()
|
go func() { defer wg.Done(); drivers, _ = client.GetDriversForSession(sessionKey) }()
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
if gridErr != nil {
|
if gridErr != nil {
|
||||||
writeError(w, gridErr, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, gridErr, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -407,6 +428,7 @@ func (s *Server) handleGrid(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
enriched = append(enriched, e)
|
enriched = append(enriched, e)
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, enriched)
|
writeJSON(w, enriched)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,26 +441,29 @@ func (s *Server) handleLaps(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
client := s.client.Scoped()
|
||||||
if dnStr := r.URL.Query().Get("driver_number"); dnStr != "" {
|
if dnStr := r.URL.Query().Get("driver_number"); dnStr != "" {
|
||||||
driverNumber, err := strconv.Atoi(dnStr)
|
driverNumber, err := strconv.Atoi(dnStr)
|
||||||
if err != nil || driverNumber == 0 {
|
if err != nil || driverNumber == 0 {
|
||||||
http.Error(w, "invalid driver_number", http.StatusBadRequest)
|
http.Error(w, "invalid driver_number", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
laps, err := s.client.GetLapsForDriver(sessionKey, driverNumber)
|
laps, err := client.GetLapsForDriver(sessionKey, driverNumber)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, laps)
|
writeJSON(w, laps)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
laps, err := s.client.GetLapsForSession(sessionKey)
|
laps, err := client.GetLapsForSession(sessionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, laps)
|
writeJSON(w, laps)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,11 +475,13 @@ func (s *Server) handleWeather(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "session_key required", http.StatusBadRequest)
|
http.Error(w, "session_key required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
weather, err := s.client.GetWeather(sessionKey)
|
client := s.client.Scoped()
|
||||||
|
weather, err := client.GetWeather(sessionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, weather)
|
writeJSON(w, weather)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,11 +493,13 @@ func (s *Server) handleRaceControl(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "session_key required", http.StatusBadRequest)
|
http.Error(w, "session_key required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rc, err := s.client.GetRaceControl(sessionKey)
|
client := s.client.Scoped()
|
||||||
|
rc, err := client.GetRaceControl(sessionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, rc)
|
writeJSON(w, rc)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,11 +516,13 @@ func (s *Server) handleTelemetry(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "driver_number required", http.StatusBadRequest)
|
http.Error(w, "driver_number required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
carData, err := s.client.GetCarData(sessionKey, driverNumber)
|
client := s.client.Scoped()
|
||||||
|
carData, err := client.GetCarData(sessionKey, driverNumber)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, carData)
|
writeJSON(w, carData)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -503,11 +534,13 @@ func (s *Server) handleOvertakes(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "session_key required", http.StatusBadRequest)
|
http.Error(w, "session_key required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
overtakes, err := s.client.GetOvertakesForSession(sessionKey)
|
client := s.client.Scoped()
|
||||||
|
overtakes, err := client.GetOvertakesForSession(sessionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, overtakes)
|
writeJSON(w, overtakes)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,11 +557,13 @@ func (s *Server) handleTeamRadio(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "driver_number required", http.StatusBadRequest)
|
http.Error(w, "driver_number required", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
radios, err := s.client.GetTeamRadio(sessionKey, driverNumber)
|
client := s.client.Scoped()
|
||||||
|
radios, err := client.GetTeamRadio(sessionKey, driverNumber)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, radios)
|
writeJSON(w, radios)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,23 +582,25 @@ func (s *Server) handleChampionshipDrivers(w http.ResponseWriter, r *http.Reques
|
|||||||
if year == 0 {
|
if year == 0 {
|
||||||
year = time.Now().Year()
|
year = time.Now().Year()
|
||||||
}
|
}
|
||||||
champ, err := s.client.GetDriverChampionshipForYear(year)
|
client := s.client.Scoped()
|
||||||
|
champ, err := client.GetDriverChampionshipForYear(year)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(champ) == 0 {
|
if len(champ) == 0 {
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, []any{})
|
writeJSON(w, []any{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
drivers, _ := s.client.GetDriversForSession(champ[0].SessionKey)
|
drivers, _ := client.GetDriversForSession(champ[0].SessionKey)
|
||||||
driverMap := buildDriverMapFirst(drivers)
|
driverMap := buildDriverMapFirst(drivers)
|
||||||
|
|
||||||
enriched := make([]champDriverWithInfo, 0, len(champ))
|
enriched := make([]champDriverWithInfo, 0, len(champ))
|
||||||
for _, c := range champ {
|
for _, c := range champ {
|
||||||
e := champDriverWithInfo{ChampionshipDriver: c}
|
e := champDriverWithInfo{ChampionshipDriver: c}
|
||||||
d, ok := s.championshipDriverInfo(c.SessionKey, c.DriverNumber, driverMap)
|
d, ok := championshipDriverInfo(client, c.SessionKey, c.DriverNumber, driverMap)
|
||||||
if ok {
|
if ok {
|
||||||
e.NameAcronym = d.NameAcronym
|
e.NameAcronym = d.NameAcronym
|
||||||
e.FullName = d.FullName
|
e.FullName = d.FullName
|
||||||
@@ -572,11 +609,12 @@ func (s *Server) handleChampionshipDrivers(w http.ResponseWriter, r *http.Reques
|
|||||||
}
|
}
|
||||||
enriched = append(enriched, e)
|
enriched = append(enriched, e)
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, enriched)
|
writeJSON(w, enriched)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) championshipDriverInfo(sessionKey, driverNumber int, fallback map[int]models.Driver) (models.Driver, bool) {
|
func championshipDriverInfo(client *api.OpenF1Client, sessionKey, driverNumber int, fallback map[int]models.Driver) (models.Driver, bool) {
|
||||||
if d, err := s.client.GetDriver(sessionKey, driverNumber); err == nil && d != nil {
|
if d, err := client.GetDriver(sessionKey, driverNumber); err == nil && d != nil {
|
||||||
return *d, true
|
return *d, true
|
||||||
}
|
}
|
||||||
d, ok := fallback[driverNumber]
|
d, ok := fallback[driverNumber]
|
||||||
@@ -590,11 +628,13 @@ func (s *Server) handleChampionshipTeams(w http.ResponseWriter, r *http.Request)
|
|||||||
if year == 0 {
|
if year == 0 {
|
||||||
year = time.Now().Year()
|
year = time.Now().Year()
|
||||||
}
|
}
|
||||||
teams, err := s.client.GetTeamChampionshipForYear(year)
|
client := s.client.Scoped()
|
||||||
|
teams, err := client.GetTeamChampionshipForYear(year)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, teams)
|
writeJSON(w, teams)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -672,6 +712,8 @@ func champHubTTL(year int, now time.Time) time.Duration {
|
|||||||
|
|
||||||
type champHubEntry struct {
|
type champHubEntry struct {
|
||||||
resp champHubResponse
|
resp champHubResponse
|
||||||
|
source string
|
||||||
|
freshness string
|
||||||
expires time.Time
|
expires time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -693,12 +735,26 @@ func (c *champHubCache) get(year int, now time.Time) (champHubResponse, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *champHubCache) put(year int, resp champHubResponse, now time.Time, ttl time.Duration) {
|
func (c *champHubCache) put(year int, resp champHubResponse, now time.Time, ttl time.Duration) {
|
||||||
|
c.putWithMetadata(year, resp, "local", "local", now, ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *champHubCache) getWithMetadata(year int, now time.Time) (champHubResponse, string, string, bool) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
e, ok := c.entries[year]
|
||||||
|
if !ok || now.After(e.expires) {
|
||||||
|
return champHubResponse{}, "", "", false
|
||||||
|
}
|
||||||
|
return e.resp, e.source, e.freshness, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *champHubCache) putWithMetadata(year int, resp champHubResponse, source, freshness string, now time.Time, ttl time.Duration) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
if c.entries == nil {
|
if c.entries == nil {
|
||||||
c.entries = map[int]champHubEntry{}
|
c.entries = map[int]champHubEntry{}
|
||||||
}
|
}
|
||||||
c.entries[year] = champHubEntry{resp: resp, expires: now.Add(ttl)}
|
c.entries[year] = champHubEntry{resp: resp, source: source, freshness: freshness, expires: now.Add(ttl)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchMeetingRaces fans fetch out across meetings with bounded concurrency.
|
// fetchMeetingRaces fans fetch out across meetings with bounded concurrency.
|
||||||
@@ -740,13 +796,6 @@ func (s *Server) handleChampionshipHub(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
mode := parseSourceMode(r)
|
mode := parseSourceMode(r)
|
||||||
|
|
||||||
if mode != sourceLocal {
|
|
||||||
if resp, ok := s.hubCache.get(year, time.Now()); ok {
|
|
||||||
writeJSON(w, resp)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if mode == sourceLocal || mode == sourceAuto {
|
if mode == sourceLocal || mode == sourceAuto {
|
||||||
resp, ok, err := s.localChampionshipHub(year)
|
resp, ok, err := s.localChampionshipHub(year)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -754,21 +803,33 @@ func (s *Server) handleChampionshipHub(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if ok {
|
if ok {
|
||||||
s.hubCache.put(year, resp, time.Now(), champHubTTL(year, time.Now()))
|
s.hubCache.putWithMetadata(year, resp, "local", "local", time.Now(), champHubTTL(year, time.Now()))
|
||||||
|
markLocalResponse(w, false)
|
||||||
writeJSON(w, resp)
|
writeJSON(w, resp)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if mode == sourceLocal {
|
if mode == sourceLocal {
|
||||||
|
markLocalResponse(w, false)
|
||||||
writeJSON(w, resp)
|
writeJSON(w, resp)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := s.openF1ChampionshipHub(year)
|
// At this point an auto request has no usable domain aggregate and an
|
||||||
if err != nil {
|
// explicit OpenF1 request must not be satisfied by a local cache entry.
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
if resp, source, freshness, ok := s.hubCache.getWithMetadata(year, time.Now()); ok && source == "openf1" {
|
||||||
|
markDataResponse(w, source, freshness)
|
||||||
|
writeJSON(w, resp)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
client := s.client.Scoped()
|
||||||
|
resp, incomplete, err := s.openF1ChampionshipHub(client, year)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
markOpenF1AggregateResponse(w, client, incomplete)
|
||||||
writeJSON(w, resp)
|
writeJSON(w, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -797,25 +858,26 @@ func (s *Server) localChampionshipHub(year int) (champHubResponse, bool, error)
|
|||||||
return aggregateChampionshipHub(year, races, inputs.Champ, inputs.Teams, inputs.DriverMap), true, nil
|
return aggregateChampionshipHub(year, races, inputs.Champ, inputs.Teams, inputs.DriverMap), true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) {
|
func (s *Server) openF1ChampionshipHub(client *api.OpenF1Client, year int) (champHubResponse, bool, error) {
|
||||||
champ, err := s.client.GetDriverChampionshipForYear(year)
|
champ, err := client.GetDriverChampionshipForYear(year)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return champHubResponse{}, err
|
return champHubResponse{}, false, err
|
||||||
}
|
}
|
||||||
if len(champ) == 0 {
|
if len(champ) == 0 {
|
||||||
return champHubResponse{Season: year, RoundLabels: []string{}, Drivers: []champHubDriver{}, Teams: []champHubTeam{}}, nil
|
return champHubResponse{Season: year, RoundLabels: []string{}, Drivers: []champHubDriver{}, Teams: []champHubTeam{}}, false, nil
|
||||||
}
|
}
|
||||||
teams, _ := s.client.GetTeamChampionshipForYear(year)
|
teams, teamsErr := client.GetTeamChampionshipForYear(year)
|
||||||
|
|
||||||
driverInfo := map[int]models.Driver{}
|
driverInfo := map[int]models.Driver{}
|
||||||
if ds, derr := s.client.GetDriversForSession(champ[0].SessionKey); derr == nil {
|
if ds, derr := client.GetDriversForSession(champ[0].SessionKey); derr == nil {
|
||||||
driverInfo = buildDriverMapFirst(ds)
|
driverInfo = buildDriverMapFirst(ds)
|
||||||
}
|
}
|
||||||
|
|
||||||
races, incomplete, err := s.fetchSeasonRaces(year)
|
races, incomplete, err := fetchSeasonRaces(client, year)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return champHubResponse{}, err
|
return champHubResponse{}, false, err
|
||||||
}
|
}
|
||||||
|
incomplete = incomplete || teamsErr != nil
|
||||||
|
|
||||||
resp := aggregateChampionshipHub(year, races, champ, teams, driverInfo)
|
resp := aggregateChampionshipHub(year, races, champ, teams, driverInfo)
|
||||||
ttl := champHubTTL(year, time.Now())
|
ttl := champHubTTL(year, time.Now())
|
||||||
@@ -825,16 +887,22 @@ func (s *Server) openF1ChampionshipHub(year int) (champHubResponse, error) {
|
|||||||
// so a partial view of the season doesn't stick around for the full TTL.
|
// so a partial view of the season doesn't stick around for the full TTL.
|
||||||
ttl = champHubIncompleteTTL
|
ttl = champHubIncompleteTTL
|
||||||
}
|
}
|
||||||
s.hubCache.put(year, resp, time.Now(), ttl)
|
freshness := "fresh"
|
||||||
return resp, nil
|
if client.LastResponseWasStale() {
|
||||||
|
freshness = "stale"
|
||||||
|
} else if incomplete {
|
||||||
|
freshness = "partial"
|
||||||
|
}
|
||||||
|
s.hubCache.putWithMetadata(year, resp, "openf1", freshness, time.Now(), ttl)
|
||||||
|
return resp, incomplete, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchSeasonRaces returns a season's GP meetings in date order, each bundled
|
// fetchSeasonRaces returns a season's GP meetings in date order, each bundled
|
||||||
// with its race results and starting grid fetched from OpenF1. incomplete
|
// with its race results and starting grid fetched from OpenF1. incomplete
|
||||||
// reports whether any per-meeting fetch failed, so callers can avoid caching a
|
// reports whether any per-meeting fetch failed, so callers can avoid caching a
|
||||||
// partial view of the season for long.
|
// partial view of the season for long.
|
||||||
func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete bool, err error) {
|
func fetchSeasonRaces(client *api.OpenF1Client, year int) (races []meetingRace, incomplete bool, err error) {
|
||||||
meetings, err := s.client.GetMeetingsForYear(year)
|
meetings, err := client.GetMeetingsForYear(year)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
@@ -842,7 +910,7 @@ func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete boo
|
|||||||
|
|
||||||
var failed atomic.Bool
|
var failed atomic.Bool
|
||||||
races = fetchMeetingRaces(meetings, champHubWorkers, func(m models.Meeting) (meetingRace, bool) {
|
races = fetchMeetingRaces(meetings, champHubWorkers, func(m models.Meeting) (meetingRace, bool) {
|
||||||
sessions, serr := s.client.GetSessionsForMeeting(int(m.MeetingKey))
|
sessions, serr := client.GetSessionsForMeeting(int(m.MeetingKey))
|
||||||
if serr != nil {
|
if serr != nil {
|
||||||
failed.Store(true)
|
failed.Store(true)
|
||||||
return meetingRace{}, false
|
return meetingRace{}, false
|
||||||
@@ -857,8 +925,8 @@ func (s *Server) fetchSeasonRaces(year int) (races []meetingRace, incomplete boo
|
|||||||
if raceKey == 0 {
|
if raceKey == 0 {
|
||||||
return meetingRace{}, false // not a GP meeting (e.g. pre-season testing)
|
return meetingRace{}, false // not a GP meeting (e.g. pre-season testing)
|
||||||
}
|
}
|
||||||
results, rerr := s.client.GetSessionResult(raceKey)
|
results, rerr := client.GetSessionResult(raceKey)
|
||||||
grid, gerr := s.client.GetStartingGrid(raceKey)
|
grid, gerr := client.GetStartingGrid(raceKey)
|
||||||
if rerr != nil || gerr != nil {
|
if rerr != nil || gerr != nil {
|
||||||
failed.Store(true)
|
failed.Store(true)
|
||||||
}
|
}
|
||||||
@@ -1077,6 +1145,7 @@ type trackOutlineResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleTrackOutline(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleTrackOutline(w http.ResponseWriter, r *http.Request) {
|
||||||
|
markLocalResponse(w, false)
|
||||||
year, _ := strconv.Atoi(r.URL.Query().Get("year"))
|
year, _ := strconv.Atoi(r.URL.Query().Get("year"))
|
||||||
if year == 0 {
|
if year == 0 {
|
||||||
year = time.Now().Year()
|
year = time.Now().Year()
|
||||||
@@ -1283,12 +1352,13 @@ func (s *Server) handleStrategy(w http.ResponseWriter, r *http.Request) {
|
|||||||
resErr error
|
resErr error
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
)
|
)
|
||||||
|
client := s.client.Scoped()
|
||||||
wg.Add(5)
|
wg.Add(5)
|
||||||
go func() { defer wg.Done(); stints, stintsErr = s.client.GetStintsForSession(sessionKey) }()
|
go func() { defer wg.Done(); stints, stintsErr = client.GetStintsForSession(sessionKey) }()
|
||||||
go func() { defer wg.Done(); pits, pitsErr = s.client.GetPitStopsForSession(sessionKey) }()
|
go func() { defer wg.Done(); pits, pitsErr = client.GetPitStopsForSession(sessionKey) }()
|
||||||
go func() { defer wg.Done(); results, resErr = s.client.GetSessionResult(sessionKey) }()
|
go func() { defer wg.Done(); results, resErr = client.GetSessionResult(sessionKey) }()
|
||||||
go func() { defer wg.Done(); drivers, _ = s.client.GetDriversForSession(sessionKey) }()
|
go func() { defer wg.Done(); drivers, _ = client.GetDriversForSession(sessionKey) }()
|
||||||
go func() { defer wg.Done(); rc, _ = s.client.GetRaceControl(sessionKey) }()
|
go func() { defer wg.Done(); rc, _ = client.GetRaceControl(sessionKey) }()
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
if stintsErr != nil || pitsErr != nil || resErr != nil {
|
if stintsErr != nil || pitsErr != nil || resErr != nil {
|
||||||
@@ -1299,12 +1369,13 @@ func (s *Server) handleStrategy(w http.ResponseWriter, r *http.Request) {
|
|||||||
if e == nil {
|
if e == nil {
|
||||||
e = resErr
|
e = resErr
|
||||||
}
|
}
|
||||||
writeError(w, e, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, e, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-race sessions have no stints.
|
// Non-race sessions have no stints.
|
||||||
if len(stints) == 0 {
|
if len(stints) == 0 {
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, map[string]any{"note": "Not applicable", "drivers": []any{}})
|
writeJSON(w, map[string]any{"note": "Not applicable", "drivers": []any{}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1396,6 +1467,7 @@ func (s *Server) handleStrategy(w http.ResponseWriter, r *http.Request) {
|
|||||||
return pi < pj
|
return pi < pj
|
||||||
})
|
})
|
||||||
|
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, strategyResponse{
|
writeJSON(w, strategyResponse{
|
||||||
SessionKey: sessionKey,
|
SessionKey: sessionKey,
|
||||||
TotalLaps: totalLaps,
|
TotalLaps: totalLaps,
|
||||||
@@ -1497,14 +1569,15 @@ func (s *Server) handleLapsComparison(w http.ResponseWriter, r *http.Request) {
|
|||||||
rc []models.RaceControl
|
rc []models.RaceControl
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
)
|
)
|
||||||
|
client := s.client.Scoped()
|
||||||
wg.Add(4)
|
wg.Add(4)
|
||||||
go func() { defer wg.Done(); allLaps, _ = s.client.GetLapsForSession(sessionKey) }()
|
go func() { defer wg.Done(); allLaps, _ = client.GetLapsForSession(sessionKey) }()
|
||||||
go func() { defer wg.Done(); stints, _ = s.client.GetStintsForSession(sessionKey) }()
|
go func() { defer wg.Done(); stints, _ = client.GetStintsForSession(sessionKey) }()
|
||||||
go func() { defer wg.Done(); pits, _ = s.client.GetPitStopsForSession(sessionKey) }()
|
go func() { defer wg.Done(); pits, _ = client.GetPitStopsForSession(sessionKey) }()
|
||||||
go func() { defer wg.Done(); rc, _ = s.client.GetRaceControl(sessionKey) }()
|
go func() { defer wg.Done(); rc, _ = client.GetRaceControl(sessionKey) }()
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
allDrivers, _ := s.client.GetDriversForSession(sessionKey)
|
allDrivers, _ := client.GetDriversForSession(sessionKey)
|
||||||
driverMap := buildDriverMap(allDrivers)
|
driverMap := buildDriverMap(allDrivers)
|
||||||
|
|
||||||
// If no filter, default to first 3 unique driver numbers from lap data.
|
// If no filter, default to first 3 unique driver numbers from lap data.
|
||||||
@@ -1558,6 +1631,7 @@ func (s *Server) handleLapsComparison(w http.ResponseWriter, r *http.Request) {
|
|||||||
compDrivers = append(compDrivers, cd)
|
compDrivers = append(compDrivers, cd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, lapsComparisonResponse{
|
writeJSON(w, lapsComparisonResponse{
|
||||||
SessionKey: sessionKey,
|
SessionKey: sessionKey,
|
||||||
SCPeriods: extractSCPeriods(rc),
|
SCPeriods: extractSCPeriods(rc),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) handleWeekendContext(w http.ResponseWriter, _ *http.Request) {
|
func (s *Server) handleWeekendContext(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
markLocalResponse(w, false)
|
||||||
if !s.hasLocalQuery() {
|
if !s.hasLocalQuery() {
|
||||||
writeJSON(w, query.WeekendContext{TemporalState: query.TemporalNoSeason})
|
writeJSON(w, query.WeekendContext{TemporalState: query.TemporalNoSeason})
|
||||||
return
|
return
|
||||||
@@ -29,6 +30,12 @@ func (s *Server) handleWeekendContext(w http.ResponseWriter, _ *http.Request) {
|
|||||||
writeError(w, err, http.StatusInternalServerError, false)
|
writeError(w, err, http.StatusInternalServerError, false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
for _, ref := range []*query.ContextSession{context.ActiveSession, context.PreviousCompletedSession, context.DefaultAnalysisSession, context.NextSession} {
|
||||||
|
if ref != nil && ref.Availability.Freshness != "local" {
|
||||||
|
markDataResponse(w, ref.Availability.Source, ref.Availability.Freshness)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
writeJSON(w, context)
|
writeJSON(w, context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,12 @@ func TestWeekendContextHandlerUsesLiveHubIdentityWithoutOpenF1(t *testing.T) {
|
|||||||
if got.ActiveSession.Availability.LiveTransport != "connected" || got.ActiveSession.Availability.LiveSession != "active" {
|
if got.ActiveSession.Availability.LiveTransport != "connected" || got.ActiveSession.Availability.LiveSession != "active" {
|
||||||
t.Fatalf("availability = %+v", got.ActiveSession.Availability)
|
t.Fatalf("availability = %+v", got.ActiveSession.Availability)
|
||||||
}
|
}
|
||||||
|
if got.ActiveSession.Availability.Source != "mixed" || got.ActiveSession.Availability.Freshness != "live" {
|
||||||
|
t.Fatalf("live source/freshness = %+v", got.ActiveSession.Availability)
|
||||||
|
}
|
||||||
|
if rr.Header().Get(dataSourceHeader) != "mixed" || rr.Header().Get(dataFreshnessHeader) != "live" {
|
||||||
|
t.Fatalf("response source/freshness = %q/%q", rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWeekendContextHandlerUsesTerminalArchiveAsCompletionEvidence(t *testing.T) {
|
func TestWeekendContextHandlerUsesTerminalArchiveAsCompletionEvidence(t *testing.T) {
|
||||||
@@ -94,6 +100,12 @@ func TestWeekendContextHandlerUsesTerminalArchiveAsCompletionEvidence(t *testing
|
|||||||
if got.PreviousCompletedSession == nil || got.PreviousCompletedSession.Availability.Archive != "available" {
|
if got.PreviousCompletedSession == nil || got.PreviousCompletedSession.Availability.Archive != "available" {
|
||||||
t.Fatalf("archive context = %+v", got)
|
t.Fatalf("archive context = %+v", got)
|
||||||
}
|
}
|
||||||
|
if got.PreviousCompletedSession.Availability.Source != "mixed" || got.PreviousCompletedSession.Availability.Freshness != "archive" {
|
||||||
|
t.Fatalf("archive source/freshness = %+v", got.PreviousCompletedSession.Availability)
|
||||||
|
}
|
||||||
|
if rr.Header().Get(dataSourceHeader) != "mixed" || rr.Header().Get(dataFreshnessHeader) != "archive" {
|
||||||
|
t.Fatalf("archive response source/freshness = %q/%q", rr.Header().Get(dataSourceHeader), rr.Header().Get(dataFreshnessHeader))
|
||||||
|
}
|
||||||
if got.DefaultAnalysisSession != nil {
|
if got.DefaultAnalysisSession != nil {
|
||||||
t.Fatal("archive-only session must not become local default analysis")
|
t.Fatal("archive-only session must not become local default analysis")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/api"
|
||||||
"github.com/AmanTahiliani/box-box/internal/models"
|
"github.com/AmanTahiliani/box-box/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -78,6 +79,7 @@ func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
|
|||||||
if r.URL.Query().Get("source") == "" {
|
if r.URL.Query().Get("source") == "" {
|
||||||
mode = sourceAuto
|
mode = sourceAuto
|
||||||
}
|
}
|
||||||
|
client := s.client.Scoped()
|
||||||
|
|
||||||
if mode == sourceLocal || mode == sourceAuto {
|
if mode == sourceLocal || mode == sourceAuto {
|
||||||
resp, sessionKey, ok, lerr := s.localDriverSummary(year, driverNumber)
|
resp, sessionKey, ok, lerr := s.localDriverSummary(year, driverNumber)
|
||||||
@@ -87,7 +89,15 @@ func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
if ok {
|
if ok {
|
||||||
if mode != sourceLocal {
|
if mode != sourceLocal {
|
||||||
s.tryEnrichDriverSummary(&resp, sessionKey)
|
tryEnrichDriverSummary(client, &resp, sessionKey)
|
||||||
|
}
|
||||||
|
switch resp.Enrichment {
|
||||||
|
case "full":
|
||||||
|
markMixedResponse(w, client, false)
|
||||||
|
case "limited":
|
||||||
|
markDataResponse(w, "local", "limited")
|
||||||
|
default:
|
||||||
|
markLocalResponse(w, false)
|
||||||
}
|
}
|
||||||
writeJSON(w, resp)
|
writeJSON(w, resp)
|
||||||
return
|
return
|
||||||
@@ -98,15 +108,16 @@ func (s *Server) handleDriverSummary(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := s.openF1DriverSummary(year, driverNumber)
|
resp, err := openF1DriverSummary(client, year, driverNumber)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if resp == nil {
|
if resp == nil {
|
||||||
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
|
http.Error(w, fmt.Sprintf("driver %d not found in %d championship", driverNumber, year), http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, resp)
|
writeJSON(w, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,8 +163,8 @@ func (s *Server) localDriverSummary(year, driverNumber int) (driverSummaryRespon
|
|||||||
return resp, sessionKey, true, nil
|
return resp, sessionKey, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) openF1DriverSummary(year, driverNumber int) (*driverSummaryResponse, error) {
|
func openF1DriverSummary(client *api.OpenF1Client, year, driverNumber int) (*driverSummaryResponse, error) {
|
||||||
champ, err := s.client.GetDriverChampionshipForYear(year)
|
champ, err := client.GetDriverChampionshipForYear(year)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -170,14 +181,14 @@ func (s *Server) openF1DriverSummary(year, driverNumber int) (*driverSummaryResp
|
|||||||
|
|
||||||
driverInfo := map[int]models.Driver{}
|
driverInfo := map[int]models.Driver{}
|
||||||
sessionKey := champ[0].SessionKey
|
sessionKey := champ[0].SessionKey
|
||||||
if ds, derr := s.client.GetDriversForSession(sessionKey); derr == nil {
|
if ds, derr := client.GetDriversForSession(sessionKey); derr == nil {
|
||||||
driverInfo = buildDriverMapFirst(ds)
|
driverInfo = buildDriverMapFirst(ds)
|
||||||
}
|
}
|
||||||
if d, ok := s.championshipDriverInfo(entry.SessionKey, driverNumber, driverInfo); ok {
|
if d, ok := championshipDriverInfo(client, entry.SessionKey, driverNumber, driverInfo); ok {
|
||||||
driverInfo[driverNumber] = d
|
driverInfo[driverNumber] = d
|
||||||
}
|
}
|
||||||
|
|
||||||
races, _, err := s.fetchSeasonRaces(year)
|
races, _, err := fetchSeasonRaces(client, year)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -194,8 +205,8 @@ func (s *Server) openF1DriverSummary(year, driverNumber int) (*driverSummaryResp
|
|||||||
// tryEnrichDriverSummary optionally fills headshot / polished identity from
|
// tryEnrichDriverSummary optionally fills headshot / polished identity from
|
||||||
// OpenF1. It never blocks longer than driverEnrichmentTimeout — on timeout or
|
// OpenF1. It never blocks longer than driverEnrichmentTimeout — on timeout or
|
||||||
// failure the local profile remains intact with enrichment=limited.
|
// failure the local profile remains intact with enrichment=limited.
|
||||||
func (s *Server) tryEnrichDriverSummary(resp *driverSummaryResponse, sessionKey int) {
|
func tryEnrichDriverSummary(client *api.OpenF1Client, resp *driverSummaryResponse, sessionKey int) {
|
||||||
if resp == nil || s.client == nil || sessionKey <= 0 {
|
if resp == nil || client == nil || sessionKey <= 0 {
|
||||||
if resp != nil && resp.Enrichment == "none" {
|
if resp != nil && resp.Enrichment == "none" {
|
||||||
// No session to enrich from — leave as none (local identity only).
|
// No session to enrich from — leave as none (local identity only).
|
||||||
}
|
}
|
||||||
@@ -209,7 +220,7 @@ func (s *Server) tryEnrichDriverSummary(resp *driverSummaryResponse, sessionKey
|
|||||||
|
|
||||||
done := make(chan enrichResult, 1)
|
done := make(chan enrichResult, 1)
|
||||||
go func() {
|
go func() {
|
||||||
d, ok := s.championshipDriverInfo(sessionKey, resp.DriverNumber, nil)
|
d, ok := championshipDriverInfo(client, sessionKey, resp.DriverNumber, nil)
|
||||||
done <- enrichResult{driver: d, ok: ok}
|
done <- enrichResult{driver: d, ok: ok}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@@ -250,6 +250,9 @@ func TestHandleDriverSummaryLocalFirstIgnoresHangingEnrichment(t *testing.T) {
|
|||||||
if resp.Enrichment != "limited" {
|
if resp.Enrichment != "limited" {
|
||||||
t.Errorf("enrichment = %q, want limited", resp.Enrichment)
|
t.Errorf("enrichment = %q, want limited", resp.Enrichment)
|
||||||
}
|
}
|
||||||
|
if rec.Header().Get(dataSourceHeader) != "local" || rec.Header().Get(dataFreshnessHeader) != "limited" {
|
||||||
|
t.Errorf("limited metadata = %q/%q", rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader))
|
||||||
|
}
|
||||||
if resp.DriverNumber != 1 || resp.NameAcronym != "VER" || resp.Points != 25 {
|
if resp.DriverNumber != 1 || resp.NameAcronym != "VER" || resp.Points != 25 {
|
||||||
t.Errorf("local identity/results missing: %+v", resp)
|
t.Errorf("local identity/results missing: %+v", resp)
|
||||||
}
|
}
|
||||||
@@ -289,6 +292,9 @@ func TestHandleDriverSummaryLocalFirstWithFailingEnrichment(t *testing.T) {
|
|||||||
if resp.Enrichment != "limited" {
|
if resp.Enrichment != "limited" {
|
||||||
t.Errorf("enrichment = %q, want limited", resp.Enrichment)
|
t.Errorf("enrichment = %q, want limited", resp.Enrichment)
|
||||||
}
|
}
|
||||||
|
if rec.Header().Get(dataSourceHeader) != "local" || rec.Header().Get(dataFreshnessHeader) != "limited" {
|
||||||
|
t.Errorf("limited metadata = %q/%q", rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader))
|
||||||
|
}
|
||||||
if resp.FullName != "Max Verstappen" {
|
if resp.FullName != "Max Verstappen" {
|
||||||
t.Errorf("full_name = %q, want local identity", resp.FullName)
|
t.Errorf("full_name = %q, want local identity", resp.FullName)
|
||||||
}
|
}
|
||||||
@@ -313,4 +319,7 @@ func TestHandleDriverSummarySourceLocalOnly(t *testing.T) {
|
|||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
if rec.Header().Get(dataSourceHeader) != "local" || rec.Header().Get(dataFreshnessHeader) != "local" {
|
||||||
|
t.Fatalf("local metadata = %q/%q", rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
56
internal/web/freshness.go
Normal file
56
internal/web/freshness.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
dataSourceHeader = "X-BoxBox-Data-Source"
|
||||||
|
dataFreshnessHeader = "X-BoxBox-Data-Freshness"
|
||||||
|
)
|
||||||
|
|
||||||
|
// markOpenF1Response publishes request-scoped success provenance. Callers must
|
||||||
|
// pass the scoped client used for this response, never Server.client.
|
||||||
|
func markOpenF1Response(w http.ResponseWriter, client *api.OpenF1Client) {
|
||||||
|
markOpenF1AggregateResponse(w, client, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func markOpenF1AggregateResponse(w http.ResponseWriter, client *api.OpenF1Client, partial bool) {
|
||||||
|
w.Header().Set(dataSourceHeader, "openf1")
|
||||||
|
if client != nil && client.LastResponseWasStale() {
|
||||||
|
w.Header().Set(dataFreshnessHeader, "stale")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if partial {
|
||||||
|
w.Header().Set(dataFreshnessHeader, "partial")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set(dataFreshnessHeader, "fresh")
|
||||||
|
}
|
||||||
|
|
||||||
|
func markDataResponse(w http.ResponseWriter, source, freshness string) {
|
||||||
|
w.Header().Set(dataSourceHeader, source)
|
||||||
|
w.Header().Set(dataFreshnessHeader, freshness)
|
||||||
|
}
|
||||||
|
|
||||||
|
func markLocalResponse(w http.ResponseWriter, partial bool) {
|
||||||
|
w.Header().Set(dataSourceHeader, "local")
|
||||||
|
if partial {
|
||||||
|
w.Header().Set(dataFreshnessHeader, "partial")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set(dataFreshnessHeader, "local")
|
||||||
|
}
|
||||||
|
|
||||||
|
func markMixedResponse(w http.ResponseWriter, client *api.OpenF1Client, partial bool) {
|
||||||
|
w.Header().Set(dataSourceHeader, "mixed")
|
||||||
|
if client != nil && client.LastResponseWasStale() {
|
||||||
|
w.Header().Set(dataFreshnessHeader, "stale")
|
||||||
|
} else if partial {
|
||||||
|
w.Header().Set(dataFreshnessHeader, "partial")
|
||||||
|
} else {
|
||||||
|
w.Header().Set(dataFreshnessHeader, "local")
|
||||||
|
}
|
||||||
|
}
|
||||||
72
internal/web/freshness_test.go
Normal file
72
internal/web/freshness_test.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/AmanTahiliani/box-box/internal/api"
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOpenF1HandlerReportsFreshThenStaleSuccess(t *testing.T) {
|
||||||
|
year := time.Now().Year()
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = fmt.Fprintf(w, `[{"meeting_key":1,"meeting_name":"British Grand Prix","year":%d}]`, year)
|
||||||
|
}))
|
||||||
|
client := api.NewOpenF1Client(upstream.URL, time.Second)
|
||||||
|
t.Cleanup(func() { _ = client.Close() })
|
||||||
|
server := NewServer(client, 0, nil)
|
||||||
|
cacheKey := fmt.Sprintf("%s/v1/meetings?year=%d", upstream.URL, year)
|
||||||
|
requestURL := fmt.Sprintf("/api/v1/meetings?year=%d&source=openf1", year)
|
||||||
|
defer func() {
|
||||||
|
// Keep the shared application cache clean even if this test fails.
|
||||||
|
db, err := sql.Open("sqlite", api.DefaultCacheDBPath()+"?_busy_timeout=5000")
|
||||||
|
if err == nil {
|
||||||
|
_, _ = db.Exec(`DELETE FROM cache WHERE key = ?`, cacheKey)
|
||||||
|
_ = db.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
fresh := httptest.NewRecorder()
|
||||||
|
server.handleMeetings(fresh, httptest.NewRequest(http.MethodGet, requestURL, nil))
|
||||||
|
if fresh.Code != http.StatusOK || fresh.Header().Get(dataSourceHeader) != "openf1" || fresh.Header().Get(dataFreshnessHeader) != "fresh" {
|
||||||
|
t.Fatalf("fresh response status/metadata = %d %q/%q body=%s", fresh.Code, fresh.Header().Get(dataSourceHeader), fresh.Header().Get(dataFreshnessHeader), fresh.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := sql.Open("sqlite", api.DefaultCacheDBPath()+"?_busy_timeout=5000")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`UPDATE cache SET created_at = ? WHERE key = ?`, time.Now().Add(-48*time.Hour).Unix(), cacheKey); err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = db.Close()
|
||||||
|
upstream.Close()
|
||||||
|
|
||||||
|
stale := httptest.NewRecorder()
|
||||||
|
server.handleMeetings(stale, httptest.NewRequest(http.MethodGet, requestURL, nil))
|
||||||
|
if stale.Code != http.StatusOK || stale.Header().Get(dataSourceHeader) != "openf1" || stale.Header().Get(dataFreshnessHeader) != "stale" {
|
||||||
|
t.Fatalf("stale response status/metadata = %d %q/%q body=%s", stale.Code, stale.Header().Get(dataSourceHeader), stale.Header().Get(dataFreshnessHeader), stale.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFreshnessHeadersAreExposedToBrowserClients(t *testing.T) {
|
||||||
|
handler := withCORS(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
markDataResponse(w, "local", "partial")
|
||||||
|
writeJSON(w, map[string]bool{"ok": true})
|
||||||
|
}))
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/test", nil))
|
||||||
|
|
||||||
|
if got := recorder.Header().Get("Access-Control-Expose-Headers"); got != dataSourceHeader+", "+dataFreshnessHeader {
|
||||||
|
t.Fatalf("exposed headers = %q", got)
|
||||||
|
}
|
||||||
|
if recorder.Header().Get(dataSourceHeader) != "local" || recorder.Header().Get(dataFreshnessHeader) != "partial" {
|
||||||
|
t.Fatalf("data metadata = %q/%q", recorder.Header().Get(dataSourceHeader), recorder.Header().Get(dataFreshnessHeader))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -330,7 +330,15 @@ func cloneLivePositions(in map[string]live.LivePositionData) map[string]live.Liv
|
|||||||
|
|
||||||
// handleLiveState returns the current live data snapshot as JSON.
|
// handleLiveState returns the current live data snapshot as JSON.
|
||||||
func (s *Server) handleLiveState(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleLiveState(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, s.hub.State())
|
state := s.hub.State()
|
||||||
|
freshness := "limited"
|
||||||
|
if state.IsLive {
|
||||||
|
freshness = "live"
|
||||||
|
} else if state.LastSnapshot != nil {
|
||||||
|
freshness = "archive"
|
||||||
|
}
|
||||||
|
markDataResponse(w, "fia", freshness)
|
||||||
|
writeJSON(w, state)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleSSEStream is the persistent SSE endpoint for live data.
|
// handleSSEStream is the persistent SSE endpoint for live data.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (s *Server) handleSeasons(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleSeasons(w http.ResponseWriter, r *http.Request) {
|
||||||
|
markLocalResponse(w, false)
|
||||||
if !s.hasLocalQuery() {
|
if !s.hasLocalQuery() {
|
||||||
writeJSON(w, []int{})
|
writeJSON(w, []int{})
|
||||||
return
|
return
|
||||||
@@ -26,6 +27,7 @@ func (s *Server) handleSeasons(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleWeekend(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleWeekend(w http.ResponseWriter, r *http.Request) {
|
||||||
|
markLocalResponse(w, false)
|
||||||
meetingKey, err := strconv.Atoi(r.URL.Query().Get("meeting_key"))
|
meetingKey, err := strconv.Atoi(r.URL.Query().Get("meeting_key"))
|
||||||
if err != nil || meetingKey == 0 {
|
if err != nil || meetingKey == 0 {
|
||||||
http.Error(w, "meeting_key required", http.StatusBadRequest)
|
http.Error(w, "meeting_key required", http.StatusBadRequest)
|
||||||
@@ -46,5 +48,6 @@ func (s *Server) handleWeekend(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, err, http.StatusInternalServerError, false)
|
writeError(w, err, http.StatusInternalServerError, false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markLocalResponse(w, weekend.Source == query.ResponseSourcePartial)
|
||||||
writeJSON(w, weekend)
|
writeJSON(w, weekend)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ func (s *Server) handleRaceHub(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !s.hasLocalQuery() {
|
if !s.hasLocalQuery() {
|
||||||
|
markDataResponse(w, "local", "limited")
|
||||||
writeJSON(w, emptyRaceHub(sessionKey))
|
writeJSON(w, emptyRaceHub(sessionKey))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -26,6 +27,7 @@ func (s *Server) handleRaceHub(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, err, http.StatusInternalServerError, false)
|
writeError(w, err, http.StatusInternalServerError, false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markLocalResponse(w, hub.Source == query.ResponseSourcePartial)
|
||||||
writeJSON(w, hub)
|
writeJSON(w, hub)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,9 @@ func TestHandleRaceHubWithoutStore(t *testing.T) {
|
|||||||
if hub.Source != query.ResponseSourceNone {
|
if hub.Source != query.ResponseSourceNone {
|
||||||
t.Fatalf("source = %q, want %q", hub.Source, query.ResponseSourceNone)
|
t.Fatalf("source = %q, want %q", hub.Source, query.ResponseSourceNone)
|
||||||
}
|
}
|
||||||
|
if rec.Header().Get(dataSourceHeader) != "local" || rec.Header().Get(dataFreshnessHeader) != "limited" {
|
||||||
|
t.Fatalf("missing hub metadata = %q/%q", rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader))
|
||||||
|
}
|
||||||
if hub.Datasets["session"].Status != query.DatasetStatusMissing {
|
if hub.Datasets["session"].Status != query.DatasetStatusMissing {
|
||||||
t.Fatalf("session dataset = %+v, want missing", hub.Datasets["session"])
|
t.Fatalf("session dataset = %+v, want missing", hub.Datasets["session"])
|
||||||
}
|
}
|
||||||
@@ -117,6 +120,12 @@ func TestHandleRaceHubWithLocalData(t *testing.T) {
|
|||||||
if hub.Datasets["results"].Status != query.DatasetStatusMissing {
|
if hub.Datasets["results"].Status != query.DatasetStatusMissing {
|
||||||
t.Fatalf("results dataset = %+v, want missing", hub.Datasets["results"])
|
t.Fatalf("results dataset = %+v, want missing", hub.Datasets["results"])
|
||||||
}
|
}
|
||||||
|
if hub.Source != query.ResponseSourcePartial {
|
||||||
|
t.Fatalf("source = %q, want partial", hub.Source)
|
||||||
|
}
|
||||||
|
if rec.Header().Get(dataSourceHeader) != "local" || rec.Header().Get(dataFreshnessHeader) != "partial" {
|
||||||
|
t.Fatalf("partial hub metadata = %q/%q", rec.Header().Get(dataSourceHeader), rec.Header().Get(dataFreshnessHeader))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleRaceHubIncludesChapters(t *testing.T) {
|
func TestHandleRaceHubIncludesChapters(t *testing.T) {
|
||||||
|
|||||||
@@ -61,11 +61,13 @@ func (s *Server) handleReplayFrames(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := assembleReplayFrames(r.Context(), s.client, sessionKey, intervalMS)
|
client := s.client.Scoped()
|
||||||
|
resp, err := assembleReplayFrames(r.Context(), client, sessionKey, intervalMS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, err, http.StatusInternalServerError, s.client.LastResponseWasStale())
|
writeError(w, err, http.StatusInternalServerError, client.LastResponseWasStale())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
markOpenF1Response(w, client)
|
||||||
writeJSON(w, resp)
|
writeJSON(w, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ func withCORS(next http.Handler) http.Handler {
|
|||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
w.Header().Set("Access-Control-Expose-Headers", dataSourceHeader+", "+dataFreshnessHeader)
|
||||||
if r.Method == http.MethodOptions {
|
if r.Method == http.MethodOptions {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user