More trial-and-error fixes.
authorFlorian Forster <ff@octo.it>
Wed, 10 Jan 2018 19:15:43 +0000 (20:15 +0100)
committerFlorian Forster <ff@octo.it>
Wed, 10 Jan 2018 19:15:43 +0000 (20:15 +0100)
* 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.

fitbit/fitbit.go [new file with mode: 0644]
gfitsync.go

diff --git a/fitbit/fitbit.go b/fitbit/fitbit.go
new file mode 100644 (file)
index 0000000..6c7a61d
--- /dev/null
@@ -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
+}
index 56cb353..fe8049c 100644 (file)
@@ -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, "<html><body><h1>Fitbit to Google Fit sync</h1>")
 
@@ -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
 }