Repository navigation
Expand file tree
/
Copy pathmain.go
More file actions
160 lines (147 loc) · 4.8 KB
/
Copy pathmain.go
File metadata and controls
160 lines (147 loc) · 4.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"regexp"
"strings"
"sync"
"time"
"github.com/labstack/echo/v4"
"github.com/maris/paddock-api/internal/trackmap"
_ "time/tzdata"
)
const (
defaultErgastBase = "https://api.jolpi.ca/ergast/f1"
defaultOpenF1Base = "https://api.openf1.org/v1"
defaultGeometryBase = trackmap.DefaultGeometryBase
defaultExpire = time.Hour
)
type config struct {
timezone *time.Location
timezoneID string
eventDetail string
trackColour string
ergastBase string
openF1Base string
geometryBase string
staticMapDir string
}
type app struct {
config config
client *http.Client
cache *cacheStore
now func() time.Time
log *slog.Logger
openF1Gap time.Duration
openF1Mu sync.Mutex
openF1Next time.Time
}
func loadConfig() (config, error) {
tz := strings.TrimSpace(os.Getenv("TIMEZONE"))
if tz == "" {
return config{}, errors.New("TIMEZONE is required")
}
loc, err := time.LoadLocation(tz)
if err != nil {
return config{}, errors.New("invalid TIMEZONE")
}
detail := strings.TrimSpace(os.Getenv("EVENT_DETAIL"))
if detail == "" {
detail = "main"
}
if detail != "main" && detail != "race" && detail != "detailed" {
return config{}, errors.New("EVENT_DETAIL must be main, race, or detailed")
}
colour := strings.TrimSpace(os.Getenv("TRACK_COLOUR"))
if !regexp.MustCompile(`^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`).MatchString(colour) {
return config{}, errors.New("TRACK_COLOUR must be a hex colour")
}
return config{
timezone: loc, timezoneID: tz, eventDetail: detail, trackColour: colour,
ergastBase: envOr("ERGAST_BASE_URL", defaultErgastBase), openF1Base: envOr("OPENF1_BASE_URL", defaultOpenF1Base),
geometryBase: envOr("GEOMETRY_BASE_URL", defaultGeometryBase), staticMapDir: envOr("STATIC_MAP_DIR", "static/track_maps"),
}, nil
}
func envOr(name, fallback string) string {
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
return strings.TrimRight(value, "/")
}
return fallback
}
func (a *app) fetchJSON(url string, target any) error {
response, err := a.client.Get(url)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
return fmt.Errorf("HTTP %d: %s", response.StatusCode, strings.TrimSpace(string(body)))
}
decoder := json.NewDecoder(response.Body)
decoder.UseNumber()
return decoder.Decode(target)
}
// fetchOpenF1 spaces calls out: OpenF1's free tier allows 3 req/s and widgets
// load together.
func (a *app) fetchOpenF1(url string, target any) error {
a.openF1Mu.Lock()
if wait := time.Until(a.openF1Next); wait > 0 {
time.Sleep(wait)
}
a.openF1Next = time.Now().Add(a.openF1Gap)
a.openF1Mu.Unlock()
return a.fetchJSON(url, target)
}
// keep stores data that never changes once it exists (see setDurable).
func (a *app) keep(key string, value any, now time.Time) {
if err := a.cache.setDurable(key, value, now); err != nil {
a.log.Warn("could not persist cache entry", "key", key, "error", err)
}
}
func newServer(a *app) *echo.Echo {
e := echo.New()
e.HideBanner, e.HidePort = true, true
e.HTTPErrorHandler = func(err error, c echo.Context) {
if !c.Response().Committed {
_ = c.JSON(http.StatusInternalServerError, map[string]string{"error": "Internal Server Error"})
}
a.log.Error("request failed", "error", err)
}
routes := []struct {
path string
handler echo.HandlerFunc
}{{"/f1/drivers_standings", a.drivers}, {"/f1/constructors_standings", a.constructors}, {"/f1/last_race", a.lastRace}, {"/f1/next_race", a.nextRace}, {"/f1/tyre_usage", a.tyreUsage}, {"/f1/latest_session", a.latestSession}, {"/f1/next_map", a.nextMap}}
for _, route := range routes {
e.GET(route.path, route.handler)
e.GET(route.path+"/", route.handler)
}
// Liveness only - never touches upstream APIs, so an Ergast/OpenF1 outage doesn't restart the pod.
e.GET("/healthz", func(c echo.Context) error { return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) })
return e
}
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
cfg, err := loadConfig()
if err != nil {
logger.Error("invalid configuration", "error", err)
os.Exit(1)
}
a := &app{config: cfg, client: &http.Client{Timeout: 15 * time.Second}, cache: newCache(envOr("CACHE_DIR", "")), now: time.Now, log: logger, openF1Gap: 400 * time.Millisecond}
if a.cache.dir != "" {
if err := a.cache.probe(); err != nil {
logger.Warn("CACHE_DIR is not writable, finished-session data will not survive restarts", "dir", a.cache.dir, "error", err)
}
}
port := envOr("PORT", "4463")
logger.Info("server starting", "port", port)
if err := newServer(a).Start(":" + port); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("server stopped", "error", err)
os.Exit(1)
}
}