diff options
author | Martin Polden <mpolden@mpolden.no> | 2021-08-11 22:03:18 +0200 |
---|---|---|
committer | Martin Polden <mpolden@mpolden.no> | 2021-08-11 22:34:12 +0200 |
commit | d3136e80ac76e9e4d0ff06179861308eaeb48831 (patch) | |
tree | d131f77a6d1c0b41c22103d96c4905f3d187fba0 | |
parent | ef9d69d86c2ec40055c759dfde67b88fcf93db64 (diff) |
http: Implement /api/v2 using Entur
-rw-r--r-- | atb/atb.go | 8 | ||||
-rw-r--r-- | cmd/atb/main.go | 7 | ||||
-rw-r--r-- | http/http.go | 73 | ||||
-rw-r--r-- | http/http_test.go | 135 | ||||
-rw-r--r-- | http/types.go | 29 |
5 files changed, 177 insertions, 75 deletions
@@ -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{ |