From 39a3d466071759c5017c0384cf328442e7d78b45 Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Sun, 8 Apr 2018 14:32:47 +0200 Subject: [PATCH] [#34] Adjust message api to be paged --- api/message.go | 63 +++++++++++++++--- api/message_test.go | 153 ++++++++++++++++++++++++++++++++++++++++++-- model/paging.go | 54 ++++++++++++++++ 3 files changed, 255 insertions(+), 15 deletions(-) create mode 100644 model/paging.go diff --git a/api/message.go b/api/message.go index 18e57f1..a1085eb 100644 --- a/api/message.go +++ b/api/message.go @@ -4,16 +4,20 @@ import ( "errors" "time" + "strconv" + "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" + "github.com/gotify/location" "github.com/gotify/server/auth" "github.com/gotify/server/model" ) // The MessageDatabase interface for encapsulating database access. type MessageDatabase interface { - GetMessagesByApplication(id uint) []*model.Message + GetMessagesByApplicationSince(appID uint, limit int, since uint) []*model.Message GetApplicationByID(id uint) *model.Application - GetMessagesByUser(userID uint) []*model.Message + GetMessagesByUserSince(userID uint, limit int, since uint) []*model.Message DeleteMessageByID(id uint) error GetMessageByID(id uint) *model.Message DeleteMessagesByUser(userID uint) error @@ -33,22 +37,61 @@ type MessageAPI struct { Notifier Notifier } +type pagingParams struct { + Limit int `form:"limit" binding:"min=1,max=200"` + Since uint `form:"since" binding:"min=0"` +} + // GetMessages returns all messages from a user. func (a *MessageAPI) GetMessages(ctx *gin.Context) { userID := auth.GetUserID(ctx) - messages := a.DB.GetMessagesByUser(userID) - ctx.JSON(200, messages) + withPaging(ctx, func(params *pagingParams) { + // the +1 is used to check if there are more messages and will be removed on buildWithPaging + messages := a.DB.GetMessagesByUserSince(userID, params.Limit+1, params.Since) + ctx.JSON(200, buildWithPaging(ctx, params, messages)) + }) +} + +func buildWithPaging(ctx *gin.Context, paging *pagingParams, messages []*model.Message) *model.PagedMessages { + next := "" + since := uint(0) + useMessages := messages + if len(messages) > paging.Limit { + useMessages = messages[:len(messages)-1] + since = useMessages[len(useMessages)-1].ID + url := location.Get(ctx) + url.Path = ctx.Request.URL.Path + query := url.Query() + query.Add("limit", strconv.Itoa(paging.Limit)) + query.Add("since", strconv.FormatUint(uint64(since), 10)) + url.RawQuery = query.Encode() + next = url.String() + } + return &model.PagedMessages{ + Paging: model.Paging{Size: len(useMessages), Limit: paging.Limit, Next: next, Since: since}, + Messages: useMessages, + } +} + +func withPaging(ctx *gin.Context, f func(pagingParams *pagingParams)) { + params := &pagingParams{Limit: 100} + if err := ctx.MustBindWith(params, binding.Query); err == nil { + f(params) + } } // GetMessagesWithApplication returns all messages from a specific application. func (a *MessageAPI) GetMessagesWithApplication(ctx *gin.Context) { withID(ctx, "id", func(id uint) { - if app := a.DB.GetApplicationByID(id); app != nil && app.UserID == auth.GetUserID(ctx) { - messages := a.DB.GetMessagesByApplication(id) - ctx.JSON(200, messages) - } else { - ctx.AbortWithError(404, errors.New("application does not exist")) - } + withPaging(ctx, func(params *pagingParams) { + if app := a.DB.GetApplicationByID(id); app != nil && app.UserID == auth.GetUserID(ctx) { + // the +1 is used to check if there are more messages and will be removed on buildWithPaging + messages := a.DB.GetMessagesByApplicationSince(id, params.Limit+1, params.Since) + ctx.JSON(200, buildWithPaging(ctx, params, messages)) + } else { + ctx.AbortWithError(404, errors.New("application does not exist")) + } + }) }) } diff --git a/api/message_test.go b/api/message_test.go index 4721131..1a9bff4 100644 --- a/api/message_test.go +++ b/api/message_test.go @@ -14,6 +14,8 @@ import ( "strings" + "net/url" + "github.com/bouk/monkey" "github.com/gotify/server/auth" ) @@ -35,6 +37,7 @@ func (s *MessageSuite) BeforeTest(suiteName, testName string) { mode.Set(mode.TestDev) s.recorder = httptest.NewRecorder() s.ctx, _ = gin.CreateTestContext(s.recorder) + s.ctx.Request = httptest.NewRequest("GET", "/irrelevant", nil) s.db = test.NewDB(s.T()) s.notified = false s.a = &MessageAPI{DB: s.db, Notifier: s} @@ -51,8 +54,12 @@ func (s *MessageSuite) Notify(userID uint, msg *model.Message) { func (s *MessageSuite) Test_ensureCorrectJsonRepresentation() { t, _ := time.Parse("2006/01/02", "2017/01/02") - actual := model.Message{ID: 55, ApplicationID: 2, Message: "hi", Title: "hi", Date: t, Priority: 4} - test.JSONEquals(s.T(), actual, `{"id":55,"appid":2,"message":"hi","title":"hi","priority":4,"date":"2017-01-02T00:00:00Z"}`) + actual := &model.PagedMessages{ + Paging: model.Paging{Limit: 5, Since: 122, Size: 5, Next: "http://example.com/message?limit=5&since=122"}, + Messages: []*model.Message{{ID: 55, ApplicationID: 2, Message: "hi", Title: "hi", Date: t, Priority: 4}}, + } + test.JSONEquals(s.T(), actual, `{"paging": {"limit":5, "since": 122, "size": 5, "next": "http://example.com/message?limit=5&since=122"}, + "messages": [{"id":55,"appid":2,"message":"hi","title":"hi","priority":4,"date":"2017-01-02T00:00:00Z"}]}`) } func (s *MessageSuite) Test_GetMessages() { @@ -63,17 +70,148 @@ func (s *MessageSuite) Test_GetMessages() { test.WithUser(s.ctx, 5) s.a.GetMessages(s.ctx) - test.BodyEquals(s.T(), &[]model.Message{first, second}, s.recorder) + expected := &model.PagedMessages{ + Paging: model.Paging{Limit: 100, Size: 2, Next: ""}, + Messages: []*model.Message{&second, &first}, + } + + test.BodyEquals(s.T(), expected, s.recorder) +} + +func (s *MessageSuite) Test_GetMessages_WithLimit_ReturnsNext() { + user := s.db.User(5) + app1 := user.App(1) + app2 := user.App(2) + var messages []*model.Message + for i := 100; i >= 1; i -= 2 { + one := app2.NewMessage(uint(i)) + two := app1.NewMessage(uint(i - 1)) + messages = append(messages, &one, &two) + } + + s.withURL("http", "example.com", "/messages", "limit=5") + test.WithUser(s.ctx, 5) + s.a.GetMessages(s.ctx) + + // Since: entries with ids from 100 - 96 will be returned (5 entries) + expected := &model.PagedMessages{ + Paging: model.Paging{Limit: 5, Size: 5, Since: 96, Next: "http://example.com/messages?limit=5&since=96"}, + Messages: messages[:5], + } + + test.BodyEquals(s.T(), expected, s.recorder) +} + +func (s *MessageSuite) Test_GetMessages_WithLimit_WithSince_ReturnsNext() { + user := s.db.User(5) + app1 := user.App(1) + app2 := user.App(2) + var messages []*model.Message + for i := 100; i >= 1; i -= 2 { + one := app2.NewMessage(uint(i)) + two := app1.NewMessage(uint(i - 1)) + messages = append(messages, &one, &two) + } + + s.withURL("http", "example.com", "/messages", "limit=13&since=55") + test.WithUser(s.ctx, 5) + s.a.GetMessages(s.ctx) + + // Since: entries with ids from 54 - 42 will be returned (13 entries) + expected := &model.PagedMessages{ + Paging: model.Paging{Limit: 13, Size: 13, Since: 42, Next: "http://example.com/messages?limit=13&since=42"}, + Messages: messages[46 : 46+13], + } + test.BodyEquals(s.T(), expected, s.recorder) +} + +func (s *MessageSuite) Test_GetMessages_BadRequestOnInvalidLimit() { + s.db.User(5) + test.WithUser(s.ctx, 5) + s.withURL("http", "example.com", "/messages", "limit=555") + s.a.GetMessages(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) +} + +func (s *MessageSuite) Test_GetMessages_BadRequestOnInvalidLimit_Negative() { + s.db.User(5) + test.WithUser(s.ctx, 5) + s.withURL("http", "example.com", "/messages", "limit=-5") + s.a.GetMessages(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) +} + +func (s *MessageSuite) Test_GetMessagesWithToken_InvalidLimit_BadRequest() { + s.db.User(4).App(2).NewMessage(1) + + test.WithUser(s.ctx, 4) + s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} + s.withURL("http", "example.com", "/messages", "limit=555") + s.a.GetMessagesWithApplication(s.ctx) + + assert.Equal(s.T(), 400, s.recorder.Code) } func (s *MessageSuite) Test_GetMessagesWithToken() { - expected := s.db.User(4).App(2).NewMessage(1) + msg := s.db.User(4).App(2).NewMessage(1) test.WithUser(s.ctx, 4) s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} s.a.GetMessagesWithApplication(s.ctx) - test.BodyEquals(s.T(), []model.Message{expected}, s.recorder) + expected := &model.PagedMessages{ + Paging: model.Paging{Limit: 100, Size: 1, Next: ""}, + Messages: []*model.Message{&msg}, + } + + test.BodyEquals(s.T(), expected, s.recorder) +} + +func (s *MessageSuite) Test_GetMessagesWithToken_WithLimit_ReturnsNext() { + user := s.db.User(5) + app1 := user.App(2) + var messages []*model.Message + for i := 100; i >= 1; i-- { + msg := app1.NewMessage(uint(i)) + messages = append(messages, &msg) + } + + s.withURL("http", "example.com", "/app/2/message", "limit=9") + test.WithUser(s.ctx, 5) + s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} + s.a.GetMessagesWithApplication(s.ctx) + + // Since: entries with ids from 100 - 92 will be returned (9 entries) + expected := &model.PagedMessages{ + Paging: model.Paging{Limit: 9, Size: 9, Since: 92, Next: "http://example.com/app/2/message?limit=9&since=92"}, + Messages: messages[:9], + } + + test.BodyEquals(s.T(), expected, s.recorder) +} + +func (s *MessageSuite) Test_GetMessagesWithToken_WithLimit_WithSince_ReturnsNext() { + user := s.db.User(5) + app1 := user.App(2) + var messages []*model.Message + for i := 100; i >= 1; i-- { + msg := app1.NewMessage(uint(i)) + messages = append(messages, &msg) + } + + s.withURL("http", "example.com", "/app/2/message", "limit=13&since=55") + test.WithUser(s.ctx, 5) + s.ctx.Params = gin.Params{{Key: "id", Value: "2"}} + s.a.GetMessagesWithApplication(s.ctx) + + // Since: entries with ids from 54 - 42 will be returned (13 entries) + expected := &model.PagedMessages{ + Paging: model.Paging{Limit: 13, Size: 13, Since: 42, Next: "http://example.com/app/2/message?limit=13&since=42"}, + Messages: messages[46 : 46+13], + } + test.BodyEquals(s.T(), expected, s.recorder) } func (s *MessageSuite) Test_GetMessagesWithToken_withWrongUser_expectNotFound() { @@ -298,3 +436,8 @@ func (s *MessageSuite) Test_CreateMessage_onFormData() { assert.Equal(s.T(), 200, s.recorder.Code) assert.True(s.T(), s.notified) } + +func (s *MessageSuite) withURL(scheme, host, path, query string) { + s.ctx.Request.URL = &url.URL{Path: path, RawQuery: query} + s.ctx.Set("location", &url.URL{Scheme: scheme, Host: host}) +} diff --git a/model/paging.go b/model/paging.go new file mode 100644 index 0000000..f13d041 --- /dev/null +++ b/model/paging.go @@ -0,0 +1,54 @@ +package model + +// Paging Model +// +// The Paging holds holds information about the limit and making requests to the next page. +// +// swagger:model Paging +type Paging struct { + // The request url for the next page. Empty/Null when no next page is available. + // + // read only: true + // required: false + // example: http://example.com/message?limit=50&since=123456 + Next string `json:"next,omitempty"` + // The amount of messages that got returned in the current request. + // + // read only: true + // required: true + // example: 5 + Size int `json:"size"` + // The ID of the last message returned in the current request. Use this as alternative to the next link. + // + // read only: true + // required: true + // example: 5 + // min: 0 + Since uint `json:"since"` + // The limit of the messages for the current request. + // + // read only: true + // required: true + // min: 1 + // max: 200 + // example: 123 + Limit int `json:"limit"` +} + +// PagedMessages Model +// +// Wrapper for the paging and the messages +// +// swagger:model PagedMessages +type PagedMessages struct { + // The paging of the messages. + // + // read only: true + // required: true + Paging Paging `json:"paging"` + // The messages. + // + // read only: true + // required: true + Messages []*Message `json:"messages"` +}