1 // Package fitbit implements functions to interact with the Fitbit API.
18 "github.com/octo/kraftakt/app"
19 "github.com/octo/retry"
21 oauth2fitbit "golang.org/x/oauth2/fitbit"
22 "google.golang.org/appengine"
23 "google.golang.org/appengine/log"
24 "google.golang.org/appengine/urlfetch"
27 func oauthConfig() *oauth2.Config {
28 return &oauth2.Config{
29 ClientID: app.Config.FitbitClientID,
30 ClientSecret: app.Config.FitbitClientSecret,
31 Endpoint: oauth2fitbit.Endpoint,
32 RedirectURL: "https://kraftakt.octo.it/fitbit/grant",
42 // AuthURL returns the URL of the Fitbit consent screen. Users are redirected
43 // there to approve Fitbit minting an OAuth2 token for us.
44 func AuthURL(ctx context.Context, u *app.User) string {
45 return oauthConfig().AuthCodeURL(u.Sign("Fitbit"), oauth2.AccessTypeOffline)
48 // ParseToken parses the request of the user being redirected back from the
49 // consent screen. The parsed token is stored in u using SetToken().
50 func ParseToken(ctx context.Context, r *http.Request, u *app.User) error {
51 if state := r.FormValue("state"); state != u.Sign("Fitbit") {
52 return fmt.Errorf("invalid state parameter: %q", state)
55 tok, err := oauthConfig().Exchange(ctx, r.FormValue("code"))
60 return u.SetToken(ctx, "Fitbit", tok)
63 // CheckSignature validates that rawSig is a valid signature of payload. This
64 // is used by the Fitbit API to ansure that the receiver can verify that the
65 // sender has access to the OAuth2 client secret.
66 func CheckSignature(ctx context.Context, payload []byte, rawSig string) bool {
67 signatureGot, err := base64.StdEncoding.DecodeString(rawSig)
69 log.Errorf(ctx, "base64.StdEncoding.DecodeString(%q) = %v", rawSig, err)
73 mac := hmac.New(sha1.New, []byte(oauthConfig().ClientSecret+"&"))
75 signatureWant := mac.Sum(nil)
77 if !hmac.Equal(signatureGot, signatureWant) {
78 log.Debugf(ctx, "CheckSignature(): got %q, want %q",
79 hex.EncodeToString(signatureGot),
80 hex.EncodeToString(signatureWant))
83 return hmac.Equal(signatureGot, signatureWant)
86 type Activity struct {
87 ActivityID int `json:"activityId"`
88 ActivityParentID int `json:"activityParentId"`
89 ActivityParentName string `json:"activityParentName"`
90 Calories int `json:"calories"`
91 Description string `json:"description"`
92 Distance float64 `json:"distance"`
93 Duration int `json:"duration"`
94 HasStartTime bool `json:"hasStartTime"`
95 IsFavorite bool `json:"isFavorite"`
96 LastModified time.Time `json:"lastModified"`
97 LogID int `json:"logId"`
98 Name string `json:"name"`
99 StartTime string `json:"startTime"`
100 StartDate string `json:"startDate"`
101 Steps int `json:"steps"`
104 type Distance struct {
105 Activity string `json:"activity"`
106 Distance float64 `json:"distance"`
109 type HeartRateZone struct {
110 Name string `json:"name"`
113 Minutes int `json:"minutes"`
114 CaloriesOut float64 `json:"caloriesOut"`
117 type ActivitySummary struct {
118 Activities []Activity `json:"activities"`
120 CaloriesOut int `json:"caloriesOut"`
121 Distance float64 `json:"distance"`
122 Floors int `json:"floors"`
123 Steps int `json:"steps"`
126 ActiveScore int `json:"activeScore"`
127 ActivityCalories int `json:"activityCalories"`
128 CaloriesBMR int `json:"caloriesBMR"`
129 CaloriesOut float64 `json:"caloriesOut"`
130 Distances []Distance `json:"distances"`
131 Elevation float64 `json:"elevation"`
132 Floors int `json:"floors"`
133 HeartRateZones []HeartRateZone `json:"heartRateZones"`
134 CustomHeartRateZones []HeartRateZone `json:"customHeartRateZones"`
135 MarginalCalories int `json:"marginalCalories"`
136 RestingHeartRate int `json:"restingHeartRate"`
137 Steps int `json:"steps"`
138 SedentaryMinutes int `json:"sedentaryMinutes"`
139 LightlyActiveMinutes int `json:"lightlyActiveMinutes"`
140 FairlyActiveMinutes int `json:"fairlyActiveMinutes"`
141 VeryActiveMinutes int `json:"veryActiveMinutes"`
145 type Subscription struct {
146 CollectionType string `json:"collectionType"`
147 Date string `json:"date"`
148 OwnerID string `json:"ownerId"`
149 OwnerType string `json:"ownerType"`
150 SubscriptionID string `json:"subscriptionId"`
153 func (s Subscription) String() string {
154 return fmt.Sprintf("https://api.fitbit.com/1/%s/%s/%s/apiSubscriptions/%s.json",
155 s.OwnerType, s.OwnerID, s.CollectionType, s.SubscriptionID)
164 func NewClient(ctx context.Context, fitbitUserID string, u *app.User) (*Client, error) {
165 if fitbitUserID == "" {
169 c, err := u.OAuthClient(ctx, "Fitbit", oauthConfig())
171 return nil, fmt.Errorf("OAuthClient(%q) = %v", "Fitbit", err)
175 fitbitUserID: fitbitUserID,
181 // ActivitySummary returns the daily activity summary.
183 // See https://dev.fitbit.com/build/reference/web-api/activity/#get-daily-activity-summary for details.
184 func (c *Client) ActivitySummary(ctx context.Context, date string) (*ActivitySummary, error) {
185 url := fmt.Sprintf("https://api.fitbit.com/1/user/%s/activities/date/%s.json",
186 c.fitbitUserID, date)
188 res, err := c.client.Get(url)
192 defer res.Body.Close()
194 data, err := ioutil.ReadAll(res.Body)
198 log.Debugf(ctx, "GET %s -> %s", url, data)
200 var summary ActivitySummary
201 if err := json.Unmarshal(data, &summary); err != nil {
208 func (c *Client) subscriberID(collection string) string {
209 return fmt.Sprintf("%s:%s", c.appUser.ID, collection)
212 // UserFromSubscriberID parses the user ID from the subscriber ID and calls
213 // app.UserByID() with the user ID.
214 func UserFromSubscriberID(ctx context.Context, subscriberID string) (*app.User, error) {
215 uid := strings.Split(subscriberID, ":")[0]
216 return app.UserByID(ctx, uid)
219 // Subscribe subscribes to one collection of the user. It uses a per-collection
220 // subscription ID so that we can subscribe to more than one collection.
222 // See https://dev.fitbit.com/build/reference/web-api/subscriptions/#adding-a-subscription for details.
223 func (c *Client) Subscribe(ctx context.Context, collection string) error {
224 url := fmt.Sprintf("https://api.fitbit.com/1/user/%s/%s/apiSubscriptions/%s.json",
225 c.fitbitUserID, collection, c.subscriberID(collection))
226 res, err := c.client.Post(url, "", nil)
230 defer res.Body.Close()
232 if res.StatusCode >= 400 && res.StatusCode != http.StatusConflict {
233 data, _ := ioutil.ReadAll(res.Body)
234 return fmt.Errorf("creating %q subscription failed: status %d %q", collection, res.StatusCode, data)
236 if res.StatusCode == http.StatusConflict {
237 log.Infof(ctx, "creating %q subscription: already exists", collection)
243 func (c *Client) unsubscribe(ctx context.Context, userID, collection, subscriptionID string) error {
245 userID = c.fitbitUserID
248 url := fmt.Sprintf("https://api.fitbit.com/1/user/%s/%s/apiSubscriptions/%s.json",
249 userID, collection, subscriptionID)
250 req, err := http.NewRequest(http.MethodDelete, url, nil)
255 res, err := c.client.Do(req.WithContext(ctx))
259 defer res.Body.Close()
261 if res.StatusCode >= 400 && res.StatusCode != http.StatusNotFound {
262 data, _ := ioutil.ReadAll(res.Body)
263 return fmt.Errorf("deleting %q subscription failed: status %d %q", collection, res.StatusCode, data)
265 if res.StatusCode == http.StatusNotFound {
266 log.Infof(ctx, "deleting %q subscription: not found", collection)
272 // UnsubscribeAll gets a list of all subscriptions we have with the user's
273 // account and deletes all found subscriptions.
275 // See https://dev.fitbit.com/build/reference/web-api/subscriptions/#deleting-a-subscription for details.
276 func (c *Client) UnsubscribeAll(ctx context.Context) error {
277 var errs appengine.MultiError
279 for _, collection := range []string{"activities", "sleep"} {
280 subs, err := c.ListSubscriptions(ctx, collection)
282 errs = append(errs, err)
286 for _, sub := range subs {
287 if err := c.unsubscribe(ctx, sub.OwnerID, sub.CollectionType, sub.SubscriptionID); err != nil {
288 errs = append(errs, err)
299 // ListSubscriptions returns a list of all subscriptions for a given collection
300 // the OAuth2 client has to a user's account.
301 func (c *Client) ListSubscriptions(ctx context.Context, collection string) ([]Subscription, error) {
302 url := fmt.Sprintf("https://api.fitbit.com/1/user/%s/%s/apiSubscriptions.json", c.fitbitUserID, collection)
303 res, err := c.client.Get(url)
305 return nil, fmt.Errorf("Get(%q) = %v", url, err)
307 defer res.Body.Close()
309 if res.StatusCode == http.StatusNotFound {
310 log.Infof(ctx, "get %q subscription: not found", collection)
314 data, err := ioutil.ReadAll(res.Body)
318 log.Debugf(ctx, "GET %s -> %s", url, data)
320 if res.StatusCode >= 400 {
321 return nil, fmt.Errorf("Get(%q) = %d", url, res.StatusCode)
325 Subscriptions []Subscription `json:"apiSubscriptions"`
327 if err := json.Unmarshal(data, &parsed); err != nil {
331 var errs appengine.MultiError
332 var ret []Subscription
333 for _, sub := range parsed.Subscriptions {
334 if sub.CollectionType != collection {
335 errs = append(errs, fmt.Errorf("unexpected collection type: got %q, want %q", sub.CollectionType, collection))
338 if sub.SubscriptionID == "" {
339 errs = append(errs, fmt.Errorf("missing subscription ID: %+v", sub))
342 if sub.OwnerID == "" {
343 sub.OwnerID = c.fitbitUserID
345 ret = append(ret, sub)
348 if len(ret) == 0 && len(errs) != 0 {
352 for _, err := range errs {
353 log.Warningf(ctx, "%v", err)
359 func (c *Client) revokeToken(ctx context.Context) error {
360 tok, err := c.appUser.Token(ctx, "Fitbit")
365 httpClient := urlfetch.Client(ctx)
366 httpClient.Transport = retry.NewTransport(httpClient.Transport)
368 url := "https://api.fitbit.com/oauth2/revoke?token=" + url.QueryEscape(tok.AccessToken)
369 req, err := http.NewRequest(http.MethodGet, url, nil)
373 req.Header.Set("Authorization", "Basic "+
374 base64.StdEncoding.EncodeToString([]byte(
375 app.Config.FitbitClientID+":"+app.Config.FitbitClientSecret)))
377 res, err := httpClient.Do(req)
379 return fmt.Errorf("GET %s: %v", url, err)
381 defer res.Body.Close()
383 if res.StatusCode != http.StatusOK {
384 if data, err := ioutil.ReadAll(res.Body); err == nil {
385 return fmt.Errorf("GET %s: %s", url, data)
387 return fmt.Errorf("GET %s: %s", url, res.Status)
394 // DeleteToken deletes the Fitbit OAuth2 token.
395 func (c *Client) DeleteToken(ctx context.Context) error {
396 if err := c.revokeToken(ctx); err != nil {
397 log.Warningf(ctx, "revokeToken() = %v", err)
400 return c.appUser.DeleteToken(ctx, "Fitbit")
403 // Provile contains data about the user.
404 // It only contains the subset of fields required by Kraftakt.
405 type Profile struct {
407 Timezone *time.Location
410 // Profile returns the profile information of the user.
411 func (c *Client) Profile(ctx context.Context) (*Profile, error) {
412 res, err := c.client.Get("https://api.fitbit.com/1/user/-/profile.json")
416 defer res.Body.Close()
418 if res.StatusCode >= 400 {
419 data, _ := ioutil.ReadAll(res.Body)
420 return nil, fmt.Errorf("reading profile failed: %s", data)
426 OffsetFromUTCMillis int
430 if err := json.NewDecoder(res.Body).Decode(&data); err != nil {
434 loc, err := time.LoadLocation(data.User.Timezone)
436 loc = time.FixedZone("Fitbit preference", data.User.OffsetFromUTCMillis/1000)
440 Name: data.User.FullName,