Skip to content

Commit

Permalink
ETAs Service (micro#31)
Browse files Browse the repository at this point in the history
  • Loading branch information
ben-toogood authored Dec 15, 2020
1 parent 47f52e4 commit da97810
Show file tree
Hide file tree
Showing 15 changed files with 1,437 additions and 0 deletions.
2 changes: 2 additions & 0 deletions etas/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@

etas
3 changes: 3 additions & 0 deletions etas/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
FROM alpine
ADD etas /etas
ENTRYPOINT [ "/etas" ]
23 changes: 23 additions & 0 deletions etas/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
GOPATH:=$(shell go env GOPATH)

.PHONY: init
init:
go get -u github.com/golang/protobuf/proto
go get -u github.com/golang/protobuf/protoc-gen-go
go get github.com/micro/micro/v3/cmd/protoc-gen-micro

.PHONY: proto
proto:
protoc --proto_path=. --micro_out=. --go_out=:. proto/etas.proto

.PHONY: build
build:
go build -o etas *.go

.PHONY: test
test:
go test -v ./... -cover

.PHONY: docker
docker:
docker build . -t etas:latest
31 changes: 31 additions & 0 deletions etas/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# ETAs Service

This is the ETAs service. It provides ETAs for single-pickup, multi-dropoff routes. It takes into account time and traffic.

Current limitations:
• Only supports "Driving" (not walking, cycling)
• Does not optimize route

## Usage

There is one required config value: `google.maps.apikey`. Once you have set this config value, run the service using `micro run`.

```bash
micro@Bens-MBP-3 etas % micro call etas ETAs.Calculate $(cat example-req.json)
{
"points": {
"brentwood-station": {
"estimated_arrival_time": "2020-12-15T11:01:29.429947Z",
"estimated_departure_time": "2020-12-15T11:01:29.429947Z"
},
"nandos": {
"estimated_arrival_time": "2020-12-15T10:54:38.429947Z",
"estimated_departure_time": "2020-12-15T10:54:38.429947Z"
},
"shenfield-station": {
"estimated_arrival_time": "2020-12-15T10:48:34.429947Z",
"estimated_departure_time": "2020-12-15T10:48:34.429947Z"
}
}
}
```
19 changes: 19 additions & 0 deletions etas/example-req.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"pickup": {
"id": "shenfield-station",
"latitude": 51.6308,
"longitude": 0.3295
},
"waypoints": [
{
"id": "nandos",
"latitude": 51.6199,
"longitude": 0.2999
},
{
"id": "brentwood-station",
"latitude": 51.6136,
"longitude": 0.2996
}
]
}
2 changes: 2 additions & 0 deletions etas/generate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
package main
//go:generate make proto
11 changes: 11 additions & 0 deletions etas/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module etas

go 1.15

require (
github.com/golang/protobuf v1.4.3
github.com/micro/micro/v3 v3.0.2
github.com/stretchr/testify v1.6.1
google.golang.org/protobuf v1.25.0
googlemaps.github.io/maps v1.3.1
)
495 changes: 495 additions & 0 deletions etas/go.sum

Large diffs are not rendered by default.

102 changes: 102 additions & 0 deletions etas/handler/etas.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package handler

import (
"context"
"fmt"
"time"

pb "etas/proto"

"github.com/micro/micro/v3/service/errors"
"google.golang.org/protobuf/types/known/timestamppb"
"googlemaps.github.io/maps"
)

type ETAs struct {
Maps *maps.Client
}

// Calculate the ETAs for a route
func (e *ETAs) Calculate(ctx context.Context, req *pb.Route, rsp *pb.Response) error {
// validate the request
if req.Pickup == nil {
return errors.BadRequest("etas.Calculate", "Missing pickup")
}
if len(req.Waypoints) == 0 {
return errors.BadRequest("etas.Calculate", "One more more waypoints required")
}
if err := validatePoint(req.Pickup, "Pickup"); err != nil {
return err
}
for i, p := range req.Waypoints {
if err := validatePoint(p, fmt.Sprintf("Waypoint %v", i)); err != nil {
return err
}
}

// construct the request
destinations := make([]string, len(req.Waypoints))
for i, p := range req.Waypoints {
destinations[i] = pointToCoords(p)
}
departureTime := "now"
if req.StartTime != nil {
departureTime = req.StartTime.String()
}
resp, err := e.Maps.DistanceMatrix(ctx, &maps.DistanceMatrixRequest{
Origins: []string{pointToCoords(req.Pickup)},
Destinations: destinations,
DepartureTime: departureTime,
Units: "UnitsMetric",
Mode: maps.TravelModeDriving,
})
if err != nil {
return err
}

// check the correct number of elements (route segments) were returned
// from the Google API
if len(resp.Rows[0].Elements) != len(destinations) {
return errors.InternalServerError("etas.Calculate", "Invalid downstream response. Expected %v segments but got %v", len(destinations), len(resp.Rows[0].Elements))
}

// calculate the response
currentTime := time.Now()
if req.StartTime != nil {
currentTime = req.StartTime.AsTime()
}
rsp.Points = make(map[string]*pb.ETA, len(req.Waypoints)+1)
for i, p := range append([]*pb.Point{req.Pickup}, req.Waypoints...) {
at := currentTime
if i > 0 {
at = at.Add(resp.Rows[0].Elements[i-1].Duration)
}
et := at.Add(time.Minute * time.Duration(p.WaitTime))

rsp.Points[p.Id] = &pb.ETA{
EstimatedArrivalTime: timestamppb.New(at),
EstimatedDepartureTime: timestamppb.New(et),
}

currentTime = et
}

return nil
}

