216 lines
		
	
	
		
			5.2 KiB
		
	
	
	
		
			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))
 | |
| }
 |