Skip to content

Commit

Permalink
add endpoints for storing push notification device tokens
Browse files Browse the repository at this point in the history
The mongodb collection maintains a unique index on the combination of the
userID and some key value for the token itself (in this case a SHA-256
checksum hash). This allows for a simple upsert whenever a key is received,
without having to check for duplicates or worry about appending to a list or
anything like that. If the received key is a duplicate, it will replace the old
one. Otherwise a new one is inserted.

While only Apple device tokens are supported now, care has been taken to allow
for other manufacturers' tokens in the future. In theory, that would mean
adding a property to the DeviceToken struct, presumably "Android". Then
modifying the DeviceToken's key() method to produce a key from either the
Apple token, or Android token, whichever is found. Validation should ensure
that only one or the other exists.

BACK-2506
  • Loading branch information
ewollesen committed Feb 15, 2024
1 parent 05c2492 commit a0f5a84
Show file tree
Hide file tree
Showing 15 changed files with 562 additions and 6 deletions.
45 changes: 45 additions & 0 deletions data/service/api/v1/devicetokens.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package v1

import (
data "github.com/tidepool-org/platform/data/service"
"github.com/tidepool-org/platform/devicetokens"
"github.com/tidepool-org/platform/request"
platform "github.com/tidepool-org/platform/service"
"github.com/tidepool-org/platform/service/api"
)

func DeviceTokensRoutes() []data.Route {
return []data.Route{
data.MakeRoute("POST", "/v1/device-tokens/:userID", UpsertDeviceToken, api.RequireAuth),
}
}

func UpsertDeviceToken(dCtx data.Context) {
r := dCtx.Request()
ctx := r.Context()
authDetails := request.GetAuthDetails(ctx)
repo := dCtx.DeviceTokensRepository()

if err := checkAuthentication(authDetails); err != nil {
dCtx.RespondWithError(platform.ErrorUnauthorized())
return
}

if err := checkUserIDConsistency(authDetails, r.PathParam("userID")); err != nil {
dCtx.RespondWithError(platform.ErrorUnauthorized())
return
}

deviceToken := devicetokens.DeviceToken{}
if err := request.DecodeRequestBody(r.Request, &deviceToken); err != nil {
dCtx.RespondWithError(platform.ErrorJSONMalformed())
return
}

userID := userIDWithServiceFallback(authDetails, r.PathParam("userID"))
doc := devicetokens.NewDocument(userID, deviceToken)
if err := repo.Upsert(ctx, doc); err != nil {
dCtx.RespondWithError(platform.ErrorInternalServerFailure())
return
}
}
119 changes: 119 additions & 0 deletions data/service/api/v1/devicetokens_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package v1

import (
"bytes"
"context"
"fmt"
"net/http"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"

"github.com/tidepool-org/platform/data/service/api/v1/mocks"
"github.com/tidepool-org/platform/devicetokens"
"github.com/tidepool-org/platform/request"
)

var _ = Describe("Device tokens endpoints", func() {

Describe("Upsert", func() {
It("succeeds with valid input", func() {
t := GinkgoT()
body := buff(`{"apple":{"environment":"sandbox","token":"b3BhcXVldG9rZW4="}}`)
dCtx := mocks.NewContext(t, "", "", body)
repo := newMockDeviceTokensRepo()
dCtx.MockDeviceTokensRepository = repo

UpsertDeviceToken(dCtx)

rec := dCtx.Recorder()
Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String())
})

It("rejects unauthenticated users", func() {
t := GinkgoT()
body := buff(`{"apple":{"environment":"sandbox","token":"blah"}}`)
dCtx := mocks.NewContext(t, "", "", body)
dCtx.MockAlertsRepository = newMockRepo()
badDetails := mocks.NewAuthDetails(request.MethodSessionToken, "", "")
dCtx.WithAuthDetails(badDetails)

UpsertDeviceToken(dCtx)

rec := dCtx.Recorder()
Expect(rec.Code).To(Equal(http.StatusForbidden))
})

It("accepts authenticated service users", func() {
t := GinkgoT()
body := buff(`{"apple":{"environment":"sandbox","token":"blah"}}`)
dCtx := mocks.NewContext(t, "", "", body)
dCtx.WithAuthDetails(mocks.ServiceAuthDetails())
dCtx.MockDeviceTokensRepository = newMockDeviceTokensRepo()

UpsertDeviceToken(dCtx)

rec := dCtx.Recorder()
Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String())
})

It("requires that the user's token matches the userID path param", func() {
t := GinkgoT()
dCtx := mocks.NewContext(t, "", "", nil)
dCtx.RESTRequest.PathParams["userID"] = "bad"
repo := newMockDeviceTokensRepo()
dCtx.MockDeviceTokensRepository = repo

UpsertDeviceToken(dCtx)

rec := dCtx.Recorder()
Expect(rec.Code).To(Equal(http.StatusForbidden))
})

It("errors on invalid JSON for device tokens", func() {
t := GinkgoT()
body := bytes.NewBuffer([]byte(`"improper JSON data"`))
dCtx := mocks.NewContext(t, "", "", body)
repo := newMockDeviceTokensRepo()
dCtx.MockDeviceTokensRepository = repo

UpsertDeviceToken(dCtx)

rec := dCtx.Recorder()
Expect(rec.Code).To(Equal(http.StatusBadRequest))
})

})

})

