From: Florian Forster Date: Wed, 10 Jan 2018 19:15:43 +0000 (+0100) Subject: More trial-and-error fixes. X-Git-Url: https://git.octo.it/?a=commitdiff_plain;h=2a96ce53ec33a7fbd6d5054671ed2dfcc0ff379e;p=kraftakt.git More trial-and-error fixes. * datastore.Key.Encode is too long for a subscriber ID, which appears limited to 50 characters / bytes. * Use UUID as subscriber ID. * Rename fitbitNotification to fitbitSubscription. * Implement parsing of the "activity summary" in a separate package. --- diff --git a/fitbit/fitbit.go b/fitbit/fitbit.go new file mode 100644 index 0000000..6c7a61d --- /dev/null +++ b/fitbit/fitbit.go @@ -0,0 +1,94 @@ +package fitbit + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "golang.org/x/oauth2" + oauth2fitbit "golang.org/x/oauth2/fitbit" +) + +var oauth2Config = &oauth2.Config{ + ClientID: "@FITBIT_CLIENT_ID@", + ClientSecret: "@FITBIT_CLIENT_SECRET@", + Endpoint: oauth2fitbit.Endpoint, + RedirectURL: "https://fitbit-gfit-sync.appspot.com/fitbit/grant", + Scopes: []string{"activity"}, +} + +type Activity struct { + ActivityID int `json:"activityId"` + ActivityParentID int `json:"activityParentId"` + Calories int `json:"calories"` + Description string `json:"description"` + Distance float64 `json:"distance"` + Duration int `json:"duration"` + HasStartTime bool `json:"hasStartTime"` + IsFavorite bool `json:"isFavorite"` + LogID int `json:"logId"` + Name string `json:"name"` + StartTime string `json:"startTime"` + Steps int `json:"steps"` +} + +type Distance struct { + Activity string `json:"activity"` + Distance float64 `json:"distance"` +} + +type ActivitySummary struct { + Activities []Activity `json:"activities"` + Goals struct { + CaloriesOut int `json:"caloriesOut"` + Distance float64 `json:"distance"` + Floors int `json:"floors"` + Steps int `json:"steps"` + } `json:"goals"` + Summary struct { + ActivityCalories int `json:"activityCalories"` + CaloriesBMR int `json:"caloriesBMR"` + CaloriesOut int `json:"caloriesOut"` + MarginalCalories int `json:"marginalCalories"` + Distances []Distance `json:"distances"` + Elevation float64 `json:"elevation"` + Floors int `json:"floors"` + Steps int `json:"steps"` + SedentaryMinutes int `json:"sedentaryMinutes"` + LightlyActiveMinutes int `json:"lightlyActiveMinutes"` + FairlyActiveMinutes int `json:"fairlyActiveMinutes"` + VeryActiveMinutes int `json:"veryActiveMinutes"` + } `json:"summary"` +} + +type Client struct { + userID string + client *http.Client +} + +func NewClient(ctx context.Context, userID string, tok *oauth2.Token) *Client { + return &Client{ + userID: userID, + client: oauth2Config.Client(ctx, tok), + } +} + +func (c *Client) ActivitySummary(t time.Time) (*ActivitySummary, error) { + url := fmt.Sprintf("https://api.fitbit.com/1/user/%s/activity/date/%s.json", + c.userID, t.Format("2006-01-02")) + + res, err := c.client.Get(url) + if err != nil { + return nil, err + } + defer res.Body.Close() + + var summary ActivitySummary + if err := json.NewDecoder(res.Body).Decode(&summary); err != nil { + return nil, err + } + + return &summary, nil +} diff --git a/gfitsync.go b/gfitsync.go index 56cb353..fe8049c 100644 --- a/gfitsync.go +++ b/gfitsync.go @@ -12,9 +12,11 @@ import ( "net/url" "time" + "github.com/google/uuid" + "github.com/octo/gfitsync/fitbit" legacy_context "golang.org/x/net/context" "golang.org/x/oauth2" - "golang.org/x/oauth2/fitbit" + oauth2fitbit "golang.org/x/oauth2/fitbit" "google.golang.org/appengine" "google.golang.org/appengine/datastore" "google.golang.org/appengine/delay" @@ -32,16 +34,11 @@ var delayedHandleNotifications = delay.Func("handleNotifications", handleNotific var oauthConfig = &oauth2.Config{ ClientID: "@FITBIT_CLIENT_ID@", ClientSecret: "@FITBIT_CLIENT_SECRET@", - Endpoint: fitbit.Endpoint, + Endpoint: oauth2fitbit.Endpoint, RedirectURL: "https://fitbit-gfit-sync.appspot.com/fitbit/grant", // TODO(octo): make full URL Scopes: []string{"activity"}, } -type storedToken struct { - Email string - oauth2.Token -} - func init() { http.HandleFunc("/setup", setupHandler) http.Handle("/fitbit/grant", AuthenticatedHandler(fitbitGrantHandler)) @@ -61,7 +58,11 @@ func (hndl ContextHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } -type AuthenticatedHandler func(context.Context, http.ResponseWriter, *http.Request, *user.User) error +type AuthenticatedHandler func(context.Context, http.ResponseWriter, *http.Request, *datastore.Key) error + +type User struct { + ID string +} func (hndl AuthenticatedHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { ctx := appengine.NewContext(r) @@ -78,11 +79,15 @@ func (hndl AuthenticatedHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques } err := datastore.RunInTransaction(ctx, func(ctx legacy_context.Context) error { - key := RootKey(ctx, u) - if err := datastore.Get(ctx, key, &struct{}{}); err != datastore.ErrNoSuchEntity { + key := datastore.NewKey(ctx, "User", u.Email, 0, nil) + + if err := datastore.Get(ctx, key, &User{}); err != datastore.ErrNoSuchEntity { return err // may be nil } - _, err := datastore.Put(ctx, key, &struct{}{}) + + _, err := datastore.Put(ctx, key, &User{ + ID: uuid.New().String(), + }) return err }, nil) if err != nil { @@ -90,23 +95,20 @@ func (hndl AuthenticatedHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques return } - if err := hndl(ctx, w, r, u); err != nil { + rootKey := datastore.NewKey(ctx, "User", u.Email, 0, nil) + if err := hndl(ctx, w, r, rootKey); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } } -func RootKey(ctx context.Context, u *user.User) *datastore.Key { - return datastore.NewKey(ctx, "User", u.Email, 0, nil) -} - -func indexHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *user.User) error { +func indexHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, rootKey *datastore.Key) error { var ( tok oauth2.Token haveToken bool ) - key := datastore.NewKey(ctx, "Token", "Fitbit", 0, RootKey(ctx, u)) + key := datastore.NewKey(ctx, "Token", "Fitbit", 0, rootKey) err := datastore.Get(ctx, key, &tok) if err != nil && err != datastore.ErrNoSuchEntity { return err @@ -115,6 +117,8 @@ func indexHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u haveToken = true } + u := user.Current(ctx) + // fmt.Fprintf(w, "u = %v, tok = %v, haveToken = %v\n", u, tok, haveToken) fmt.Fprintln(w, "

