vrnp/main.go

216 lines
5.2 KiB
Go

package main
import "cmp"
import "encoding/json"
import "errors"
import "fmt"
import "log"
import "net/http"
import "os"
import "slices"
import "strings"
import "strconv"
import "sync/atomic"
import "time"
// JSON unmarshaling types for departure monitor API
type DMResponse struct {
DateTime DMDateTime `json:"dateTime"`
DepartureList []DMDeparture `json:"departureList"`
}
type DMDeparture struct {
StopName string `json:"stopName"`
Platform string `json:"platform"`
Countdown string `json:"countdown"`
DateTime DMDateTime `json:"dateTime"`
RealDateTime *DMDateTime `json:"realDateTime"`
ServingLine DMServingLine `json:"servingLine"`
OnwardStopSeq []DMStop `json:"onwardStopSeq"`
}
type DMServingLine struct {
Symbol string `json:"symbol"`
Direction string `json:"direction"`
}
type DMStop struct {
PlaceID string `json:"placeID"`
Place string `json:"place"`
NameWO string `json:"nameWO"`
}
type DMDateTime struct {
Hour string `json:"hour"`
Minute string `json:"minute"`
}
func FetchDepartures(c EFAClient, stopId string) (DMResponse, error) {
req, err := c.BuildRequest(stopId)
if err != nil {
return DMResponse{}, err
}
// Send the request, wait max 10 seconds
client := http.Client{
Timeout: 10 * time.Second,
}
res, err := client.Do(req)
if err != nil {
return DMResponse{}, err
}
defer res.Body.Close()
var dmResponse DMResponse
err = json.NewDecoder(res.Body).Decode(&dmResponse)
if err != nil {
return DMResponse{}, err
}
return dmResponse, nil
}
// Types for JSON marshaling
type Departures struct {
Departures []Departure `json:"departures"`
ServerTime string `json:"serverTime"`
}
type Departure struct {
Platform string `json:"platform"`
Symbol string `json:"symbol"`
Direction string `json:"direction"`
Leaving string `json:"leaving"`
Countdown int `json:"-"`
}
func ParseDepartures(response DMResponse, allowedPlatform *string) (Departures, error) {
var ds []Departure
for _, d := range response.DepartureList {
direction := d.ServingLine.Direction
// If available, use name of last stop as direction
// Note that OnwardStopSeq is only populated if includeCompleteStopSeq=1 is set.
if len(d.OnwardStopSeq) != 0 {
last := d.OnwardStopSeq[len(d.OnwardStopSeq)-1]
if slices.Contains([]string{"5", "6"}, last.PlaceID) {
direction = last.NameWO
}
}
leaving := fmt.Sprintf("%s min", d.Countdown)
countdown, err := strconv.Atoi(d.Countdown)
if err != nil {
return Departures{}, errors.New("could not parse countdown")
}
matchesPlatform := allowedPlatform == nil || d.Platform == *allowedPlatform
isInThePast := countdown < 0
if !matchesPlatform || isInThePast {
continue
}
if countdown == 0 {
leaving = "sofort"
}
if countdown > 20 {
dt := d.DateTime
if d.RealDateTime != nil {
dt = *d.RealDateTime
}
leaving = fmt.Sprintf("%02s:%02s", dt.Hour, dt.Minute)
}
ds = append(ds, Departure{
d.Platform,
d.ServingLine.Symbol,
direction,
leaving,
countdown,
})
}
slices.SortFunc(ds, func(a Departure, b Departure) int {
return cmp.Compare(a.Countdown, b.Countdown)
})
dt := response.DateTime
return Departures{
ds,
fmt.Sprintf("%02s:%02s", dt.Hour, dt.Minute),
}, nil
}
func main() {
password := os.Getenv("VRNP_PASSWORD")
if len(password) == 0 {
log.Fatal("Required environment variable VRNP_PASSWORD is not set")
}
clientAllowlist := strings.Split(os.Getenv("VRNP_CLIENT_ALLOWLIST"), ",")
// Use round-robin to send incoming requests to different servers
var efaClient atomic.Uint64
var efaClients []EFAClient
for _, efaClient := range allEfaClients {
if len(clientAllowlist) != 0 && slices.Contains(clientAllowlist, efaClient.GetName()) {
efaClients = append(efaClients, efaClient)
}
}
http.HandleFunc("/departures", func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !(ok && user == "admin" && pass == password) {
w.Header().Set("WWW-Authenticate", "Basic realm=\"Access to departure API\"")
http.Error(w, "You shall not pass", http.StatusUnauthorized)
return
}
query := r.URL.Query()
if query["stop_id"] == nil || len(query["stop_id"]) < 1 {
http.Error(w, "Missing query parameter: stop_id", http.StatusBadRequest)
return
}
stopId := query["stop_id"][0]
var platform *string = nil
if query["platform"] != nil && len(query["platform"]) != 0 {
platform = &query["platform"][0]
}
c := efaClients[(efaClient.Add(1) - 1) % uint64(len(efaClients))]
ds, err := FetchDepartures(c, stopId)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
departures, err := ParseDepartures(ds, platform)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Does not handle multiple media types
if r.Header.Get("Accept") == "application/json" {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(departures)
} else {
// plain text
for _, d := range departures.Departures {
fmt.Fprintf(w, "%2s %-11s %6s\n",
d.Symbol,
d.Direction[:min(11, len(d.Direction))],
d.Leaving,
)
}
}
return
})
log.Fatal(http.ListenAndServe(":8000", nil))
}