func validatePoint(p *pb.Point, desc string) error {
if len(p.Id) == 0 {
return errors.BadRequest("etas.Calculate", "%v missing ID", desc)
}
if p.Latitude == 0 {
return errors.BadRequest("etas.Calculate", "%v missing Latitude", desc)
}
if p.Longitude == 0 {
return errors.BadRequest("etas.Calculate", "%v missing Longitude", desc)
}
return nil
}

func pointToCoords(p *pb.Point) string {
return fmt.Sprintf("%v,%v", p.Latitude, p.Longitude)
}
129 changes: 129 additions & 0 deletions etas/handler/etas_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package handler_test

import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/stretchr/testify/assert"
"google.golang.org/protobuf/types/known/timestamppb"

"etas/handler"
pb "etas/proto"

"googlemaps.github.io/maps"
)

func TestCalculate(t *testing.T) {
// mock the API response from Google Maps
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
fmt.Fprintln(w, `{
"rows": [
{
"elements": [
{
"duration": {
"text": "10 mins",
"value": 600
},
"status": "OK"
},
{
"duration": {
"text": "6 mins",
"value": 360
},
"status": "OK"
}
]
}
],
"status": "OK"
}`)
}))
defer s.Close()
m, err := maps.NewClient(maps.WithAPIKey("notrequired"), maps.WithBaseURL(s.URL))
if err != nil {
t.Fatal(err)
}

// construct the handler and test the response
e := handler.ETAs{m}
t.Run("MissingPickup", func(t *testing.T) {
err := e.Calculate(context.TODO(), &pb.Route{
Waypoints: []*pb.Point{
&pb.Point{
Id: "shenfield-station",
Latitude: 51.6308,
Longitude: 0.3295,
},
},
}, &pb.Response{})
assert.Error(t, err)
})

t.Run("MissingWaypoints", func(t *testing.T) {
err := e.Calculate(context.TODO(), &pb.Route{
Pickup: &pb.Point{
Id: "shenfield-station",
Latitude: 51.6308,
Longitude: 0.3295,
},
}, &pb.Response{})
assert.Error(t, err)
})

t.Run("Valid", func(t *testing.T) {
st := time.Unix(1609459200, 0)

var rsp pb.Response
err := e.Calculate(context.TODO(), &pb.Route{
StartTime: timestamppb.New(st),
Pickup: &pb.Point{
Id: "shenfield-station",
Latitude: 51.6308,
Longitude: 0.3295,
WaitTime: 5,
},
Waypoints: []*pb.Point{
{
Id: "nandos",
Latitude: 51.6199,
Longitude: 0.2999,
WaitTime: 10,
},
{
Id: "brentwood-station",
Latitude: 51.6136,
Longitude: 0.2996,
},
},
}, &rsp)

assert.NoError(t, err)
assert.NotNilf(t, rsp.Points, "Points should be returned")

p := rsp.Points["shenfield-station"]
ea := st
ed := ea.Add(time.Minute * 5)
assert.True(t, p.EstimatedArrivalTime.AsTime().Equal(ea))
assert.True(t, p.EstimatedDepartureTime.AsTime().Equal(ed))

p = rsp.Points["nandos"]
ea = ed.Add(time.Minute * 10) // drive time
ed = ea.Add(time.Minute * 10) // wait time
assert.True(t, p.EstimatedArrivalTime.AsTime().Equal(ea))
assert.True(t, p.EstimatedDepartureTime.AsTime().Equal(ed))

p = rsp.Points["brentwood-station"]
ea = ed.Add(time.Minute * 6) // drive time
ed = ea
assert.True(t, p.EstimatedArrivalTime.AsTime().Equal(ea))
assert.True(t, p.EstimatedDepartureTime.AsTime().Equal(ed))
})
}
42 changes: 42 additions & 0 deletions etas/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package main

import (
"etas/handler"
pb "etas/proto"

"googlemaps.github.io/maps"

"github.com/micro/micro/v3/service"
"github.com/micro/micro/v3/service/config"
"github.com/micro/micro/v3/service/logger"
)

func main() {
// Create service
srv := service.New(
service.Name("etas"),
service.Version("latest"),
)

// Connect to GoogleMaps
cf, err := config.Get("google.maps.apikey")
if err != nil {
logger.Fatalf("Error loading config: %v", err)
}
key := cf.String("")
if len(key) == 0 {
logger.Fatalf("Missing require config: google.maps.apikey")
}
m, err := maps.NewClient(maps.WithAPIKey(key))
if err != nil {
logger.Fatal(err)
}

// Register handler
pb.RegisterETAsHandler(srv.Server(), &handler.ETAs{Maps: m})

// Run service
if err := srv.Run(); err != nil {
logger.Fatal(err)
}
}
1 change: 1 addition & 0 deletions etas/micro.mu
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
service etas
Loading

0 comments on commit da97810

Please sign in to comment.