Skip to content
This repository has been archived by the owner on Oct 12, 2023. It is now read-only.

Commit

Permalink
Merge pull request #7 from lildude/oura-api-v2
Browse files Browse the repository at this point in the history
Add support for Oura API v2
  • Loading branch information
lildude committed Apr 15, 2022
2 parents 308a999 + fbced69 commit 5d9cc83
Show file tree
Hide file tree
Showing 30 changed files with 1,132 additions and 146 deletions.
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

![Tests Status Badge](https://github.com/lildude/oura/workflows/Tests/badge.svg)

An unofficial Go client for the [Oura Cloud API](https://cloud.ouraring.com/docs/).
An unofficial Go client for the [Oura Cloud API v1](https://cloud.ouraring.com/docs/) and [Oura Cloud API v2](https://cloud.ouraring.com/v2/docs/).

## Installation

Expand All @@ -16,7 +16,7 @@ go get -u 'github.com/lildude/oura'

Depending on your requirements, you will need an access token to query the API. This can be a personal access token or a full OAuth2 authenticated access token.

See the section on Authentication in the [Oura Cloud API Docs](https://cloud.ouraring.com/docs) for more information the authentication methods.
See the section on Authentication in the [Oura Cloud API Docs](https://cloud.ouraring.com/v2/docs) for more information the authentication methods.

The simplest approach for accessing your own data is to use a personal access token like this:

Expand All @@ -41,15 +41,16 @@ func main() {

cl := oura.NewClient(tc)

userInfo, _, err := cl.GetUserInfo(ctx)
info, _, err := cl.PersonalInfo(ctx)
if err != nil {
fmt.Println(err)
}
fmt.Println(userInfo.Age, userInfo.Gender, userInfo.Weight, userInfo.Email)
fmt.Println(info.Age, info.Gender, info.Weight, info.Email)
}
```

This library supports both v1 and v2 of the Oura API. Function names are in the plural form, where appropriate, with the v1 API calls prefixed with `Get`. For example, `GetActivities` queries the v1 API, and `DailyActivities` queries the v2 API. `GetUserInfo` queries the v1 API and `PersonalInfo` queries the v2 API.

## Releasing

This project uses [GoReleaser](https://goreleaser.com) via GitHub Actions to make the releases quick and easy. When I'm ready for a new release, I push a new tag and the workflow takes care of things.

4 changes: 2 additions & 2 deletions activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ type Activities struct {
// "If you omit the start date, it will be set to one week ago.
// If you omit the end date, it will be set to the current day."
func (c *Client) GetActivities(ctx context.Context, start string, end string) (*Activities, *http.Response, error) {
path := "activity"
path := "v1/activity"
params := url.Values{}

if start != "" {
Expand All @@ -78,7 +78,7 @@ func (c *Client) GetActivities(ctx context.Context, start string, end string) (*
}

var activities *Activities
resp, err := c.Do(ctx, req, &activities)
resp, err := c.do(ctx, req, &activities)
if err != nil {
return activities, resp, err
}
Expand Down
10 changes: 5 additions & 5 deletions activity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ var activitiesTestCases = []struct {
name: "get activity without specific dates",
start: "",
end: "",
expectedURL: "/activity",
expectedURL: "/v1/activity",
mock: `{
"activity": [{
"summary_date": "2016-09-03",
Expand Down Expand Up @@ -62,7 +62,7 @@ var activitiesTestCases = []struct {
name: "get activity with only start date",
start: "2020-01-20",
end: "",
expectedURL: "/activity?start=2020-01-20",
expectedURL: "/v1/activity?start=2020-01-20",
mock: `{
"activity": [
{"summary_date": "2020-01-20"},
Expand All @@ -76,7 +76,7 @@ var activitiesTestCases = []struct {
name: "get activity with start and end dates",
start: "2020-01-20",
end: "2020-01-22",
expectedURL: "/activity?end=2020-01-22&start=2020-01-20",
expectedURL: "/v1/activity?end=2020-01-22&start=2020-01-20",
mock: `{
"activity": [
{"summary_date": "2020-01-20"},
Expand All @@ -99,7 +99,7 @@ func testGetActivities(t *testing.T, start, end, expectedURL, mock string) {
client, mux, _, teardown := setup()
defer teardown()

mux.HandleFunc("/activity", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/v1/activity", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, expectedURL, r.URL.String())
fmt.Fprint(w, mock)
Expand All @@ -109,7 +109,7 @@ func testGetActivities(t *testing.T, start, end, expectedURL, mock string) {
assert.NoError(t, err, "should not return an error")

want := &Activities{}
json.Unmarshal([]byte(mock), want)
json.Unmarshal([]byte(mock), want) //nolint:errcheck

assert.ObjectsAreEqual(want, got)
}
4 changes: 2 additions & 2 deletions bedtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ type IdealBedtimes struct {
// "If you omit the start date, it will be set to one week ago.
// If you omit the end date, it will be set to the current day."
func (c *Client) GetBedtime(ctx context.Context, start string, end string) (*IdealBedtimes, *http.Response, error) {
path := "bedtime"
path := "v1/bedtime"
params := url.Values{}

if start != "" {
Expand All @@ -46,7 +46,7 @@ func (c *Client) GetBedtime(ctx context.Context, start string, end string) (*Ide
}

var bedtimes *IdealBedtimes
resp, err := c.Do(ctx, req, &bedtimes)
resp, err := c.do(ctx, req, &bedtimes)
if err != nil {
return bedtimes, resp, err
}
Expand Down
10 changes: 5 additions & 5 deletions bedtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ var bedtimeTestCases = []struct {
name: "get bedtime without specific dates",
start: "",
end: "",
expectedURL: "/bedtime",
expectedURL: "/v1/bedtime",
mock: `{
"ideal_bedtimes": [
{
Expand All @@ -47,7 +47,7 @@ var bedtimeTestCases = []struct {
name: "get bedtime with only start date",
start: "2020-01-20",
end: "",
expectedURL: "/bedtime?start=2020-01-20",
expectedURL: "/v1/bedtime?start=2020-01-20",
mock: `{
"ideal_bedtimes": [
{
Expand All @@ -73,7 +73,7 @@ var bedtimeTestCases = []struct {
name: "get bedtime with start and end dates",
start: "2020-01-20",
end: "2020-01-22",
expectedURL: "/bedtime?end=2020-01-22&start=2020-01-20",
expectedURL: "/v1/bedtime?end=2020-01-22&start=2020-01-20",
mock: `{
"ideal_bedtimes": [
{
Expand Down Expand Up @@ -105,7 +105,7 @@ func testGetBedtime(t *testing.T, start, end, expectedURL, mock string) {
client, mux, _, teardown := setup()
defer teardown()

mux.HandleFunc("/bedtime", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/v1/bedtime", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodGet, r.Method)
assert.Equal(t, expectedURL, r.URL.String())
fmt.Fprint(w, mock)
Expand All @@ -115,7 +115,7 @@ func testGetBedtime(t *testing.T, start, end, expectedURL, mock string) {
assert.NoError(t, err, "should not return an error")

want := &IdealBedtimes{}
json.Unmarshal([]byte(mock), want)
json.Unmarshal([]byte(mock), want) //nolint:errcheck

assert.ObjectsAreEqual(want, got)
}
120 changes: 71 additions & 49 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,18 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/url"
"strings"

"github.com/pkg/errors"
"time"
)

var (
// BaseURLV1 is Oura's v1 API endpoint
BaseURLV1 = "https://api.ouraring.com/v1/"
BaseURL = "https://api.ouraring.com/"
userAgent = "go-oura"
)

Expand All @@ -32,12 +31,11 @@ type Client struct {
// provided, http.DefaultClient will be used. To use API methods which require
// authentication, provide an http.Client that will perform the authentication
// for you (such as that provided by the golang.org/x/oauth2 library).
// Inspiration: https://github.com/google/go-github/blob/master/github/github.go
func NewClient(cc *http.Client) *Client {
if cc == nil {
cc = http.DefaultClient
}
baseURL, _ := url.Parse(BaseURLV1)
baseURL, _ := url.Parse(BaseURL)

c := &Client{baseURL: baseURL, UserAgent: userAgent, client: cc}
return c
Expand All @@ -46,7 +44,6 @@ func NewClient(cc *http.Client) *Client {
// NewRequest creates an HTTP Request. The client baseURL is checked to confirm that it has a trailing
// slash. A relative URL should be provided without the leading slash. If a non-nil body is provided
// it will be JSON encoded and included in the request.
// Inspiration: https://github.com/google/go-github/blob/master/github/github.go
func (c *Client) NewRequest(method, urlStr string, body interface{}) (*http.Request, error) {
if !strings.HasSuffix(c.baseURL.Path, "/") {
return nil, fmt.Errorf("client baseURL does not have a trailing slash: %q", c.baseURL)
Expand Down Expand Up @@ -86,61 +83,29 @@ func (c *Client) NewRequest(method, urlStr string, body interface{}) (*http.Requ
// Do sends a request and returns the response. An error is returned if the request cannot
// be sent or if the API returns an error. If a response is received, the body response body
// is decoded and stored in the value pointed to by v.
// Inspiration: https://github.com/google/go-github/blob/master/github/github.go
func (c *Client) Do(ctx context.Context, req *http.Request, v interface{}) (*http.Response, error) {
func (c *Client) do(ctx context.Context, req *http.Request, v interface{}) (*http.Response, error) {
req = req.WithContext(ctx)
resp, err := c.client.Do(req)

if err != nil {
select {
case <-ctx.Done():
return nil, errors.Wrap(err, ctx.Err().Error())
default:
return nil, err
}
return nil, err
}

data, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, errors.Wrap(err, "unable to read body")
return nil, err
}
resp.Body.Close()

// Anything other than a HTTP 2xx response code is treated as an error. But the structure of error
// responses differs depending on the API being called. Some APIs return validation errors as part
// of the standard response. Others respond with a standardised error structure.
if c := resp.StatusCode; c >= 300 {

// Handle auth errors
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
err := AuthError(http.StatusText(resp.StatusCode))
return resp, err
}

// Try parsing the response using the standard error schema. If this fails we wrap the parsing
// error and return. Otherwise return the errors included in the API response payload.
var e = Errors{}
// Anything other than a HTTP 2xx response code is treated as an error.
if resp.StatusCode >= 300 {
e := errorDetail{}
err := json.Unmarshal(data, &e)
if err != nil {
err = errors.Wrap(err, http.StatusText(resp.StatusCode))
return resp, errors.Wrap(err, "unable to parse API error response")
}

if len(e) != 0 {
return resp, errors.Wrap(e, http.StatusText(resp.StatusCode))
}

// In some cases, the error response is returned as part of the
// requested resource. In these cases we attempt to decode the
// resource and return the error.
err = json.Unmarshal(data, v)
if err != nil {
err = errors.Wrap(err, http.StatusText(resp.StatusCode))
return resp, errors.Wrap(err, "unable to parse API response")
return resp, err
}

err = errors.New("no additional error information available")
return resp, errors.Wrap(err, http.StatusText(resp.StatusCode))
err = errors.New(http.StatusText(resp.StatusCode) + ": " + e.Detail)
return resp, err
}

if v != nil && len(data) != 0 {
Expand All @@ -151,9 +116,66 @@ func (c *Client) Do(ctx context.Context, req *http.Request, v interface{}) (*htt
case io.EOF:
err = nil
default:
err = errors.Wrap(err, "unable to parse API response")
}
}

return resp, err
}

// timeSeriesData is time series data used by various other methods.
type timeSeriesData struct {
Interval float32 `json:"interval"`
Items []float32 `json:"items"`
Timestamp time.Time `json:"timestamp"`
}

func (e *errorDetail) Error() string {
return e.Detail
}

// errorDetail holds the details of an error message
type errorDetail struct {
Status int `json:"status,omitempty"`
Title string `json:"title,omitempty"`
Detail string `json:"detail"`
}

// parametiseDate takes the arguments and URL encodes them into a string
// where the dates are ISO 8601 date strings without times.
func parametiseDate(path, start, end, next string) string {
params := url.Values{}

if start != "" {
params.Add("start_date", start)
}
if end != "" {
params.Add("end_date", end)
}
if next != "" {
params.Add("next_token", next)
}
if len(params) > 0 {
path += fmt.Sprintf("?%s", params.Encode())
}
return path
}

// parametiseDate takes the arguments and URL encodes them into a string
// where the dates are ISO 8601 date strings with times.
func parametiseDatetime(path, start, end, next string) string {
params := url.Values{}

if start != "" {
params.Add("start_datetime", start)
}
if end != "" {
params.Add("end_datetime", end)
}
if next != "" {
params.Add("next_token", next)
}
if len(params) > 0 {
path += fmt.Sprintf("?%s", params.Encode())
}
return path
}
Loading

0 comments on commit 5d9cc83

Please sign in to comment.