aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMartin Polden <mpolden@mpolden.no>2021-08-11 22:03:18 +0200
committerMartin Polden <mpolden@mpolden.no>2021-08-11 22:34:12 +0200
commitd3136e80ac76e9e4d0ff06179861308eaeb48831 (patch)
treed131f77a6d1c0b41c22103d96c4905f3d187fba0
parentef9d69d86c2ec40055c759dfde67b88fcf93db64 (diff)
http: Implement /api/v2 using Entur
-rw-r--r--atb/atb.go8
-rw-r--r--cmd/atb/main.go7
-rw-r--r--http/http.go73
-rw-r--r--http/http_test.go135
-rw-r--r--http/types.go29
5 files changed, 177 insertions, 75 deletions
diff --git a/atb/atb.go b/atb/atb.go
index c627e6a..eb629a4 100644
--- a/atb/atb.go
+++ b/atb/atb.go
@@ -75,19 +75,19 @@ type forecastRequest struct {
}
// NewFromConfig creates a new client where name is the path to the config file.
-func NewFromConfig(name string) (Client, error) {
+func NewFromConfig(name string) (*Client, error) {
data, err := ioutil.ReadFile(name)
if err != nil {
- return Client{}, err
+ return nil, err
}
var client Client
if err := json.Unmarshal(data, &client); err != nil {
- return Client{}, err
+ return nil, err
}
if client.URL == "" {
client.URL = DefaultURL
}
- return client, nil
+ return &client, nil
}
func (c *Client) postXML(body string, dst interface{}) error {
diff --git a/cmd/atb/main.go b/cmd/atb/main.go
index 5fa11bf..7b7e131 100644
--- a/cmd/atb/main.go
+++ b/cmd/atb/main.go
@@ -6,6 +6,7 @@ import (
"time"
"github.com/mpolden/atb/atb"
+ "github.com/mpolden/atb/entur"
"github.com/mpolden/atb/http"
)
@@ -30,12 +31,12 @@ func main() {
cors := flag.Bool("x", false, "Allow requests from other domains")
flag.Parse()
- client, err := atb.NewFromConfig(*config)
+ atb, err := atb.NewFromConfig(*config)
if err != nil {
log.Fatal(err)
}
-
- server := http.New(client, mustParseDuration(*stopTTL), mustParseDuration(*departureTTL), *cors)
+ entur := entur.New("")
+ server := http.New(atb, entur, mustParseDuration(*stopTTL), mustParseDuration(*departureTTL), *cors)
log.Printf("Listening on %s", *listen)
if err := server.ListenAndServe(*listen); err != nil {
diff --git a/http/http.go b/http/http.go
index b69accb..b47d755 100644
--- a/http/http.go
+++ b/http/http.go
@@ -12,13 +12,15 @@ import (
"github.com/mpolden/atb/atb"
"github.com/mpolden/atb/cache"
+ "github.com/mpolden/atb/entur"
)
// Server represents an Server server.
type Server struct {
- Client atb.Client
- CORS bool
- cache *cache.Cache
+ ATB *atb.Client
+ Entur *entur.Client
+ CORS bool
+ cache *cache.Cache
ttl
}
@@ -50,7 +52,7 @@ func (s *Server) getBusStops(urlPrefix string) (BusStops, bool, error) {
if hit {
return cached.(BusStops), hit, nil
}
- atbBusStops, err := s.Client.BusStops()
+ atbBusStops, err := s.ATB.BusStops()
if err != nil {
return BusStops{}, hit, err
}
@@ -71,13 +73,13 @@ func (s *Server) getBusStops(urlPrefix string) (BusStops, bool, error) {
return busStops, hit, nil
}
-func (s *Server) getDepartures(urlPrefix string, nodeID int) (Departures, bool, error) {
+func (s *Server) atbDepartures(urlPrefix string, nodeID int) (Departures, bool, error) {
cacheKey := strconv.Itoa(nodeID)
cached, hit := s.cache.Get(cacheKey)
if hit {
return cached.(Departures), hit, nil
}
- forecasts, err := s.Client.Forecasts(nodeID)
+ forecasts, err := s.ATB.Forecasts(nodeID)
if err != nil {
return Departures{}, hit, err
}
@@ -90,6 +92,22 @@ func (s *Server) getDepartures(urlPrefix string, nodeID int) (Departures, bool,
return departures, hit, nil
}
+func (s *Server) enturDepartures(urlPrefix string, stopID int) (Departures, bool, error) {
+ cacheKey := strconv.Itoa(stopID)
+ cached, hit := s.cache.Get(cacheKey)
+ if hit {
+ return cached.(Departures), hit, nil
+ }
+ enturDepartures, err := s.Entur.Departures(stopID)
+ if err != nil {
+ return Departures{}, hit, err
+ }
+ departures := convertDepartures(enturDepartures)
+ departures.URL = fmt.Sprintf("%s/api/v2/departures/%d", urlPrefix, stopID)
+ s.cache.Set(cacheKey, departures, s.ttl.departures)
+ return departures, hit, nil
+}
+
func (s *Server) setCacheHeader(w http.ResponseWriter, hit bool) {
v := "MISS"
if hit {
@@ -174,7 +192,29 @@ func (s *Server) DepartureHandler(w http.ResponseWriter, r *http.Request) (inter
Message: "Unknown bus stop",
}
}
- departures, hit, err := s.getDepartures(urlPrefix(r), nodeID)
+ departures, hit, err := s.atbDepartures(urlPrefix(r), nodeID)
+ if err != nil {
+ return nil, &Error{
+ err: err,
+ Status: http.StatusInternalServerError,
+ Message: "Failed to get departures from AtB",
+ }
+ }
+ s.setCacheHeader(w, hit)
+ return departures, nil
+}
+
+// DepartureHandlerV2 is a handler which retrieves departures for a given bus stop through Entur.
+func (s *Server) DepartureHandlerV2(w http.ResponseWriter, r *http.Request) (interface{}, *Error) {
+ stopID, err := strconv.Atoi(filepath.Base(r.URL.Path))
+ if err != nil {
+ return nil, &Error{
+ err: err,
+ Status: http.StatusBadRequest,
+ Message: "Invalid stop ID. Use https://stoppested.entur.org/ to find stop IDs.",
+ }
+ }
+ departures, hit, err := s.enturDepartures(urlPrefix(r), stopID)
if err != nil {
return nil, &Error{
err: err,
@@ -215,21 +255,23 @@ func (s *Server) DefaultHandler(w http.ResponseWriter, r *http.Request) (interfa
prefix := urlPrefix(r)
busStopsURL := fmt.Sprintf("%s/api/v1/busstops", prefix)
departuresURL := fmt.Sprintf("%s/api/v1/departures", prefix)
+ departuresV2URL := fmt.Sprintf("%s/api/v2/departures", prefix)
return struct {
URLs []string `json:"urls"`
}{
- []string{busStopsURL, departuresURL},
+ []string{busStopsURL, departuresURL, departuresV2URL},
}, nil
}
-// New returns a new Server using client to communicate with AtB. stopTTL and departureTTL control the cache TTL bus
-// stops and departures.
-func New(client atb.Client, stopTTL, departureTTL time.Duration, cors bool) Server {
+// New returns a new Server using given clients to communicate with AtB and Entur. stopTTL and departureTTL control the
+// cache TTL bus stops and departures.
+func New(atb *atb.Client, entur *entur.Client, stopTTL, departureTTL time.Duration, cors bool) *Server {
cache := cache.New(time.Minute)
- return Server{
- Client: client,
- CORS: cors,
- cache: cache,
+ return &Server{
+ ATB: atb,
+ Entur: entur,
+ CORS: cors,
+ cache: cache,
ttl: ttl{
stops: stopTTL,
departures: departureTTL,
@@ -279,6 +321,7 @@ func (s *Server) Handler() http.Handler {
mux.Handle("/api/v1/busstops/", appHandler(s.BusStopHandler))
mux.Handle("/api/v1/departures", appHandler(s.DeparturesHandler))
mux.Handle("/api/v1/departures/", appHandler(s.DepartureHandler))
+ mux.Handle("/api/v2/departures/", appHandler(s.DepartureHandlerV2))
mux.Handle("/", appHandler(s.DefaultHandler))
return requestFilter(mux, s.CORS)
}
diff --git a/http/http_test.go b/http/http_test.go
index c8d2293..7df36ea 100644
--- a/http/http_test.go
+++ b/http/http_test.go
@@ -12,22 +12,29 @@ import (
"time"
"github.com/mpolden/atb/atb"
+ "github.com/mpolden/atb/entur"
)
-func atbTestServer() *httptest.Server {
+func apiTestServer() *httptest.Server {
handler := func(w http.ResponseWriter, r *http.Request) {
- b, err := ioutil.ReadAll(r.Body)
- if err != nil {
- panic(err)
- }
- xml := string(b)
- w.Header().Set("Content-Type", "application/soap+xml; charset=utf-8")
- if strings.Contains(xml, "GetBusStopsList") {
- fmt.Fprint(w, busStopsResponse)
- } else if strings.Contains(xml, "getUserRealTimeForecastByStop") {
- fmt.Fprint(w, forecastResponse)
+ isSOAP := r.Header.Get("Content-Type") == "application/soap+xml"
+ if isSOAP {
+ b, err := ioutil.ReadAll(r.Body)
+ if err != nil {
+ panic(err)
+ }
+ xml := string(b)
+ w.Header().Set("Content-Type", "application/soap+xml; charset=utf-8")
+ if strings.Contains(xml, "GetBusStopsList") {
+ fmt.Fprint(w, busStopsResponse)
+ } else if strings.Contains(xml, "getUserRealTimeForecastByStop") {
+ fmt.Fprint(w, forecastResponse)
+ } else {
+ panic("unknown request body: " + xml)
+ }
} else {
- panic("unknown request body: " + xml)
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ fmt.Fprint(w, enturResponse)
}
}
mux := http.NewServeMux()
@@ -35,11 +42,12 @@ func atbTestServer() *httptest.Server {
return httptest.NewServer(mux)
}
-func testServers() (*httptest.Server, *httptest.Server) {
- atbServer := atbTestServer()
- atb := atb.Client{URL: atbServer.URL}
- api := New(atb, 168*time.Hour, 1*time.Minute, false)
- return atbServer, httptest.NewServer(api.Handler())
+func testServers() (*httptest.Server, *Server) {
+ apiServer := apiTestServer()
+ atb := &atb.Client{URL: apiServer.URL}
+ entur := &entur.Client{URL: apiServer.URL}
+ server := New(atb, entur, 168*time.Hour, 1*time.Minute, false)
+ return apiServer, server
}
func httpGet(url string) (string, string, int, error) {
@@ -56,9 +64,10 @@ func httpGet(url string) (string, string, int, error) {
}
func TestAPI(t *testing.T) {
- atbServer, server := testServers()
- defer atbServer.Close()
- defer server.Close()
+ apiServer, server := testServers()
+ httpSrv := httptest.NewServer(server.Handler())
+ defer apiServer.Close()
+ defer httpSrv.Close()
log.SetOutput(ioutil.Discard)
var tests = []struct {
@@ -69,24 +78,27 @@ func TestAPI(t *testing.T) {
// Unknown resources
{"/not-found", `{"status":404,"message":"Resource not found"}`, 404},
// List know URLs
- {"/", fmt.Sprintf(`{"urls":["%s/api/v1/busstops","%s/api/v1/departures"]}`, server.URL, server.URL), 200},
+ {"/", fmt.Sprintf(`{"urls":["%s/api/v1/busstops","%s/api/v1/departures","%s/api/v2/departures"]}`, httpSrv.URL, httpSrv.URL, httpSrv.URL), 200},
// List all bus stops
- {"/api/v1/busstops", fmt.Sprintf(`{"stops":[{"url":"%s/api/v1/busstops/16011376","stopId":100633,"nodeId":16011376,"description":"Prof. Brochs gt","longitude":10.398126,"latitude":63.415535,"mobileCode":"16011376 (Prof.)","mobileName":"Prof. (16011376)"}]}`, server.URL), 200},
+ {"/api/v1/busstops", fmt.Sprintf(`{"stops":[{"url":"%s/api/v1/busstops/16011376","stopId":100633,"nodeId":16011376,"description":"Prof. Brochs gt","longitude":10.398126,"latitude":63.415535,"mobileCode":"16011376 (Prof.)","mobileName":"Prof. (16011376)"}]}`, httpSrv.URL), 200},
// List all departures
- {"/api/v1/departures", fmt.Sprintf(`{"urls":["%s/api/v1/departures/16011376"]}`, server.URL), 200},
+ {"/api/v1/departures", fmt.Sprintf(`{"urls":["%s/api/v1/departures/16011376"]}`, httpSrv.URL), 200},
// Show specific bus stop
{"/api/v1/busstops/", `{"status":400,"message":"Invalid nodeID"}`, 400},
{"/api/v1/busstops/foo", `{"status":400,"message":"Invalid nodeID"}`, 400},
{"/api/v1/busstops/42", `{"status":404,"message":"Unknown bus stop"}`, 404},
- {"/api/v1/busstops/16011376", fmt.Sprintf(`{"url":"%s/api/v1/busstops/16011376","stopId":100633,"nodeId":16011376,"description":"Prof. Brochs gt","longitude":10.398126,"latitude":63.415535,"mobileCode":"16011376 (Prof.)","mobileName":"Prof. (16011376)"}`, server.URL), 200},
+ {"/api/v1/busstops/16011376", fmt.Sprintf(`{"url":"%s/api/v1/busstops/16011376","stopId":100633,"nodeId":16011376,"description":"Prof. Brochs gt","longitude":10.398126,"latitude":63.415535,"mobileCode":"16011376 (Prof.)","mobileName":"Prof. (16011376)"}`, httpSrv.URL), 200},
// Show specific departure
{"/api/v1/departures/", `{"status":400,"message":"Invalid nodeID"}`, 400},
{"/api/v1/departures/foo", `{"status":400,"message":"Invalid nodeID"}`, 400},
{"/api/v1/departures/42", `{"status":404,"message":"Unknown bus stop"}`, 404},
- {"/api/v1/departures/16011376", fmt.Sprintf(`{"url":"%s/api/v1/departures/16011376","isGoingTowardsCentrum":true,"departures":[{"line":"6","registeredDepartureTime":"2015-02-26T18:38:00.000","scheduledDepartureTime":"2015-02-26T18:01:00.000","destination":"Munkegata M5","isRealtimeData":true}]}`, server.URL), 200},
+ {"/api/v1/departures/16011376", fmt.Sprintf(`{"url":"%s/api/v1/departures/16011376","isGoingTowardsCentrum":true,"departures":[{"line":"6","registeredDepartureTime":"2015-02-26T18:38:00.000","scheduledDepartureTime":"2015-02-26T18:01:00.000","destination":"Munkegata M5","isRealtimeData":true}]}`, httpSrv.URL), 200},
+ // Show specific departure (v2)
+ {"/api/v2/departures/", `{"status":400,"message":"Invalid stop ID. Use https://stoppested.entur.org/ to find stop IDs."}`, 400},
+ {"/api/v2/departures/42098", fmt.Sprintf(`{"url":"%s/api/v2/departures/42098","isGoingTowardsCentrum":false,"departures":[{"line":"21","scheduledDepartureTime":"2021-08-11T21:19:00.000","destination":"Pirbadet via sentrum","isRealtimeData":false}]}`, httpSrv.URL), 200},
}
for _, tt := range tests {
- data, contentType, status, err := httpGet(server.URL + tt.url)
+ data, contentType, status, err := httpGet(httpSrv.URL + tt.url)
if err != nil {
t.Fatal(err)
}
@@ -110,8 +122,8 @@ func TestURLPrefix(t *testing.T) {
{&http.Request{Host: "foo"}, "http://foo"},
{&http.Request{Host: "", RemoteAddr: "127.0.0.1"}, "http://127.0.0.1"},
{&http.Request{Host: "bar", TLS: &tls.ConnectionState{}}, "https://bar"},
- {&http.Request{Host: "baz", Header: map[string][]string{"X-Forwarded-Proto": []string{"https"}}}, "https://baz"},
- {&http.Request{Host: "qux", Header: map[string][]string{"X-Forwarded-Proto": []string{}}}, "http://qux"},
+ {&http.Request{Host: "baz", Header: map[string][]string{"X-Forwarded-Proto": {"https"}}}, "https://baz"},
+ {&http.Request{Host: "qux", Header: map[string][]string{"X-Forwarded-Proto": {}}}, "http://qux"},
}
for _, tt := range tests {
prefix := urlPrefix(tt.in)
@@ -122,15 +134,13 @@ func TestURLPrefix(t *testing.T) {
}
func TestGetBusStops(t *testing.T) {
- server := atbTestServer()
- defer server.Close()
- atb := atb.Client{URL: server.URL}
- api := New(atb, 168*time.Hour, 1*time.Minute, false)
- _, _, err := api.getBusStops("")
+ apiServer, server := testServers()
+ defer apiServer.Close()
+ _, _, err := server.getBusStops("")
if err != nil {
t.Fatal(err)
}
- cached, ok := api.cache.Get("stops")
+ cached, ok := server.cache.Get("stops")
if !ok {
t.Fatal("Expected true")
}
@@ -147,18 +157,16 @@ func TestGetBusStops(t *testing.T) {
}
func TestGetBusStopsCache(t *testing.T) {
- server := atbTestServer()
- defer server.Close()
- atb := atb.Client{URL: server.URL}
- api := New(atb, 168*time.Hour, 1*time.Minute, false)
- _, hit, err := api.getBusStops("")
+ apiServer, server := testServers()
+ defer apiServer.Close()
+ _, hit, err := server.getBusStops("")
if err != nil {
t.Fatal(err)
}
if hit {
t.Error("Expected false")
}
- _, hit, err = api.getBusStops("")
+ _, hit, err = server.getBusStops("")
if err != nil {
t.Fatal(err)
}
@@ -168,15 +176,13 @@ func TestGetBusStopsCache(t *testing.T) {
}
func TestGetDepartures(t *testing.T) {
- server := atbTestServer()
- defer server.Close()
- atb := atb.Client{URL: server.URL}
- api := New(atb, 168*time.Hour, 1*time.Minute, false)
- _, _, err := api.getDepartures("", 16011376)
+ apiServer, server := testServers()
+ defer apiServer.Close()
+ _, _, err := server.atbDepartures("", 16011376)
if err != nil {
t.Fatal(err)
}
- cached, ok := api.cache.Get("16011376")
+ cached, ok := server.cache.Get("16011376")
if !ok {
t.Fatal("Expected true")
}
@@ -190,18 +196,16 @@ func TestGetDepartures(t *testing.T) {
}
func TestGetDeparturesCache(t *testing.T) {
- server := atbTestServer()
- defer server.Close()
- atb := atb.Client{URL: server.URL}
- api := New(atb, 168*time.Hour, 1*time.Minute, false)
- _, hit, err := api.getDepartures("", 16011376)
+ apiServer, server := testServers()
+ defer apiServer.Close()
+ _, hit, err := server.atbDepartures("", 16011376)
if err != nil {
t.Fatal(err)
}
if hit {
t.Error("Expected false")
}
- _, hit, err = api.getDepartures("", 16011376)
+ _, hit, err = server.atbDepartures("", 16011376)
if err != nil {
t.Fatal(err)
}
@@ -273,3 +277,30 @@ const forecastResponse = `<?xml version="1.0" encoding="utf-8"?>
</getUserRealTimeForecastByStopResponse>
</soap12:Body>
</soap12:Envelope>`
+
+const enturResponse = `{
+ "data": {
+ "stopPlace": {
+ "id": "NSR:StopPlace:42098",
+ "name": "Ilsvika",
+ "estimatedCalls": [
+ {
+ "realtime": false,
+ "expectedDepartureTime": "2021-08-11T21:19:00+0200",
+ "actualDepartureTime": null,
+ "destinationDisplay": {
+ "frontText": "Pirbadet via sentrum"
+ },
+ "serviceJourney": {
+ "journeyPattern": {
+ "directionType": "outbound",
+ "line": {
+ "publicCode": "21"
+ }
+ }
+ }
+ }
+ ]
+ }
+ }
+}`
diff --git a/http/types.go b/http/types.go
index 17d46cf..d08f969 100644
--- a/http/types.go
+++ b/http/types.go
@@ -7,6 +7,7 @@ import (
"time"
"github.com/mpolden/atb/atb"
+ "github.com/mpolden/atb/entur"
)
// BusStops represents a list of bus stops.
@@ -56,7 +57,7 @@ type Departures struct {
// Departure represents a single departure in a given direction.
type Departure struct {
LineID string `json:"line"`
- RegisteredDepartureTime string `json:"registeredDepartureTime"`
+ RegisteredDepartureTime string `json:"registeredDepartureTime,omitempty"`
ScheduledDepartureTime string `json:"scheduledDepartureTime"`
Destination string `json:"destination"`
IsRealtimeData bool `json:"isRealtimeData"`
@@ -183,6 +184,32 @@ func convertForecasts(f atb.Forecasts) (Departures, error) {
}, nil
}
+func convertDepartures(enturDepartures []entur.Departure) Departures {
+ departures := make([]Departure, 0, len(enturDepartures))
+ inbound := false
+ const timeLayout = "2006-01-02T15:04:05.000"
+ for _, d := range enturDepartures {
+ scheduledDepartureTime := d.ScheduledDepartureTime.Format(timeLayout)
+ registeredDepartureTime := ""
+ if !d.RegisteredDepartureTime.IsZero() {
+ registeredDepartureTime = d.RegisteredDepartureTime.Format(timeLayout)
+ }
+ departure := Departure{
+ LineID: d.Line,
+ ScheduledDepartureTime: scheduledDepartureTime,
+ RegisteredDepartureTime: registeredDepartureTime,
+ Destination: d.Destination,
+ IsRealtimeData: d.IsRealtime,
+ }
+ inbound = d.Inbound
+ departures = append(departures, departure)
+ }
+ return Departures{
+ TowardsCentrum: inbound,
+ Departures: departures,
+ }
+}
+
// GeoJSON converts BusStop into the GeoJSON format.
func (s *BusStop) GeoJSON() GeoJSON {
geometry := Geometry{