Fitbit to Google Fit sync

") @@ -137,7 +141,7 @@ func setupHandler(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, url, http.StatusTemporaryRedirect) } -func fitbitGrantHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *user.User) error { +func fitbitGrantHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, rootKey *datastore.Key) error { if state := r.FormValue("state"); state != csrfToken { return fmt.Errorf("invalid state parameter: %q", state) } @@ -147,24 +151,35 @@ func fitbitGrantHandler(ctx context.Context, w http.ResponseWriter, r *http.Requ return err } - key := datastore.NewKey(ctx, "Token", "Fitbit", 0, RootKey(ctx, u)) + key := datastore.NewKey(ctx, "Token", "Fitbit", 0, rootKey) if _, err := datastore.Put(ctx, key, tok); err != nil { return err } - c := oauthConfig.Client(ctx, tok) + var u User + if err := datastore.Get(ctx, rootKey, &u); err != nil { + return err + } + // create a subscription - url := fmt.Sprintf("https://api.fitbit.com/1/user/-/activities/apiSubscriptions/%s.json", - RootKey(ctx, u).Encode()) + url := fmt.Sprintf("https://api.fitbit.com/1/user/-/activities/apiSubscriptions/%s.json", u.ID) res, err := c.Post(url, "", nil) if err != nil { return err } defer res.Body.Close() + if res.StatusCode == http.StatusConflict { + var n fitbitSubscription + if err := json.NewDecoder(res.Body).Decode(&n); err != nil { + return err + } + log.Warningf(ctx, "conflict with existing subscription %v", n) + } + if res.StatusCode >= 400 { - data, _ := ioutil.ReadAll(r.Body) + data, _ := ioutil.ReadAll(res.Body) log.Errorf(ctx, "creating subscription failed: status %d %q", res.StatusCode, data) return fmt.Errorf("creating subscription failed") } @@ -177,7 +192,7 @@ func fitbitGrantHandler(ctx context.Context, w http.ResponseWriter, r *http.Requ return nil } -type fitbitNotification struct { +type fitbitSubscription struct { CollectionType string `json:"collectionType"` Date string `json:"date"` OwnerID string `json:"ownerId"` @@ -185,19 +200,20 @@ type fitbitNotification struct { SubscriptionID string `json:"subscriptionId"` } -func (n *fitbitNotification) URLValues() url.Values { +func (s *fitbitSubscription) URLValues() url.Values { return url.Values{ - "CollectionType": []string{n.CollectionType}, - "Date": []string{n.Date}, - "OwnerID": []string{n.OwnerID}, - "OwnerType": []string{n.OwnerType}, - "SubscriptionID": []string{n.SubscriptionID}, + "CollectionType": []string{s.CollectionType}, + "Date": []string{s.Date}, + "OwnerID": []string{s.OwnerID}, + "OwnerType": []string{s.OwnerType}, + "SubscriptionID": []string{s.SubscriptionID}, } } -func (n *fitbitNotification) URL() string { +func (s *fitbitSubscription) URL() string { + // daily summary: GET https://api.fitbit.com/1/user/[user-id]/activities/date/[date].json return fmt.Sprintf("https://api.fitbit.com/1/user/%s/%s/date/%s.json", - n.OwnerID, n.CollectionType, n.Date) + s.OwnerID, s.CollectionType, s.Date) } func checkSignature(ctx context.Context, payload []byte, rawSig string) bool { @@ -263,17 +279,17 @@ func fitbitNotifyHandler(ctx context.Context, w http.ResponseWriter, r *http.Req // handleNotifications parses fitbit notifications and requests the individual // activities from Fitbit. It is executed asynchronously via the delay package. func handleNotifications(ctx context.Context, payload []byte) error { - var notifications []fitbitNotification - if err := json.Unmarshal(payload, notifications); err != nil { + var subscriptions []fitbitSubscription + if err := json.Unmarshal(payload, &subscriptions); err != nil { return err } - for _, n := range notifications { - if n.CollectionType != "activities" { + for _, s := range subscriptions { + if s.CollectionType != "activities" { continue } - if err := handleNotification(ctx, &n); err != nil { + if err := handleNotification(ctx, &s); err != nil { log.Errorf(ctx, "handleNotification() = %v", err) continue } @@ -282,11 +298,17 @@ func handleNotifications(ctx context.Context, payload []byte) error { return nil } -func handleNotification(ctx context.Context, n *fitbitNotification) error { - rootKey, err := datastore.DecodeKey(n.SubscriptionID) +func handleNotification(ctx context.Context, s *fitbitSubscription) error { + q := datastore.NewQuery("User").Filter("ID=", s.SubscriptionID).KeysOnly() + keys, err := q.GetAll(ctx, nil) if err != nil { - return err + return fmt.Errorf("datastore.Query.GetAll(): %v", err) + } + if len(keys) != 1 { + return fmt.Errorf("len(keys) = %d, want 1", len(keys)) } + + rootKey := keys[0] key := datastore.NewKey(ctx, "Token", "Fitbit", 0, rootKey) var tok oauth2.Token @@ -294,12 +316,18 @@ func handleNotification(ctx context.Context, n *fitbitNotification) error { return err } - c := oauthConfig.Client(ctx, &tok) - res, err := c.Get(n.URL()) + c := fitbit.NewClient(ctx, s.OwnerID, &tok) + + t, err := time.Parse("2006-01-02", s.Date) + if err != nil { + return err + } + + summary, err := c.ActivitySummary(t) if err != nil { return err } - log.Infof(ctx, "GET %s = %v", n.URL(), res) + log.Debugf(ctx, "ActivitySummary() = %v", summary) return nil }