diff --git a/README.md b/README.md index 48eb38a..a2032fd 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,13 @@ Uber API client in Go To use client v1, you'll need to set + `UBER_TOKEN_KEY` +## CLI installation +```go +$ go get -u -v github.com/orijtech/uber/cmd/uber +$ uber --order +``` + +## SDK usage Sample usage: You can see file [example_test.go](./example_test.go) diff --git a/cmd/uber/main.go b/cmd/uber/main.go index 8180366..0277653 100644 --- a/cmd/uber/main.go +++ b/cmd/uber/main.go @@ -16,17 +16,50 @@ package main import ( "encoding/json" + "errors" "flag" + "fmt" "log" "os" "path/filepath" + "strconv" + "strings" "github.com/orijtech/uber/oauth2" + "github.com/orijtech/uber/v1" + + "github.com/olekukonko/tablewriter" + + "github.com/odeke-em/cli-spinner" + "github.com/odeke-em/go-utils/fread" + "github.com/odeke-em/mapbox" + "github.com/odeke-em/semalim" ) +const repeatSentinel = "n" + +var mapboxClient *mapbox.Client + +func init() { + var err error + mapboxClient, err = mapbox.NewClient() + if err != nil { + log.Fatal(err) + } +} + func main() { + log.SetFlags(0) + + uberClient, err := uber.NewClientFromOAuth2File(os.ExpandEnv("$HOME/.uber/credentials.json")) + if err != nil { + log.Fatal(err) + } + var init bool + var order bool flag.BoolVar(&init, "init", false, "allow a user to authorize this app to make requests on their behalf") + flag.BoolVar(&order, "order", false, "order an Uber") flag.Parse() // Make log not print out time info in its prefix. @@ -35,7 +68,304 @@ func main() { switch { case init: authorize() + case order: + spinr := spinner.New(10) + var startGeocodeFeature, endGeocodeFeature mapbox.GeocodeFeature + items := [...]struct { + ft *mapbox.GeocodeFeature + prompt string + }{ + 0: {&startGeocodeFeature, "Start Point: "}, + 1: {&endGeocodeFeature, "End Point: "}, + } + + linesChan := fread.Fread(os.Stdin) + for i, item := range items { + for { + geocodeFeature, query, err := doSearch(item.prompt, linesChan, "n", spinr) + if err == nil { + *item.ft = *geocodeFeature + break + } + + switch err { + case errRepeat: + fmt.Printf("\033[32mSearching again *\033[00m\n") + continue + case errNoMatchFound: + fmt.Printf("No matches found found for %q. Try again? (y/N) ", query) + continueResponse := strings.TrimSpace(<-linesChan) + if strings.HasPrefix(strings.ToLower(continueResponse), "y") { + continue + } + return + default: + // Otherwise an unhandled error + log.Fatalf("%d: search err: %v; prompt=%q", i, err, item.prompt) + } + } + } + + var seatCount int = 2 + for { + fmt.Printf("Seat count: 1 or 2 (default 2) ") + seatCountLine := strings.TrimSpace(<-linesChan) + if seatCountLine == "" { + seatCount = 2 + break + } else { + parsed, err := strconv.ParseInt(seatCountLine, 10, 32) + if err != nil { + log.Fatalf("seatCount parsing err: %v", err) + } + if parsed >= 1 && parsed <= 2 { + seatCount = int(parsed) + break + } else { + fmt.Printf("\033[31mPlease enter either 1 or 2!\033[00m\n") + } + } + } + + startCoord := centerToCoord(startGeocodeFeature.Center) + endCoord := centerToCoord(endGeocodeFeature.Center) + esReq := &uber.EstimateRequest{ + StartLatitude: startCoord.Lat, + StartLongitude: startCoord.Lng, + EndLatitude: endCoord.Lat, + EndLongitude: endCoord.Lng, + SeatCount: seatCount, + } + + estimates, err := doUberEstimates(uberClient, esReq, spinr) + if err != nil { + log.Fatalf("estimate err: %v\n", err) + } + + table := tablewriter.NewWriter(os.Stdout) + table.SetRowLine(true) + table.SetHeader([]string{ + "Choice", "Name", "Estimate", "Currency", + "Pickup time (minutes)", "TripDuration (minutes)", + }) + for i, est := range estimates { + et := est.Estimate + ufare := est.UpfrontFare + table.Append([]string{ + fmt.Sprintf("%d", i), + fmt.Sprintf("%s", et.LocalizedName), + fmt.Sprintf("%s", et.Estimate), + fmt.Sprintf("%s", et.CurrencyCode), + fmt.Sprintf("%.1f", ufare.PickupEstimateMinutes), + fmt.Sprintf("%.1f", et.DurationSeconds/60.0), + }) + } + table.Render() + + var estimateChoice *estimateAndUpfrontFarePair + + for { + fmt.Printf("Please enter the choice of your item or n to cancel ") + + lineIn := strings.TrimSpace(<-linesChan) + if strings.EqualFold(lineIn, repeatSentinel) { + return + } + + choice, err := strconv.ParseUint(lineIn, 10, 32) + if err != nil { + log.Fatalf("parsing choice err: %v", err) + } + if choice < 0 || choice >= uint64(len(estimates)) { + log.Fatalf("choice must be >=0 && < %d", len(estimates)) + } + estimateChoice = estimates[choice] + break + } + + if estimateChoice == nil { + log.Fatal("illogical error, estimateChoice cannot be nil") + } + + rreq := &uber.RideRequest{ + StartLatitude: startCoord.Lat, + StartLongitude: startCoord.Lng, + EndLatitude: endCoord.Lat, + EndLongitude: endCoord.Lng, + SeatCount: seatCount, + FareID: string(estimateChoice.UpfrontFare.Fare.ID), + ProductID: estimateChoice.Estimate.ProductID, + } + spinr.Start() + rres, err := uberClient.RequestRide(rreq) + spinr.Stop() + if err != nil { + log.Fatalf("requestRide err: %v", err) + } + + fmt.Printf("\033[33mRide\033[00m\n") + dtable := tablewriter.NewWriter(os.Stdout) + dtable.SetHeader([]string{ + "Status", "RequestID", "Driver", "Rating", "Phone", "Shared", "Pickup ETA", "Destination ETA", + }) + + locationDeref := func(loc *uber.Location) *uber.Location { + if loc == nil { + loc = new(uber.Location) + } + return loc + } + + dtable.Append([]string{ + fmt.Sprintf("%s", rres.Status), + rres.RequestID, + rres.Driver.Name, + fmt.Sprintf("%d", rres.Driver.Rating), + fmt.Sprintf("%s", rres.Driver.PhoneNumber), + fmt.Sprintf("%v", rres.Shared), + fmt.Sprintf("%.1f", locationDeref(rres.Pickup).ETAMinutes), + fmt.Sprintf("%.1f", locationDeref(rres.Destination).ETAMinutes), + }) + dtable.Render() + + vtable := tablewriter.NewWriter(os.Stdout) + fmt.Printf("\n\033[32mVehicle\033[00m\n") + vtable.SetHeader([]string{ + "Make", "Model", "License plate", "Picture", + }) + vtable.Append([]string{ + rres.Vehicle.Make, + rres.Vehicle.Model, + rres.Vehicle.LicensePlate, + rres.Vehicle.PictureURL, + }) + vtable.Render() + } +} + +func doUberEstimates(uberC *uber.Client, esReq *uber.EstimateRequest, spinr *spinner.Spinner) ([]*estimateAndUpfrontFarePair, error) { + spinr.Start() + estimatesPageChan, cancelPaging, err := uberC.EstimatePrice(esReq) + spinr.Stop() + if err != nil { + return nil, err + } + + var allEstimates []*uber.PriceEstimate + for page := range estimatesPageChan { + if page.Err == nil { + allEstimates = append(allEstimates, page.Estimates...) + } + if len(allEstimates) >= 400 { + cancelPaging() + } + } + + spinr.Start() + defer spinr.Stop() + + jobsBench := make(chan semalim.Job) + go func() { + defer close(jobsBench) + + for i, estimate := range allEstimates { + jobsBench <- &lookupFare{ + client: uberC, + id: i, + estimate: estimate, + esReq: &uber.EstimateRequest{ + StartLatitude: esReq.StartLatitude, + StartLongitude: esReq.StartLongitude, + StartPlace: esReq.StartPlace, + EndPlace: esReq.EndPlace, + EndLatitude: esReq.EndLatitude, + EndLongitude: esReq.EndLongitude, + SeatCount: esReq.SeatCount, + ProductID: estimate.ProductID, + }, + } + } + }() + + var pairs []*estimateAndUpfrontFarePair + resChan := semalim.Run(jobsBench, 5) + for res := range resChan { + // No ordering required so can just retrieve and add results in + if retr := res.Value().(*estimateAndUpfrontFarePair); retr != nil { + pairs = append(pairs, retr) + } + } + + return pairs, nil +} + +var ( + errNoMatchFound = errors.New("no matches found") + errRepeat = errors.New("repeat match") +) + +func doSearch(prompt string, linesChan <-chan string, repeatSentinel string, spinr *spinner.Spinner) (*mapbox.GeocodeFeature, string, error) { + fmt.Printf(prompt) + + query := strings.TrimSpace(<-linesChan) + if query == "" { + return nil, query, errRepeat + } + spinr.Start() + matches, err := mapboxClient.LookupPlace(query) + spinr.Stop() + if err != nil { + return nil, query, err + } + + if len(matches.Features) == 0 { + return nil, query, errNoMatchFound + } + + table := tablewriter.NewWriter(os.Stdout) + table.SetHeader([]string{"Choice", "Name", "Relevance", "Latitude", "Longitude"}) + table.SetRowLine(true) + + for i, feat := range matches.Features { + coord := centerToCoord(feat.Center) + table.Append([]string{ + fmt.Sprintf("%d", i), + fmt.Sprintf("%s", feat.PlaceName), + fmt.Sprintf("%.2f%%", feat.Relevance*100), + fmt.Sprintf("%f", coord.Lat), + fmt.Sprintf("%f", coord.Lng), + }) + } + table.Render() + + fmt.Printf("Please enter your choice by numeric key or (%v) to search again: ", repeatSentinel) + lineIn := strings.TrimSpace(<-linesChan) + if strings.EqualFold(lineIn, repeatSentinel) { + return nil, query, errRepeat + } + + choice, err := strconv.ParseUint(lineIn, 10, 32) + if err != nil { + return nil, query, err + } + if choice < 0 || choice >= uint64(len(matches.Features)) { + return nil, query, fmt.Errorf("choice must be >=0 && < %d", len(matches.Features)) } + return matches.Features[choice], query, nil +} + +func input() string { + var str string + fmt.Scanln(os.Stdin, &str) + return str +} + +type coord struct { + Lat, Lng float64 +} + +func centerToCoord(center []float32) *coord { + return &coord{Lat: float64(center[1]), Lng: float64(center[0])} } func authorize() { @@ -82,3 +412,41 @@ func ensureUberCredsDirExists() (string, error) { } return curDirPath, nil } + +type lookupFare struct { + id int + estimate *uber.PriceEstimate + esReq *uber.EstimateRequest + client *uber.Client +} + +var _ semalim.Job = (*lookupFare)(nil) + +func (lf *lookupFare) Id() interface{} { + return lf.id +} + +func (lf *lookupFare) Do() (interface{}, error) { + upfrontFare, err := lookupUpfrontFare(lf.client, &uber.EstimateRequest{ + StartLatitude: lf.esReq.StartLatitude, + StartLongitude: lf.esReq.StartLongitude, + StartPlace: lf.esReq.StartPlace, + EndPlace: lf.esReq.EndPlace, + EndLatitude: lf.esReq.EndLatitude, + EndLongitude: lf.esReq.EndLongitude, + SeatCount: lf.esReq.SeatCount, + ProductID: lf.estimate.ProductID, + }) + + return &estimateAndUpfrontFarePair{Estimate: lf.estimate, UpfrontFare: upfrontFare}, err +} + +type estimateAndUpfrontFarePair struct { + Estimate *uber.PriceEstimate `json:"estimate"` + UpfrontFare *uber.UpfrontFare `json:"upfront_fare"` +} + +func lookupUpfrontFare(c *uber.Client, rr *uber.EstimateRequest) (*uber.UpfrontFare, error) { + // Otherwise it is time to get the estimate of the fare + return c.UpfrontFare(rr) +} diff --git a/cmd/uber/testdata/end.json b/cmd/uber/testdata/end.json new file mode 100644 index 0000000..f4efd2e --- /dev/null +++ b/cmd/uber/testdata/end.json @@ -0,0 +1 @@ +[{"id":"place.9080100702660390","type":"Feature","text":"Edmonton","place_name":"Edmonton, Alberta, Canada","relevance":0.49,"properties":{"wikidata":"Q2096"},"context":[{"id":"region.219693","text":"Alberta","short_code":"CA-AB","wikidata":"Q1951"},{"id":"country.3179","text":"Canada","short_code":"ca","wikidata":"Q16"}],"bbox":[-113.7138,53.395603,-113.271645,53.715984],"center":[-113.5065,53.5344],"geometry":{"type":"Point","coordinates":[-113.5065,53.5344]},"attribution":""},{"id":"place.12628624761375070","type":"Feature","text":"Southgate","place_name":"Southgate, Michigan, United States","relevance":0.49,"properties":{"wikidata":"Q119706"},"context":[{"id":"region.219112","text":"Michigan","short_code":"US-MI","wikidata":"Q1166"},{"id":"country.3145","text":"United States","short_code":"us","wikidata":"Q30"}],"bbox":[-83.23253,42.18428,-83.179596,42.22801],"center":[-83.2083,42.2032],"geometry":{"type":"Point","coordinates":[-83.2083,42.2032]},"attribution":""},{"id":"neighborhood.293894","type":"Feature","text":"Southgate","place_name":"Southgate, Hayward, California 94545, United States","relevance":0.49,"properties":{},"context":[{"id":"postcode.6500720573961840","text":"94545","short_code":"","wikidata":""},{"id":"place.10282886204411860","text":"Hayward","short_code":"","wikidata":"Q491114"},{"id":"region.3591","text":"California","short_code":"US-CA","wikidata":"Q99"},{"id":"country.3145","text":"United States","short_code":"us","wikidata":"Q30"}],"bbox":[-122.11031,37.63093,-122.08442,37.65673],"center":[-122.1,37.64],"geometry":{"type":"Point","coordinates":[-122.1,37.64]},"attribution":""},{"id":"neighborhood.2100987","type":"Feature","text":"Southgate","place_name":"Southgate, Lakewood, Washington 98499, United States","relevance":0.49,"properties":{},"context":[{"id":"postcode.10053733604137270","text":"98499","short_code":"","wikidata":""},{"id":"place.9773350605366720","text":"Lakewood","short_code":"","wikidata":"Q983791"},{"id":"region.213154","text":"Washington","short_code":"US-WA","wikidata":"Q1223"},{"id":"country.3145","text":"United States","short_code":"us","wikidata":"Q30"}],"bbox":[-122.49517,47.154114,-122.466736,47.172024],"center":[-122.48,47.16],"geometry":{"type":"Point","coordinates":[-122.48,47.16]},"attribution":""},{"id":"neighborhood.2106382","type":"Feature","text":"Southgate","place_name":"Southgate, Milwaukee, Wisconsin 53215, United States","relevance":0.49,"properties":{},"context":[{"id":"postcode.8707108028172880","text":"53215","short_code":"","wikidata":""},{"id":"place.7506480977217990","text":"Milwaukee","short_code":"","wikidata":"Q37836"},{"id":"region.219024","text":"Wisconsin","short_code":"US-WI","wikidata":"Q1537"},{"id":"country.3145","text":"United States","short_code":"us","wikidata":"Q30"}],"bbox":[-87.95305,42.98107,-87.93833,42.992683],"center":[-87.95,42.99],"geometry":{"type":"Point","coordinates":[-87.95,42.99]},"attribution":""}] \ No newline at end of file diff --git a/cmd/uber/testdata/ride.json b/cmd/uber/testdata/ride.json new file mode 100644 index 0000000..1a276ab --- /dev/null +++ b/cmd/uber/testdata/ride.json @@ -0,0 +1,35 @@ +{ + "product_id": "17cb78a7-b672-4d34-a288-a6c6e44d5315", + "request_id": "a1111c8c-c720-46c3-8534-2fcdd730040d", + "status": "accepted", + "surge_multiplier": 1.0, + "shared": true, + "driver": { + "phone_number": "(415)555-1212", + "sms_number": "(415)555-1212", + "rating": 5, + "picture_url": "https://d1w2poirtb3as9.cloudfront.net/img.jpeg", + "name": "Bob" + }, + "vehicle": { + "make": "Bugatti", + "model": "Veyron", + "license_plate": "I<3Uber", + "picture_url": "https://d1w2poirtb3as9.cloudfront.net/car.jpeg" + }, + "location": { + "latitude": 37.3382129093, + "longitude": -121.8863287568, + "bearing": 328 + }, + "pickup": { + "latitude": 37.3303463, + "longitude": -121.8890484, + "eta": 5 + }, + "destination": { + "latitude": 37.6213129, + "longitude": -122.3789554, + "eta": 19 + } +} diff --git a/cmd/uber/testdata/start.json b/cmd/uber/testdata/start.json new file mode 100644 index 0000000..5664777 --- /dev/null +++ b/cmd/uber/testdata/start.json @@ -0,0 +1 @@ +[{"id":"poi.15555644443896030","type":"Feature","text":"West Edmonton Mall","place_name":"West Edmonton Mall, 8882 170 St NW, Edmonton, Alberta T5T 4M2, Canada","relevance":0.99,"properties":{"address":"8882 170 St NW","category":"landmark","landmark":true,"maki":"monument","tel":"(780) 444-5321","wikidata":"Q1044789"},"context":[{"id":"neighborhood.5879030753669280","text":"Lynnwood","short_code":"","wikidata":""},{"id":"postcode.12520904101019170","text":"T5T 4M2","short_code":"","wikidata":""},{"id":"place.9080100702660390","text":"Edmonton","short_code":"","wikidata":"Q2096"},{"id":"region.219693","text":"Alberta","short_code":"CA-AB","wikidata":"Q1951"},{"id":"country.3179","text":"Canada","short_code":"ca","wikidata":"Q16"}],"bbox":null,"center":[-113.62278,53.52222],"geometry":{"type":"Point","coordinates":[-113.62278,53.52222]},"attribution":""},{"id":"poi.4514440791940890","type":"Feature","text":"West Edmonton Mall Transit Centre","place_name":"West Edmonton Mall Transit Centre, Edmonton, Alberta T5T 4J2, Canada","relevance":0.99,"properties":{"category":"bus station, bus stop, bus","landmark":true,"maki":"bus"},"context":[{"id":"neighborhood.5879030753669280","text":"Lynnwood","short_code":"","wikidata":""},{"id":"postcode.12420303953212740","text":"T5T 4J2","short_code":"","wikidata":""},{"id":"place.9080100702660390","text":"Edmonton","short_code":"","wikidata":"Q2096"},{"id":"region.219693","text":"Alberta","short_code":"CA-AB","wikidata":"Q1951"},{"id":"country.3179","text":"Canada","short_code":"ca","wikidata":"Q16"}],"bbox":null,"center":[-113.622574,53.520397],"geometry":{"type":"Point","coordinates":[-113.622574,53.520397]},"attribution":""},{"id":"poi.10581969664216370","type":"Feature","text":"Malmo Elementary School","place_name":"Malmo Elementary School, 4716 115 St NW, Edmonton, Alberta T6H 0E5, Canada","relevance":0.6536667,"properties":{"address":"4716 115 St NW","category":"primary school, secondary school, elementary, school","landmark":true,"maki":"school","tel":"(780) 434-1362"},"context":[{"id":"neighborhood.18827898930330520","text":"Lendrum Place","short_code":"","wikidata":""},{"id":"postcode.10569297065954110","text":"T6H 0E5","short_code":"","wikidata":""},{"id":"place.9080100702660390","text":"Edmonton","short_code":"","wikidata":"Q2096"},{"id":"region.219693","text":"Alberta","short_code":"CA-AB","wikidata":"Q1951"},{"id":"country.3179","text":"Canada","short_code":"ca","wikidata":"Q16"}],"bbox":null,"center":[-113.52909,53.484833],"geometry":{"type":"Point","coordinates":[-113.52909,53.484833]},"attribution":""},{"id":"poi.9160354503027690","type":"Feature","text":"Malcolm Tweddle Elementary School","place_name":"Malcolm Tweddle Elementary School, 2340 Millbourne Rd West NW, Edmonton, Alberta T6K 1H3, Canada","relevance":0.6536667,"properties":{"address":"2340 Millbourne Rd West NW","category":"primary school, secondary school, elementary, school","landmark":true,"maki":"school","tel":"(780) 462-3270"},"context":[{"id":"neighborhood.16818569417694470","text":"Argyll","short_code":"","wikidata":""},{"id":"postcode.9167295351027780","text":"T6K 1H3","short_code":"","wikidata":""},{"id":"place.9080100702660390","text":"Edmonton","short_code":"","wikidata":"Q2096"},{"id":"region.219693","text":"Alberta","short_code":"CA-AB","wikidata":"Q1951"},{"id":"country.3179","text":"Canada","short_code":"ca","wikidata":"Q16"}],"bbox":null,"center":[-113.45801,53.477253],"geometry":{"type":"Point","coordinates":[-113.45801,53.477253]},"attribution":""},{"id":"poi.10016908438986270","type":"Feature","text":"Malt's Lounge","place_name":"Malt's Lounge, 10155 105 St NW, Edmonton, Alberta T5J 1C9, Canada","relevance":0.6536667,"properties":{"address":"10155 105 St NW","category":"bar, alcohol","landmark":true,"maki":"bar","tel":"(780) 423-4811"},"context":[{"id":"neighborhood.2475627669682630","text":"Downtown Edmonton","short_code":"","wikidata":""},{"id":"postcode.10004308389782740","text":"T5J 1C9","short_code":"","wikidata":""},{"id":"place.9080100702660390","text":"Edmonton","short_code":"","wikidata":"Q2096"},{"id":"region.219693","text":"Alberta","short_code":"CA-AB","wikidata":"Q1951"},{"id":"country.3179","text":"Canada","short_code":"ca","wikidata":"Q16"}],"bbox":null,"center":[-113.50084,53.54219],"geometry":{"type":"Point","coordinates":[-113.50084,53.54219]},"attribution":""}] diff --git a/v1/rides.go b/v1/rides.go index 1a45d1c..45f5c48 100644 --- a/v1/rides.go +++ b/v1/rides.go @@ -158,6 +158,7 @@ func (c *Client) RequestRide(rreq *RideRequest) (*Ride, error) { if err != nil { return nil, err } + req.Header.Set("Content-Type", "application/json") blob, _, err = c.doHTTPReq(req) if err != nil { return nil, err @@ -190,22 +191,26 @@ func blankPlaceOrCoords(place PlaceName, lat, lon float64) bool { } type Ride struct { - RequestID string `json:"request_id"` - ProductID string `json:"product_id"` + RequestID string `json:"request_id,omitempty"` + ProductID string `json:"product_id,omitempty"` // Status indicates the state of the ride request. - Status Status `json:"status"` + Status Status `json:"status,omitempty"` + Shared bool `json:"shared,omitempty"` Vehicle *Vehicle `json:"vehicle,omitempty"` - Driver *Driver `json:"driver"` - Location *Location `json:"location"` + Driver *Driver `json:"driver,omitempty"` + Location *Location `json:"location,omitempty"` + + Pickup *Location `json:"pickup,omitempty"` + Destination *Location `json:"destination,omitempty"` // ETAMinutes is the expected time of arrival in minutes. - ETAMinutes int `json:"eta"` + ETAMinutes int `json:"eta,omitempty"` // The surge pricing multiplier used to calculate the increased price of a request. // A surge multiplier of 1.0 means surge pricing is not in effect. - SurgeMultiplier float32 `json:"surge_multiplier"` + SurgeMultiplier float32 `json:"surge_multiplier,omitempty"` } func (r *Ride) SurgeInEffect() bool { @@ -244,4 +249,6 @@ type Location struct { State string `json:"state,omitempty"` PostalCode string `json:"postal_code,omitempty"` Country string `json:"country,omitempty"` + + ETAMinutes float32 `json:"eta,omitempty"` }