type mockDeviceTokensRepo struct {
UserID string
Error error
}

func newMockDeviceTokensRepo() *mockDeviceTokensRepo {
return &mockDeviceTokensRepo{}
}

func (r *mockDeviceTokensRepo) ReturnsError(err error) {
r.Error = err
}

func (r *mockDeviceTokensRepo) Upsert(ctx context.Context, conf *devicetokens.Document) error {
if r.Error != nil {
return r.Error
}
if conf != nil {
r.UserID = conf.UserID
}
return nil
}

func (r *mockDeviceTokensRepo) EnsureIndexes() error {
return nil
}

func buff(template string, args ...any) *bytes.Buffer {
return bytes.NewBuffer([]byte(fmt.Sprintf(template, args...)))
}
18 changes: 12 additions & 6 deletions data/service/api/v1/mocks/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (

"github.com/tidepool-org/platform/alerts"
"github.com/tidepool-org/platform/data/service/context"
"github.com/tidepool-org/platform/devicetokens"
"github.com/tidepool-org/platform/permission"
"github.com/tidepool-org/platform/request"
servicecontext "github.com/tidepool-org/platform/service/context"
Expand All @@ -21,12 +22,13 @@ type Context struct {

T likeT
// authDetails should be updated via the WithAuthDetails method.
authDetails *AuthDetails
RESTRequest *rest.Request
ResponseWriter rest.ResponseWriter
recorder *httptest.ResponseRecorder
MockAlertsRepository alerts.Repository
MockPermissionClient permission.Client
authDetails *AuthDetails
RESTRequest *rest.Request
ResponseWriter rest.ResponseWriter
recorder *httptest.ResponseRecorder
MockAlertsRepository alerts.Repository
MockDeviceTokensRepository devicetokens.Repository
MockPermissionClient permission.Client
}

func NewContext(t likeT, method, url string, body io.Reader) *Context {
Expand Down Expand Up @@ -96,6 +98,10 @@ func (c *Context) AlertsRepository() alerts.Repository {
return c.MockAlertsRepository
}

func (c *Context) DeviceTokensRepository() devicetokens.Repository {
return c.MockDeviceTokensRepository
}

func (c *Context) PermissionClient() permission.Client {
return c.MockPermissionClient
}
1 change: 1 addition & 0 deletions data/service/api/v1/v1.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ func Routes() []service.Route {
routes = append(routes, SourcesRoutes()...)
routes = append(routes, SummaryRoutes()...)
routes = append(routes, AlertsRoutes()...)
routes = append(routes, DeviceTokensRoutes()...)

return routes
}
2 changes: 2 additions & 0 deletions data/service/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
dataSource "github.com/tidepool-org/platform/data/source"
dataStore "github.com/tidepool-org/platform/data/store"
"github.com/tidepool-org/platform/data/summary"
"github.com/tidepool-org/platform/devicetokens"
"github.com/tidepool-org/platform/metric"
"github.com/tidepool-org/platform/permission"
"github.com/tidepool-org/platform/service"
Expand All @@ -27,6 +28,7 @@ type Context interface {
SummaryRepository() dataStore.SummaryRepository
SyncTaskRepository() syncTaskStore.SyncTaskRepository
AlertsRepository() alerts.Repository
DeviceTokensRepository() devicetokens.Repository

SummarizerRegistry() *summary.SummarizerRegistry
DataClient() dataClient.Client
Expand Down
12 changes: 12 additions & 0 deletions data/service/context/standard.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
dataSource "github.com/tidepool-org/platform/data/source"
dataStore "github.com/tidepool-org/platform/data/store"
"github.com/tidepool-org/platform/data/summary"
"github.com/tidepool-org/platform/devicetokens"
"github.com/tidepool-org/platform/errors"
"github.com/tidepool-org/platform/metric"
"github.com/tidepool-org/platform/permission"
Expand All @@ -35,6 +36,7 @@ type Standard struct {
dataClient dataClient.Client
dataSourceClient dataSource.Client
alertsRepository alerts.Repository
deviceTokensRepository devicetokens.Repository
}

func WithContext(authClient auth.Client, metricClient metric.Client, permissionClient permission.Client,
Expand Down Expand Up @@ -120,6 +122,9 @@ func (s *Standard) Close() {
if s.alertsRepository != nil {
s.alertsRepository = nil
}
if s.deviceTokensRepository != nil {
s.deviceTokensRepository = nil
}
}

func (s *Standard) AuthClient() auth.Client {
Expand Down Expand Up @@ -183,3 +188,10 @@ func (s *Standard) AlertsRepository() alerts.Repository {
}
return s.alertsRepository
}

func (s *Standard) DeviceTokensRepository() devicetokens.Repository {
if s.deviceTokensRepository == nil {
s.deviceTokensRepository = s.dataStore.NewDeviceTokensRepository()
}
return s.deviceTokensRepository
}
11 changes: 11 additions & 0 deletions data/store/mongo/mongo.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package mongo
import (
"github.com/tidepool-org/platform/alerts"
"github.com/tidepool-org/platform/data/store"
"github.com/tidepool-org/platform/devicetokens"
storeStructuredMongo "github.com/tidepool-org/platform/store/structured/mongo"
)

Expand All @@ -25,6 +26,7 @@ func (s *Store) EnsureIndexes() error {
dataRepository := s.NewDataRepository()
summaryRepository := s.NewSummaryRepository()
alertsRepository := s.NewAlertsRepository()
deviceTokensRepository := s.NewDeviceTokensRepository()

if err := dataRepository.EnsureIndexes(); err != nil {
return err
Expand All @@ -38,6 +40,10 @@ func (s *Store) EnsureIndexes() error {
return err
}

if err := deviceTokensRepository.EnsureIndexes(); err != nil {
return err
}

return nil
}

Expand All @@ -62,3 +68,8 @@ func (s *Store) NewAlertsRepository() alerts.Repository {
r := alertsRepo(*s.Store.GetRepository("alerts"))
return &r
}

func (s *Store) NewDeviceTokensRepository() devicetokens.Repository {
r := deviceTokensRepo(*s.Store.GetRepository("deviceTokens"))
return &r
}
59 changes: 59 additions & 0 deletions data/store/mongo/mongo_devicetokens.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package mongo

import (
"context"
"fmt"

"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"

"github.com/tidepool-org/platform/devicetokens"
structuredmongo "github.com/tidepool-org/platform/store/structured/mongo"
)

// deviceTokensRepo implements devicetokens.Repository, writing data to a
// MongoDB collection.
type deviceTokensRepo structuredmongo.Repository

// Upsert will create or update the given Config.
func (r *deviceTokensRepo) Upsert(ctx context.Context, doc *devicetokens.Document) error {
// The presence of UserID and TokenID should be enforced with a mongodb
// index, but better safe than sorry.
if doc.UserID == "" {
return fmt.Errorf("UserID may not be empty")
}
if doc.TokenID == "" {
return fmt.Errorf("TokenID may not be empty")
}

opts := options.Update().SetUpsert(true)
_, err := r.UpdateOne(ctx, r.filter(doc), bson.M{"$set": doc}, opts)
if err != nil {
return fmt.Errorf("upserting device token: %w", err)
}
return nil
}

// EnsureIndexes to maintain index constraints.
func (r *deviceTokensRepo) EnsureIndexes() error {
repo := structuredmongo.Repository(*r)
return (&repo).CreateAllIndexes(context.Background(), []mongo.IndexModel{
{
Keys: bson.D{
{Key: "userId", Value: 1},
{Key: "tokenId", Value: 1},
},
Options: options.Index().
SetUnique(true).
SetName("UserIdTokenIdTypeUnique"),
},
})
}

func (r *deviceTokensRepo) filter(doc *devicetokens.Document) interface{} {
return &devicetokens.Document{
UserID: doc.UserID,
TokenID: doc.TokenID,
}
}
Loading

0 comments on commit a0f5a84

Please sign in to comment.