Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

lift Repeat out of the base alert config #716

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 33 additions & 6 deletions alerts/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

"github.com/tidepool-org/platform/auth"
"github.com/tidepool-org/platform/client"
"github.com/tidepool-org/platform/errors"
platformlog "github.com/tidepool-org/platform/log"
"github.com/tidepool-org/platform/log/null"
"github.com/tidepool-org/platform/platform"
Expand Down Expand Up @@ -43,6 +44,8 @@ type PlatformClient interface {
requestBody interface{}, responseBody interface{}, inspectors ...request.ResponseInspector) error
}

// TokenProvider retrieves session tokens for calling the alerts API.
//
// client.External is one implementation
type TokenProvider interface {
// ServerSessionToken provides a server-to-server API authentication token.
Expand All @@ -51,12 +54,12 @@ type TokenProvider interface {

// request performs common operations before passing a request off to the
// underlying platform.Client.
func (c *Client) request(ctx context.Context, method, url string, body any) error {
func (c *Client) request(ctx context.Context, method, url string, reqBody, resBody any) error {
// Platform's client.Client expects a logger to exist in the request's
// context. If it doesn't exist, request processing will panic.
loggingCtx := platformlog.NewContextWithLogger(ctx, c.logger)
// Make sure the auth token is injected into the request's headers.
return c.requestWithAuth(loggingCtx, method, url, body)
return c.requestWithAuth(loggingCtx, method, url, reqBody, resBody)
}

// requestWithAuth injects an auth token before calling platform.Client.RequestData.
Expand All @@ -65,24 +68,48 @@ func (c *Client) request(ctx context.Context, method, url string, body any) erro
// platform.Client. It might be nice to be able to use a mutator, but the auth
// is specifically handled by the platform.Client via the context field, and
// if left blank, platform.Client errors.
func (c *Client) requestWithAuth(ctx context.Context, method, url string, body any) error {
func (c *Client) requestWithAuth(ctx context.Context, method, url string, reqBody, resBody any) error {
authCtx, err := c.ctxWithAuth(ctx)
if err != nil {
return err
}
return c.client.RequestData(authCtx, method, url, nil, body, nil)
return c.client.RequestData(authCtx, method, url, nil, reqBody, resBody)
}

// Upsert updates cfg if it exists or creates it if it doesn't.
func (c *Client) Upsert(ctx context.Context, cfg *Config) error {
url := c.client.ConstructURL("v1", "users", cfg.FollowedUserID, "followers", cfg.UserID, "alerts")
return c.request(ctx, http.MethodPost, url, cfg)
return c.request(ctx, http.MethodPost, url, cfg, nil)
}

// Delete the alerts config.
func (c *Client) Delete(ctx context.Context, cfg *Config) error {
url := c.client.ConstructURL("v1", "users", cfg.FollowedUserID, "followers", cfg.UserID, "alerts")
return c.request(ctx, http.MethodDelete, url, nil)
return c.request(ctx, http.MethodDelete, url, nil, nil)
}

// Get a user's alerts configuration for the followed user.
func (c *Client) Get(ctx context.Context, followedUserID, userID string) (*Config, error) {
url := c.client.ConstructURL("v1", "users", followedUserID, "followers", userID, "alerts")
cfg := &Config{}
err := c.request(ctx, http.MethodGet, url, nil, cfg)
if err != nil {
return nil, errors.Wrap(err, "Unable to request alerts config")
}
return cfg, nil
}

// List the alerts configurations that follow the given user.
//
// This method should only be called via an authenticated service session.
func (c *Client) List(ctx context.Context, followedUserID string) ([]*Config, error) {
url := c.client.ConstructURL("v1", "users", followedUserID, "followers", "alerts")
configs := []*Config{}
err := c.request(ctx, http.MethodGet, url, nil, &configs)
if err != nil {
return nil, errors.Wrap(err, "Unable to request alerts configs list")
}
return configs, nil
}

// ctxWithAuth injects a server session token into the context.
Expand Down
29 changes: 18 additions & 11 deletions alerts/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,16 +72,10 @@ func (a Alerts) Validate(validator structure.Validator) {
type Base struct {
// Enabled controls whether notifications should be sent for this alert.
Enabled bool `json:"enabled" bson:"enabled"`
// Repeat is measured in minutes.
//
// A value of 0 (the default) disables repeat notifications.
Repeat DurationMinutes `json:"repeat,omitempty" bson:"repeat"`
}

func (b Base) Validate(validator structure.Validator) {
validator.Bool("enabled", &b.Enabled)
dur := b.Repeat.Duration()
validator.Duration("repeat", &dur).Using(validateRepeat)
}

const (
Expand Down Expand Up @@ -110,7 +104,7 @@ type UrgentLowAlert struct {
Base `bson:",inline"`
// Threshold is compared the current value to determine if an alert should
// be triggered.
Threshold `json:"threshold"`
Threshold `json:"threshold" bson:"threshold"`
}

func (a UrgentLowAlert) Validate(validator structure.Validator) {
Expand Down Expand Up @@ -149,13 +143,19 @@ type LowAlert struct {
// be triggered.
Threshold `json:"threshold"`
Delay DurationMinutes `json:"delay,omitempty"`
// Repeat is measured in minutes.
//
// A value of 0 (the default) disables repeat notifications.
Repeat DurationMinutes `json:"repeat,omitempty" bson:"repeat"`
}

func (a LowAlert) Validate(validator structure.Validator) {
a.Base.Validate(validator)
dur := a.Delay.Duration()
validator.Duration("delay", &dur).InRange(0, 2*time.Hour)
delayDur := a.Delay.Duration()
validator.Duration("delay", &delayDur).InRange(0, 2*time.Hour)
a.Threshold.Validate(validator)
repeatDur := a.Repeat.Duration()
validator.Duration("repeat", &repeatDur).Using(validateRepeat)
}

// HighAlert extends Base with a threshold and a delay.
Expand All @@ -165,13 +165,19 @@ type HighAlert struct {
// be triggered.
Threshold `json:"threshold"`
Delay DurationMinutes `json:"delay,omitempty"`
// Repeat is measured in minutes.
//
// A value of 0 (the default) disables repeat notifications.
Repeat DurationMinutes `json:"repeat,omitempty" bson:"repeat"`
}

func (a HighAlert) Validate(validator structure.Validator) {
a.Base.Validate(validator)
a.Threshold.Validate(validator)
dur := a.Delay.Duration()
validator.Duration("delay", &dur).InRange(0, 6*time.Hour)
delayDur := a.Delay.Duration()
validator.Duration("delay", &delayDur).InRange(0, 6*time.Hour)
repeatDur := a.Repeat.Duration()
validator.Duration("repeat", &repeatDur).Using(validateRepeat)
}

// DurationMinutes reads a JSON integer and converts it to a time.Duration.
Expand Down Expand Up @@ -239,6 +245,7 @@ type Repository interface {
Get(ctx context.Context, conf *Config) (*Config, error)
Upsert(ctx context.Context, conf *Config) error
Delete(ctx context.Context, conf *Config) error
List(ctx context.Context, userID string) ([]*Config, error)

EnsureIndexes() error
}
106 changes: 55 additions & 51 deletions alerts/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ var _ = Describe("Config", func() {
},
"urgentLow": {
"enabled": false,
"repeat": 30,
"threshold": {
"units": "mg/dL",
"value": 47.5
Expand All @@ -60,12 +59,10 @@ var _ = Describe("Config", func() {
},
"notLooping": {
"enabled": true,
"repeat": 32,
"delay": 4
},
"noCommunication": {
"enabled": true,
"repeat": 33,
"delay": 6
}
}`, mockUserID1, mockUserID2, mockUploadID)
Expand All @@ -86,14 +83,11 @@ var _ = Describe("Config", func() {
Expect(conf.Low.Threshold.Value).To(Equal(80.0))
Expect(conf.Low.Threshold.Units).To(Equal(glucose.MgdL))
Expect(conf.UrgentLow.Enabled).To(Equal(false))
Expect(conf.UrgentLow.Repeat).To(Equal(DurationMinutes(30 * time.Minute)))
Expect(conf.UrgentLow.Threshold.Value).To(Equal(47.5))
Expect(conf.UrgentLow.Threshold.Units).To(Equal(glucose.MgdL))
Expect(conf.NotLooping.Enabled).To(Equal(true))
Expect(conf.NotLooping.Repeat).To(Equal(DurationMinutes(32 * time.Minute)))
Expect(conf.NotLooping.Delay).To(Equal(DurationMinutes(4 * time.Minute)))
Expect(conf.NoCommunication.Enabled).To(Equal(true))
Expect(conf.NoCommunication.Repeat).To(Equal(DurationMinutes(33 * time.Minute)))
Expect(conf.NoCommunication.Delay).To(Equal(DurationMinutes(6 * time.Minute)))
})

Expand Down Expand Up @@ -322,32 +316,41 @@ var _ = Describe("Config", func() {
})

Context("repeat", func() {
var defaultAlert = LowAlert{
Threshold: Threshold{Value: 11, Units: glucose.MmolL},
}

It("accepts values of 0 (indicating disabled)", func() {
val := validator.New()
b := Base{Repeat: 0}
b.Validate(val)
l := defaultAlert
l.Repeat = 0
l.Validate(val)
Expect(val.Error()).To(Succeed())
})

It("accepts values of 15 minutes to 4 hours (inclusive)", func() {
val := validator.New()
b := Base{Repeat: DurationMinutes(15 * time.Minute)}
b.Validate(val)
l := defaultAlert
l.Repeat = DurationMinutes(15 * time.Minute)
l.Validate(val)
Expect(val.Error()).To(Succeed())

val = validator.New()
b = Base{Repeat: DurationMinutes(4 * time.Hour)}
b.Validate(val)
l = defaultAlert
l.Repeat = DurationMinutes(4 * time.Hour)
l.Validate(val)
Expect(val.Error()).To(Succeed())

val = validator.New()
b = Base{Repeat: DurationMinutes(4*time.Hour + 1)}
b.Validate(val)
l = defaultAlert
l.Repeat = DurationMinutes(4*time.Hour + 1)
l.Validate(val)
Expect(val.Error()).NotTo(Succeed())

val = validator.New()
b = Base{Repeat: DurationMinutes(15*time.Minute - 1)}
b.Validate(val)
l = defaultAlert
l.Repeat = DurationMinutes(15*time.Minute - 1)
l.Validate(val)
Expect(val.Error()).NotTo(Succeed())
})
})
Expand All @@ -359,67 +362,68 @@ var _ = Describe("Config", func() {
err := request.DecodeObject(nil, buf, threshold)
Expect(err).To(MatchError("json is malformed"))
})
It("validates repeat minutes (negative)", func() {
})

Context("low", func() {
It("accepts a blank repeat", func() {
buf := buff(`{
"userId": "%s",
"followedUserId": "%s",
"uploadId": "%s",
"urgentLow": {
"enabled": false,
"repeat": -11,
"low": {
"enabled": true,
"delay": 10,
"threshold": {
"units": "%s",
"value": 47.5
"units": "mg/dL",
"value": 80
}
}
}`, mockUserID1, mockUserID2, mockUploadID, glucose.MgdL)
cfg := &Config{}
err := request.DecodeObject(nil, buf, cfg)
Expect(err).To(MatchError("value -11m0s is not greater than or equal to 15m0s"))
}`, mockUserID1, mockUserID2, mockUploadID)
conf := &Config{}
err := request.DecodeObject(nil, buf, conf)
Expect(err).To(Succeed())
Expect(conf.Low.Repeat).To(Equal(DurationMinutes(0)))
})
It("validates repeat minutes (string)", func() {
buf := buff(`{
})
It("validates repeat minutes (negative)", func() {
buf := buff(`{
"userId": "%s",
"followedUserId": "%s",
"urgentLow": {
"uploadId": "%s",
"low": {
"enabled": false,
"repeat": "a",
"repeat": -11,
"threshold": {
"units": "%s",
"value": 1
"value": 47.5
}
}
}`, mockUserID1, mockUserID2, glucose.MgdL)
cfg := &Config{}
err := request.DecodeObject(nil, buf, cfg)
Expect(err).To(MatchError("json is malformed"))
})
}`, mockUserID1, mockUserID2, mockUploadID, glucose.MgdL)
cfg := &Config{}
err := request.DecodeObject(nil, buf, cfg)
Expect(err).To(MatchError("value -11m0s is not greater than or equal to 15m0s"))
})

Context("low", func() {
It("accepts a blank repeat", func() {
buf := buff(`{
It("validates repeat minutes (string)", func() {
buf := buff(`{
"userId": "%s",
"followedUserId": "%s",
"uploadId": "%s",
"low": {
"enabled": true,
"delay": 10,
"enabled": false,
"repeat": "a",
"threshold": {
"units": "mg/dL",
"value": 80
"units": "%s",
"value": 1
}
}
}`, mockUserID1, mockUserID2, mockUploadID)
conf := &Config{}
err := request.DecodeObject(nil, buf, conf)
Expect(err).To(Succeed())
Expect(conf.Low.Repeat).To(Equal(DurationMinutes(0)))
})
}`, mockUserID1, mockUserID2, mockUploadID, glucose.MgdL)
cfg := &Config{}
err := request.DecodeObject(nil, buf, cfg)
Expect(err).To(MatchError("json is malformed"))
})
})

var _ = Describe("Duration", func() {
var _ = Describe("DurationMinutes", func() {
It("parses 42", func() {
d := DurationMinutes(0)
err := d.UnmarshalJSON([]byte(`42`))
Expand Down
Loading