From b093947d84127789e5a3a662a9e11d0b9438180e Mon Sep 17 00:00:00 2001 From: kim <89579420+NyaaaWhatsUpDoc@users.noreply.github.com> Date: Thu, 7 Sep 2023 15:58:37 +0100 Subject: [PATCH] [chore] much improved paging package (#2182) --- internal/api/client/blocks/blocksget.go | 14 +- internal/cache/slice.go | 25 -- internal/db/bundb/relationship.go | 17 +- internal/db/relationship.go | 2 +- internal/paging/boundary.go | 135 +++++++++++ internal/paging/order.go | 55 +++++ internal/paging/page.go | 251 ++++++++++++++++++++ internal/paging/page_test.go | 298 ++++++++++++++++++++++++ internal/paging/paging.go | 227 ------------------ internal/paging/paging_test.go | 171 -------------- internal/paging/parse.go | 111 +++++++++ internal/paging/response.go | 91 ++++++++ internal/paging/response_test.go | 134 +++++++++++ internal/paging/util.go | 49 ++++ internal/processing/blocks.go | 19 +- 15 files changed, 1154 insertions(+), 445 deletions(-) create mode 100644 internal/paging/boundary.go create mode 100644 internal/paging/order.go create mode 100644 internal/paging/page.go create mode 100644 internal/paging/page_test.go delete mode 100644 internal/paging/paging.go delete mode 100644 internal/paging/paging_test.go create mode 100644 internal/paging/parse.go create mode 100644 internal/paging/response.go create mode 100644 internal/paging/response_test.go create mode 100644 internal/paging/util.go diff --git a/internal/api/client/blocks/blocksget.go b/internal/api/client/blocks/blocksget.go index 505c33db8..dcf70e9cf 100644 --- a/internal/api/client/blocks/blocksget.go +++ b/internal/api/client/blocks/blocksget.go @@ -103,8 +103,12 @@ func (m *Module) BlocksGETHandler(c *gin.Context) { return } - limit, errWithCode := apiutil.ParseLimit(c.Query(LimitKey), 20, 100, 2) - if err != nil { + page, errWithCode := paging.ParseIDPage(c, + 1, // min limit + 100, // max limit + 20, // default limit + ) + if errWithCode != nil { apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) return } @@ -112,11 +116,7 @@ func (m *Module) BlocksGETHandler(c *gin.Context) { resp, errWithCode := m.processor.BlocksGet( c.Request.Context(), authed.Account, - paging.Pager{ - SinceID: c.Query(SinceIDKey), - MaxID: c.Query(MaxIDKey), - Limit: limit, - }, + page, ) if errWithCode != nil { apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) diff --git a/internal/cache/slice.go b/internal/cache/slice.go index e296a3b57..5e7fa6ce1 100644 --- a/internal/cache/slice.go +++ b/internal/cache/slice.go @@ -49,28 +49,3 @@ func (c *SliceCache[T]) Load(key string, load func() ([]T, error)) ([]T, error) // Return data clone for safety. return slices.Clone(data), nil } - -// LoadRange is functionally the same as .Load(), but will pass the result through provided reslice function before returning a cloned result. -func (c *SliceCache[T]) LoadRange(key string, load func() ([]T, error), reslice func([]T) []T) ([]T, error) { - // Look for follow IDs list in cache under this key. - data, ok := c.Get(key) - - if !ok { - var err error - - // Not cached, load! - data, err = load() - if err != nil { - return nil, err - } - - // Store the data. - c.Set(key, data) - } - - // Reslice to range. - slice := reslice(data) - - // Return range clone for safety. - return slices.Clone(slice), nil -} diff --git a/internal/db/bundb/relationship.go b/internal/db/bundb/relationship.go index 2f93b12ad..f1bdcf52b 100644 --- a/internal/db/bundb/relationship.go +++ b/internal/db/bundb/relationship.go @@ -150,9 +150,9 @@ func (r *relationshipDB) GetAccountFollowRequesting(ctx context.Context, account return r.GetFollowRequestsByIDs(ctx, followReqIDs) } -func (r *relationshipDB) GetAccountBlocks(ctx context.Context, accountID string, page *paging.Pager) ([]*gtsmodel.Block, error) { +func (r *relationshipDB) GetAccountBlocks(ctx context.Context, accountID string, page *paging.Page) ([]*gtsmodel.Block, error) { // Load block IDs from cache with database loader callback. - blockIDs, err := r.state.Caches.GTS.BlockIDs().LoadRange(accountID, func() ([]string, error) { + blockIDs, err := r.state.Caches.GTS.BlockIDs().Load(accountID, func() ([]string, error) { var blockIDs []string // Block IDs not in cache, perform DB query! @@ -162,11 +162,22 @@ func (r *relationshipDB) GetAccountBlocks(ctx context.Context, accountID string, } return blockIDs, nil - }, page.PageDesc) + }) if err != nil { return nil, err } + // Our cached / selected block IDs are + // ALWAYS stored in descending order. + // Depending on the paging requested + // this may be an unexpected order. + if !page.GetOrder().Ascending() { + blockIDs = paging.Reverse(blockIDs) + } + + // Page the resulting block IDs. + blockIDs = page.Page(blockIDs) + // Convert these IDs to full block objects. return r.GetBlocksByIDs(ctx, blockIDs) } diff --git a/internal/db/relationship.go b/internal/db/relationship.go index 50f615ef3..91c98644c 100644 --- a/internal/db/relationship.go +++ b/internal/db/relationship.go @@ -174,7 +174,7 @@ type Relationship interface { CountAccountFollowRequesting(ctx context.Context, accountID string) (int, error) // GetAccountBlocks returns all blocks originating from the given account, with given optional paging parameters. - GetAccountBlocks(ctx context.Context, accountID string, paging *paging.Pager) ([]*gtsmodel.Block, error) + GetAccountBlocks(ctx context.Context, accountID string, paging *paging.Page) ([]*gtsmodel.Block, error) // GetNote gets a private note from a source account on a target account, if it exists. GetNote(ctx context.Context, sourceAccountID string, targetAccountID string) (*gtsmodel.AccountNote, error) diff --git a/internal/paging/boundary.go b/internal/paging/boundary.go new file mode 100644 index 000000000..2f202097b --- /dev/null +++ b/internal/paging/boundary.go @@ -0,0 +1,135 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package paging + +// MinID returns an ID boundary with given min ID value, +// using either the `since_id`,"DESC" name,ordering or +// `min_id`,"ASC" name,ordering depending on which is set. +func MinID(minID, sinceID string) Boundary { + /* + + Paging with `since_id` vs `min_id`: + + limit = 4 limit = 4 + +----------+ +----------+ + max_id--> |xxxxxxxxxx| | | <-- max_id + +----------+ +----------+ + |xxxxxxxxxx| | | + +----------+ +----------+ + |xxxxxxxxxx| | | + +----------+ +----------+ + |xxxxxxxxxx| |xxxxxxxxxx| + +----------+ +----------+ + | | |xxxxxxxxxx| + +----------+ +----------+ + | | |xxxxxxxxxx| + +----------+ +----------+ + since_id--> | | |xxxxxxxxxx| <-- min_id + +----------+ +----------+ + | | | | + +----------+ +----------+ + + */ + switch { + case minID != "": + return Boundary{ + Name: "min_id", + Value: minID, + Order: OrderAscending, + } + default: + // default min is `since_id` + return Boundary{ + Name: "since_id", + Value: sinceID, + Order: OrderDescending, + } + } +} + +// MaxID returns an ID boundary with given max +// ID value, and the "max_id" query key set. +func MaxID(maxID string) Boundary { + return Boundary{ + Name: "max_id", + Value: maxID, + Order: OrderDescending, + } +} + +// MinShortcodeDomain returns a boundary with the given minimum emoji +// shortcode@domain, and the "min_shortcode_domain" query key set. +func MinShortcodeDomain(min string) Boundary { + return Boundary{ + Name: "min_shortcode_domain", + Value: min, + Order: OrderAscending, + } +} + +// MaxShortcodeDomain returns a boundary with the given maximum emoji +// shortcode@domain, and the "max_shortcode_domain" query key set. +func MaxShortcodeDomain(max string) Boundary { + return Boundary{ + Name: "max_shortcode_domain", + Value: max, + Order: OrderDescending, + } +} + +// Boundary represents the upper or lower limit in a page slice. +type Boundary struct { + Name string // i.e. query key + Value string + Order Order // NOTE: see Order type for explanation +} + +// new creates a new Boundary with the same ordering and name +// as the original (receiving), but with the new provided value. +func (b Boundary) new(value string) Boundary { + return Boundary{ + Name: b.Name, + Value: value, + Order: b.Order, + } +} + +// Find finds the boundary's set value in input slice, or returns -1. +func (b Boundary) Find(in []string) int { + if zero(b.Value) { + return -1 + } + for i := range in { + if in[i] == b.Value { + return i + } + } + return -1 +} + +// Query returns this boundary as assembled query key=value pair. +func (b Boundary) Query() string { + switch { + case zero(b.Value): + return "" + case b.Name == "": + panic("value without boundary name") + default: + return b.Name + "=" + b.Value + } +} diff --git a/internal/paging/order.go b/internal/paging/order.go new file mode 100644 index 000000000..2f2bf3a06 --- /dev/null +++ b/internal/paging/order.go @@ -0,0 +1,55 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package paging + +// Order represents the order an input +// page should be sorted and paged in. +// +// NOTE: this does not effect the order of returned +// API results, which must always be in descending +// order. This behaviour is confusing, but we adopt +// it to stay inline with Mastodon API expectations. +type Order int + +const ( + _default Order = iota + OrderDescending + OrderAscending +) + +// Ascending returns whether this Order is ascending. +func (i Order) Ascending() bool { + return i == OrderAscending +} + +// Descending returns whether this Order is descending. +func (i Order) Descending() bool { + return i == OrderDescending +} + +// String returns a string representation of Order. +func (i Order) String() string { + switch i { + case OrderDescending: + return "Descending" + case OrderAscending: + return "Ascending" + default: + return "not-specified" + } +} diff --git a/internal/paging/page.go b/internal/paging/page.go new file mode 100644 index 000000000..7d8f84aab --- /dev/null +++ b/internal/paging/page.go @@ -0,0 +1,251 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package paging + +import ( + "net/url" + "strconv" + "strings" + + "golang.org/x/exp/slices" +) + +type Page struct { + // Min is the Page's lower limit value. + Min Boundary + + // Max is this Page's upper limit value. + Max Boundary + + // Limit will limit the returned + // page of items to at most 'limit'. + Limit int +} + +// GetMin is a small helper function to return minimum boundary value (checking for nil page). +func (p *Page) GetMin() string { + if p == nil { + return "" + } + return p.Min.Value +} + +// GetMax is a small helper function to return maximum boundary value (checking for nil page). +func (p *Page) GetMax() string { + if p == nil { + return "" + } + return p.Max.Value +} + +// GetLimit is a small helper function to return limit (checking for nil page and unusable limit). +func (p *Page) GetLimit() int { + if p == nil || p.Limit < 0 { + return 0 + } + return p.Limit +} + +// GetOrder is a small helper function to return page sort ordering (checking for nil page). +func (p *Page) GetOrder() Order { + if p == nil { + return 0 + } + return p.order() +} + +func (p *Page) order() Order { + var ( + // Check if min/max values set. + minValue = zero(p.Min.Value) + maxValue = zero(p.Max.Value) + + // Check if min/max orders set. + minOrder = (p.Min.Order != 0) + maxOrder = (p.Max.Order != 0) + ) + + switch { + // Boundaries with a value AND order set + // take priority. Min always comes first. + case minValue && minOrder: + return p.Min.Order + case maxValue && maxOrder: + return p.Max.Order + case minOrder: + return p.Min.Order + case maxOrder: + return p.Max.Order + default: + return 0 + } +} + +// Page will page the given slice of input according +// to the receiving Page's minimum, maximum and limit. +// NOTE: input slice MUST be sorted according to the order is +// expected to be paged in, i.e. it is currently sorted +// according to Page.Order(). Sorted data isn't always according +// to string inequalities so this CANNOT be checked here. +func (p *Page) Page(in []string) []string { + if p == nil { + // no paging. + return in + } + + if o := p.order(); !o.Ascending() { + // Default sort is descending, + // catching all cases when NOT + // ascending (even zero value). + // + // NOTE: sorted data does not always + // occur according to string ineqs + // so we unfortunately cannot check. + + if maxIdx := p.Max.Find(in); maxIdx != -1 { + // Reslice skipping up to max. + in = in[maxIdx+1:] + } + + if minIdx := p.Min.Find(in); minIdx != -1 { + // Reslice stripping past min. + in = in[:minIdx] + } + } else { + // Sort type is ascending, input + // data is assumed to be ascending. + // + // NOTE: sorted data does not always + // occur according to string ineqs + // so we unfortunately cannot check. + + if minIdx := p.Min.Find(in); minIdx != -1 { + // Reslice skipping up to min. + in = in[minIdx+1:] + } + + if maxIdx := p.Max.Find(in); maxIdx != -1 { + // Reslice stripping past max. + in = in[:maxIdx] + } + + if len(in) > 1 { + // Clone input before + // any modifications. + in = slices.Clone(in) + + // Output slice must + // ALWAYS be descending. + in = Reverse(in) + } + } + + if p.Limit > 0 && p.Limit < len(in) { + // Reslice input to limit. + in = in[:p.Limit] + } + + return in +} + +// Next creates a new instance for the next returnable page, using +// given max value. This preserves original limit and max key name. +func (p *Page) Next(max string) *Page { + if p == nil || max == "" { + // no paging. + return nil + } + + // Create new page. + p2 := new(Page) + + // Set original limit. + p2.Limit = p.Limit + + // Create new from old. + p2.Max = p.Max.new(max) + + return p2 +} + +// Prev creates a new instance for the prev returnable page, using +// given min value. This preserves original limit and min key name. +func (p *Page) Prev(min string) *Page { + if p == nil || min == "" { + // no paging. + return nil + } + + // Create new page. + p2 := new(Page) + + // Set original limit. + p2.Limit = p.Limit + + // Create new from old. + p2.Min = p.Min.new(min) + + return p2 +} + +// ToLink builds a URL link for given endpoint information and extra query parameters, +// appending this Page's minimum / maximum boundaries and available limit (if any). +func (p *Page) ToLink(proto, host, path string, queryParams []string) string { + if p == nil { + // no paging. + return "" + } + + // Check length before + // adding boundary params. + old := len(queryParams) + + if minParam := p.Min.Query(); minParam != "" { + // A page-minimum query parameter is available. + queryParams = append(queryParams, minParam) + } + + if maxParam := p.Max.Query(); maxParam != "" { + // A page-maximum query parameter is available. + queryParams = append(queryParams, maxParam) + } + + if len(queryParams) == old { + // No page boundaries. + return "" + } + + if p.Limit > 0 { + // Build limit key-value query parameter. + param := "limit=" + strconv.Itoa(p.Limit) + + // Append `limit=$value` query parameter. + queryParams = append(queryParams, param) + } + + // Join collected params into query str. + query := strings.Join(queryParams, "&") + + // Build URL string. + return (&url.URL{ + Scheme: proto, + Host: host, + Path: path, + RawQuery: query, + }).String() +} diff --git a/internal/paging/page_test.go b/internal/paging/page_test.go new file mode 100644 index 000000000..419b9ea44 --- /dev/null +++ b/internal/paging/page_test.go @@ -0,0 +1,298 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package paging_test + +import ( + "math/rand" + "testing" + "time" + + "github.com/oklog/ulid" + "github.com/superseriousbusiness/gotosocial/internal/paging" + "golang.org/x/exp/slices" +) + +// random reader according to current-time source seed. +var randRd = rand.New(rand.NewSource(time.Now().Unix())) + +type Case struct { + // Name is the test case name. + Name string + + // Page to use for test. + Page *paging.Page + + // Input contains test case input ID slice. + Input []string + + // Expect contains expected test case output. + Expect []string +} + +// CreateCase creates a new test case with random input for function defining test page parameters and expected output. +func CreateCase(name string, getParams func([]string) (input []string, page *paging.Page, expect []string)) Case { + i := randRd.Intn(100) + in := generateSlice(i) + input, page, expect := getParams(in) + return Case{ + Name: name, + Page: page, + Input: input, + Expect: expect, + } +} + +func TestPage(t *testing.T) { + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + // Page the input slice. + out := c.Page.Page(c.Input) + + // Log the results for case of error returns. + t.Logf("\ninput=%v\noutput=%v\nexpected=%v", c.Input, out, c.Expect) + + // Check paged output is as expected. + if !slices.Equal(out, c.Expect) { + t.Error("unexpected paged output") + } + }) + } +} + +var cases = []Case{ + CreateCase("minID and maxID set", func(ids []string) ([]string, *paging.Page, []string) { + // Ensure input slice sorted ascending for min_id + slices.SortFunc(ids, func(a, b string) bool { + return a > b // i.e. largest at lowest idx + }) + + // Select random indices in slice. + minIdx := randRd.Intn(len(ids)) + maxIdx := randRd.Intn(len(ids)) + + // Select the boundaries. + minID := ids[minIdx] + maxID := ids[maxIdx] + + // Create expected output. + expect := slices.Clone(ids) + expect = cutLower(expect, minID) + expect = cutUpper(expect, maxID) + expect = paging.Reverse(expect) + + // Return page and expected IDs. + return ids, &paging.Page{ + Min: paging.MinID(minID, ""), + Max: paging.MaxID(maxID), + }, expect + }), + CreateCase("minID, maxID and limit set", func(ids []string) ([]string, *paging.Page, []string) { + // Ensure input slice sorted ascending for min_id + slices.SortFunc(ids, func(a, b string) bool { + return a > b // i.e. largest at lowest idx + }) + + // Select random parameters in slice. + minIdx := randRd.Intn(len(ids)) + maxIdx := randRd.Intn(len(ids)) + limit := randRd.Intn(len(ids)) + + // Select the boundaries. + minID := ids[minIdx] + maxID := ids[maxIdx] + + // Create expected output. + expect := slices.Clone(ids) + expect = cutLower(expect, minID) + expect = cutUpper(expect, maxID) + expect = paging.Reverse(expect) + + // Now limit the slice. + if limit < len(expect) { + expect = expect[:limit] + } + + // Return page and expected IDs. + return ids, &paging.Page{ + Min: paging.MinID(minID, ""), + Max: paging.MaxID(maxID), + Limit: limit, + }, expect + }), + CreateCase("minID, maxID and too-large limit set", func(ids []string) ([]string, *paging.Page, []string) { + // Ensure input slice sorted ascending for min_id + slices.SortFunc(ids, func(a, b string) bool { + return a > b // i.e. largest at lowest idx + }) + + // Select random parameters in slice. + minIdx := randRd.Intn(len(ids)) + maxIdx := randRd.Intn(len(ids)) + + // Select the boundaries. + minID := ids[minIdx] + maxID := ids[maxIdx] + + // Create expected output. + expect := slices.Clone(ids) + expect = cutLower(expect, minID) + expect = cutUpper(expect, maxID) + expect = paging.Reverse(expect) + + // Return page and expected IDs. + return ids, &paging.Page{ + Min: paging.MinID(minID, ""), + Max: paging.MaxID(maxID), + Limit: len(ids) * 2, + }, expect + }), + CreateCase("sinceID and maxID set", func(ids []string) ([]string, *paging.Page, []string) { + // Ensure input slice sorted descending for since_id + slices.SortFunc(ids, func(a, b string) bool { + return a < b // i.e. smallest at lowest idx + }) + + // Select random indices in slice. + sinceIdx := randRd.Intn(len(ids)) + maxIdx := randRd.Intn(len(ids)) + + // Select the boundaries. + sinceID := ids[sinceIdx] + maxID := ids[maxIdx] + + // Create expected output. + expect := slices.Clone(ids) + expect = cutLower(expect, maxID) + expect = cutUpper(expect, sinceID) + + // Return page and expected IDs. + return ids, &paging.Page{ + Min: paging.MinID("", sinceID), + Max: paging.MaxID(maxID), + }, expect + }), + CreateCase("maxID set", func(ids []string) ([]string, *paging.Page, []string) { + // Ensure input slice sorted descending for max_id + slices.SortFunc(ids, func(a, b string) bool { + return a < b // i.e. smallest at lowest idx + }) + + // Select random indices in slice. + maxIdx := randRd.Intn(len(ids)) + + // Select the boundaries. + maxID := ids[maxIdx] + + // Create expected output. + expect := slices.Clone(ids) + expect = cutLower(expect, maxID) + + // Return page and expected IDs. + return ids, &paging.Page{ + Max: paging.MaxID(maxID), + }, expect + }), + CreateCase("sinceID set", func(ids []string) ([]string, *paging.Page, []string) { + // Ensure input slice sorted descending for since_id + slices.SortFunc(ids, func(a, b string) bool { + return a < b + }) + + // Select random indices in slice. + sinceIdx := randRd.Intn(len(ids)) + + // Select the boundaries. + sinceID := ids[sinceIdx] + + // Create expected output. + expect := slices.Clone(ids) + expect = cutUpper(expect, sinceID) + + // Return page and expected IDs. + return ids, &paging.Page{ + Min: paging.MinID("", sinceID), + }, expect + }), + CreateCase("minID set", func(ids []string) ([]string, *paging.Page, []string) { + // Ensure input slice sorted ascending for min_id + slices.SortFunc(ids, func(a, b string) bool { + return a > b // i.e. largest at lowest idx + }) + + // Select random indices in slice. + minIdx := randRd.Intn(len(ids)) + + // Select the boundaries. + minID := ids[minIdx] + + // Create expected output. + expect := slices.Clone(ids) + expect = cutLower(expect, minID) + expect = paging.Reverse(expect) + + // Return page and expected IDs. + return ids, &paging.Page{ + Min: paging.MinID(minID, ""), + }, expect + }), +} + +// cutLower cuts off the lower part of the slice from `bound` downwards. +func cutLower(in []string, bound string) []string { + for i := 0; i < len(in); i++ { + if in[i] == bound { + return in[i+1:] + } + } + return in +} + +// cutUpper cuts off the upper part of the slice from `bound` onwards. +func cutUpper(in []string, bound string) []string { + for i := 0; i < len(in); i++ { + if in[i] == bound { + return in[:i] + } + } + return in +} + +// generateSlice generates a new slice of len containing ascending sorted slice. +func generateSlice(len int) []string { + if len <= 0 { + // minimum testable + // pageable amount + len = 2 + } + now := time.Now() + in := make([]string, len) + for i := 0; i < len; i++ { + // Convert now to timestamp. + t := ulid.Timestamp(now) + + // Create anew ulid for now. + u := ulid.MustNew(t, randRd) + + // Add to slice. + in[i] = u.String() + + // Bump now by 1 second. + now = now.Add(time.Second) + } + return in +} diff --git a/internal/paging/paging.go b/internal/paging/paging.go deleted file mode 100644 index 0323f40bc..000000000 --- a/internal/paging/paging.go +++ /dev/null @@ -1,227 +0,0 @@ -// GoToSocial -// Copyright (C) GoToSocial Authors admin@gotosocial.org -// SPDX-License-Identifier: AGPL-3.0-or-later -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package paging - -import "golang.org/x/exp/slices" - -// Pager provides a means of paging serialized IDs, -// using the terminology of our API endpoint queries. -type Pager struct { - // SinceID will limit the returned - // page of IDs to contain newer than - // since ID (excluding it). Result - // will be returned DESCENDING. - SinceID string - - // MinID will limit the returned - // page of IDs to contain newer than - // min ID (excluding it). Result - // will be returned ASCENDING. - MinID string - - // MaxID will limit the returned - // page of IDs to contain older - // than (excluding) this max ID. - MaxID string - - // Limit will limit the returned - // page of IDs to at most 'limit'. - Limit int -} - -// Page will page the given slice of GoToSocial IDs according -// to the receiving Pager's SinceID, MinID, MaxID and Limits. -// NOTE THE INPUT SLICE MUST BE SORTED IN ASCENDING ORDER -// (I.E. OLDEST ITEMS AT LOWEST INDICES, NEWER AT HIGHER). -func (p *Pager) PageAsc(ids []string) []string { - if p == nil { - // no paging. - return ids - } - - var asc bool - - if p.SinceID != "" { - // If a sinceID is given, we - // page down i.e. descending. - asc = false - - for i := 0; i < len(ids); i++ { - if ids[i] == p.SinceID { - // Hit the boundary. - // Reslice to be: - // "from here" - ids = ids[i+1:] - break - } - } - } else if p.MinID != "" { - // We only support minID if - // no sinceID is provided. - // - // If a minID is given, we - // page up, i.e. ascending. - asc = true - - for i := 0; i < len(ids); i++ { - if ids[i] == p.MinID { - // Hit the boundary. - // Reslice to be: - // "from here" - ids = ids[i+1:] - break - } - } - } - - if p.MaxID != "" { - for i := 0; i < len(ids); i++ { - if ids[i] == p.MaxID { - // Hit the boundary. - // Reslice to be: - // "up to here" - ids = ids[:i] - break - } - } - } - - if !asc && len(ids) > 1 { - var ( - // Start at front. - i = 0 - - // Start at back. - j = len(ids) - 1 - ) - - // Clone input IDs before - // we perform modifications. - ids = slices.Clone(ids) - - for i < j { - // Swap i,j index values in slice. - ids[i], ids[j] = ids[j], ids[i] - - // incr + decr, - // looping until - // they meet in - // the middle. - i++ - j-- - } - } - - if p.Limit > 0 && p.Limit < len(ids) { - // Reslice IDs to given limit. - ids = ids[:p.Limit] - } - - return ids -} - -// Page will page the given slice of GoToSocial IDs according -// to the receiving Pager's SinceID, MinID, MaxID and Limits. -// NOTE THE INPUT SLICE MUST BE SORTED IN ASCENDING ORDER. -// (I.E. NEWEST ITEMS AT LOWEST INDICES, OLDER AT HIGHER). -func (p *Pager) PageDesc(ids []string) []string { - if p == nil { - // no paging. - return ids - } - - var asc bool - - if p.MaxID != "" { - for i := 0; i < len(ids); i++ { - if ids[i] == p.MaxID { - // Hit the boundary. - // Reslice to be: - // "from here" - ids = ids[i+1:] - break - } - } - } - - if p.SinceID != "" { - // If a sinceID is given, we - // page down i.e. descending. - asc = false - - for i := 0; i < len(ids); i++ { - if ids[i] == p.SinceID { - // Hit the boundary. - // Reslice to be: - // "up to here" - ids = ids[:i] - break - } - } - } else if p.MinID != "" { - // We only support minID if - // no sinceID is provided. - // - // If a minID is given, we - // page up, i.e. ascending. - asc = true - - for i := 0; i < len(ids); i++ { - if ids[i] == p.MinID { - // Hit the boundary. - // Reslice to be: - // "up to here" - ids = ids[:i] - break - } - } - } - - if asc && len(ids) > 1 { - var ( - // Start at front. - i = 0 - - // Start at back. - j = len(ids) - 1 - ) - - // Clone input IDs before - // we perform modifications. - ids = slices.Clone(ids) - - for i < j { - // Swap i,j index values in slice. - ids[i], ids[j] = ids[j], ids[i] - - // incr + decr, - // looping until - // they meet in - // the middle. - i++ - j-- - } - } - - if p.Limit > 0 && p.Limit < len(ids) { - // Reslice IDs to given limit. - ids = ids[:p.Limit] - } - - return ids -} diff --git a/internal/paging/paging_test.go b/internal/paging/paging_test.go deleted file mode 100644 index 71c3be0c9..000000000 --- a/internal/paging/paging_test.go +++ /dev/null @@ -1,171 +0,0 @@ -// GoToSocial -// Copyright (C) GoToSocial Authors admin@gotosocial.org -// SPDX-License-Identifier: AGPL-3.0-or-later -// -// This program is free software: you can redistribute it and/or modify -// it under the terms of the GNU Affero General Public License as published by -// the Free Software Foundation, either version 3 of the License, or -// (at your option) any later version. -// -// This program is distributed in the hope that it will be useful, -// but WITHOUT ANY WARRANTY; without even the implied warranty of -// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -// GNU Affero General Public License for more details. -// -// You should have received a copy of the GNU Affero General Public License -// along with this program. If not, see . - -package paging_test - -import ( - "testing" - - "github.com/superseriousbusiness/gotosocial/internal/paging" - "golang.org/x/exp/slices" -) - -type Case struct { - // Name is the test case name. - Name string - - // Input contains test case input ID slice. - Input []string - - // Expect contains expected test case output. - Expect []string - - // Page contains the paging function to use. - Page func([]string) []string -} - -var cases = []Case{ - { - Name: "min_id and max_id set", - Input: []string{ - "064Q5D7VG6TPPQ46T09MHJ96FW", - "064Q5D7VGPTC4NK5T070VYSSF8", - "064Q5D7VH5F0JXG6W5NCQ3JCWW", - "064Q5D7VHMSW9DF3GCS088VAZC", - "064Q5D7VJ073XG9ZTWHA2KHN10", - "064Q5D7VJADJTPA3GW8WAX10TW", - "064Q5D7VJMWXZD3S1KT7RD51N8", - "064Q5D7VJYFBYSAH86KDBKZ6AC", - "064Q5D7VK8H7WMJS399SHEPCB0", - "064Q5D7VKG5EQ43TYP71B4K6K0", - }, - Expect: []string{ - "064Q5D7VGPTC4NK5T070VYSSF8", - "064Q5D7VH5F0JXG6W5NCQ3JCWW", - "064Q5D7VHMSW9DF3GCS088VAZC", - "064Q5D7VJ073XG9ZTWHA2KHN10", - "064Q5D7VJADJTPA3GW8WAX10TW", - "064Q5D7VJMWXZD3S1KT7RD51N8", - "064Q5D7VJYFBYSAH86KDBKZ6AC", - "064Q5D7VK8H7WMJS399SHEPCB0", - }, - Page: (&paging.Pager{ - MinID: "064Q5D7VG6TPPQ46T09MHJ96FW", - MaxID: "064Q5D7VKG5EQ43TYP71B4K6K0", - }).PageAsc, - }, - { - Name: "min_id, max_id and limit set", - Input: []string{ - "064Q5D7VG6TPPQ46T09MHJ96FW", - "064Q5D7VGPTC4NK5T070VYSSF8", - "064Q5D7VH5F0JXG6W5NCQ3JCWW", - "064Q5D7VHMSW9DF3GCS088VAZC", - "064Q5D7VJ073XG9ZTWHA2KHN10", - "064Q5D7VJADJTPA3GW8WAX10TW", - "064Q5D7VJMWXZD3S1KT7RD51N8", - "064Q5D7VJYFBYSAH86KDBKZ6AC", - "064Q5D7VK8H7WMJS399SHEPCB0", - "064Q5D7VKG5EQ43TYP71B4K6K0", - }, - Expect: []string{ - "064Q5D7VGPTC4NK5T070VYSSF8", - "064Q5D7VH5F0JXG6W5NCQ3JCWW", - "064Q5D7VHMSW9DF3GCS088VAZC", - "064Q5D7VJ073XG9ZTWHA2KHN10", - "064Q5D7VJADJTPA3GW8WAX10TW", - }, - Page: (&paging.Pager{ - MinID: "064Q5D7VG6TPPQ46T09MHJ96FW", - MaxID: "064Q5D7VKG5EQ43TYP71B4K6K0", - Limit: 5, - }).PageAsc, - }, - { - Name: "min_id, max_id and too-large limit set", - Input: []string{ - "064Q5D7VG6TPPQ46T09MHJ96FW", - "064Q5D7VGPTC4NK5T070VYSSF8", - "064Q5D7VH5F0JXG6W5NCQ3JCWW", - "064Q5D7VHMSW9DF3GCS088VAZC", - "064Q5D7VJ073XG9ZTWHA2KHN10", - "064Q5D7VJADJTPA3GW8WAX10TW", - "064Q5D7VJMWXZD3S1KT7RD51N8", - "064Q5D7VJYFBYSAH86KDBKZ6AC", - "064Q5D7VK8H7WMJS399SHEPCB0", - "064Q5D7VKG5EQ43TYP71B4K6K0", - }, - Expect: []string{ - "064Q5D7VGPTC4NK5T070VYSSF8", - "064Q5D7VH5F0JXG6W5NCQ3JCWW", - "064Q5D7VHMSW9DF3GCS088VAZC", - "064Q5D7VJ073XG9ZTWHA2KHN10", - "064Q5D7VJADJTPA3GW8WAX10TW", - "064Q5D7VJMWXZD3S1KT7RD51N8", - "064Q5D7VJYFBYSAH86KDBKZ6AC", - "064Q5D7VK8H7WMJS399SHEPCB0", - }, - Page: (&paging.Pager{ - MinID: "064Q5D7VG6TPPQ46T09MHJ96FW", - MaxID: "064Q5D7VKG5EQ43TYP71B4K6K0", - Limit: 100, - }).PageAsc, - }, - { - Name: "since_id and max_id set", - Input: []string{ - "064Q5D7VG6TPPQ46T09MHJ96FW", - "064Q5D7VGPTC4NK5T070VYSSF8", - "064Q5D7VH5F0JXG6W5NCQ3JCWW", - "064Q5D7VHMSW9DF3GCS088VAZC", - "064Q5D7VJ073XG9ZTWHA2KHN10", - "064Q5D7VJADJTPA3GW8WAX10TW", - "064Q5D7VJMWXZD3S1KT7RD51N8", - "064Q5D7VJYFBYSAH86KDBKZ6AC", - "064Q5D7VK8H7WMJS399SHEPCB0", - "064Q5D7VKG5EQ43TYP71B4K6K0", - }, - Expect: []string{ - "064Q5D7VK8H7WMJS399SHEPCB0", - "064Q5D7VJYFBYSAH86KDBKZ6AC", - "064Q5D7VJMWXZD3S1KT7RD51N8", - "064Q5D7VJADJTPA3GW8WAX10TW", - "064Q5D7VJ073XG9ZTWHA2KHN10", - "064Q5D7VHMSW9DF3GCS088VAZC", - "064Q5D7VH5F0JXG6W5NCQ3JCWW", - "064Q5D7VGPTC4NK5T070VYSSF8", - }, - Page: (&paging.Pager{ - SinceID: "064Q5D7VG6TPPQ46T09MHJ96FW", - MaxID: "064Q5D7VKG5EQ43TYP71B4K6K0", - }).PageAsc, - }, -} - -func TestPage(t *testing.T) { - for _, c := range cases { - t.Run(c.Name, func(t *testing.T) { - // Page the input slice. - out := c.Page(c.Input) - - // Check paged output is as expected. - if !slices.Equal(out, c.Expect) { - t.Errorf("\nreceived=%v\nexpect%v\n", out, c.Expect) - } - }) - } -} diff --git a/internal/paging/parse.go b/internal/paging/parse.go new file mode 100644 index 000000000..55ebef7f5 --- /dev/null +++ b/internal/paging/parse.go @@ -0,0 +1,111 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package paging + +import ( + "strconv" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +// ParseIDPage parses an ID Page from a request context, returning BadRequest on error parsing. +// The min, max and default parameters define the page size limit minimum, maximum and default +// value, where a non-zero default will enforce paging for the endpoint on which this is called. +// While conversely, a zero default limit will not enforce paging, returning a nil page value. +func ParseIDPage(c *gin.Context, min, max, _default int) (*Page, gtserror.WithCode) { + // Extract request query params. + sinceID := c.Query("since_id") + minID := c.Query("min_id") + maxID := c.Query("max_id") + + // Extract request limit parameter. + limit, errWithCode := ParseLimit(c, min, max, _default) + if errWithCode != nil { + return nil, errWithCode + } + + if sinceID == "" && + minID == "" && + maxID == "" && + limit == 0 { + // No ID paging params provided, and no default + // limit value which indicates paging not enforced. + return nil, nil + } + + return &Page{ + Min: MinID(minID, sinceID), + Max: MaxID(maxID), + Limit: limit, + }, nil +} + +// ParseShortcodeDomainPage parses an emoji shortcode domain Page from a request context, returning BadRequest +// on error parsing. The min, max and default parameters define the page size limit minimum, maximum and default +// value where a non-zero default will enforce paging for the endpoint on which this is called. While conversely, +// a zero default limit will not enforce paging, returning a nil page value. +func ParseShortcodeDomainPage(c *gin.Context, min, max, _default int) (*Page, gtserror.WithCode) { + // Extract request query parameters. + minShortcode := c.Query("min_shortcode_domain") + maxShortcode := c.Query("max_shortcode_domain") + + // Extract request limit parameter. + limit, errWithCode := ParseLimit(c, min, max, _default) + if errWithCode != nil { + return nil, errWithCode + } + + if minShortcode == "" && + maxShortcode == "" && + limit == 0 { + // No ID paging params provided, and no default + // limit value which indicates paging not enforced. + return nil, nil + } + + return &Page{ + Min: MinShortcodeDomain(minShortcode), + Max: MaxShortcodeDomain(maxShortcode), + Limit: limit, + }, nil +} + +// ParseLimit parses the limit query parameter from a request context, returning BadRequest on error parsing and _default if zero limit given. +func ParseLimit(c *gin.Context, min, max, _default int) (int, gtserror.WithCode) { + // Get limit query param. + str := c.Query("limit") + + // Attempt to parse limit int. + i, err := strconv.Atoi(str) + if err != nil { + const help = "bad integer limit value" + return 0, gtserror.NewErrorBadRequest(err, help) + } + + switch { + case i == 0: + return _default, nil + case i < min: + return min, nil + case i > max: + return max, nil + default: + return i, nil + } +} diff --git a/internal/paging/response.go b/internal/paging/response.go new file mode 100644 index 000000000..498b42d34 --- /dev/null +++ b/internal/paging/response.go @@ -0,0 +1,91 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package paging + +import ( + "strings" + + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + "github.com/superseriousbusiness/gotosocial/internal/config" +) + +// ResponseParams models the parameters to pass to PageableResponse. +// +// The given items will be provided in the paged response. +// +// The other values are all used to create the Link header so that callers know +// which endpoint to query next and previously in order to do paging. +type ResponseParams struct { + Items []interface{} // Sorted slice of items (statuses, notifications, etc) + Path string // path to use for next/prev queries in the link header + Next *Page // page details for the next page + Prev *Page // page details for the previous page + Query []string // any extra query parameters to provide in the link header, should be in the format 'example=value' +} + +// PackageResponse is a convenience function for returning +// a bunch of pageable items (notifications, statuses, etc), as well +// as a Link header to inform callers of where to find next/prev items. +func PackageResponse(params ResponseParams) *apimodel.PageableResponse { + if len(params.Items) == 0 { + // No items to page through. + return EmptyResponse() + } + + var ( + // Extract paging params. + nextPg = params.Next + prevPg = params.Prev + + // Host app configuration. + proto = config.GetProtocol() + host = config.GetHost() + + // Combined next/prev page link header parts. + linkHeaderParts = make([]string, 0, 2) + ) + + // Build the next / previous page links from page and host config. + nextLink := nextPg.ToLink(proto, host, params.Path, params.Query) + prevLink := prevPg.ToLink(proto, host, params.Path, params.Query) + + if nextLink != "" { + // Append page "next" link to header parts. + linkHeaderParts = append(linkHeaderParts, `<`+nextLink+`>; rel="next"`) + } + + if prevLink != "" { + // Append page "prev" link to header parts. + linkHeaderParts = append(linkHeaderParts, `<`+prevLink+`>; rel="prev"`) + } + + return &apimodel.PageableResponse{ + Items: params.Items, + NextLink: nextLink, + PrevLink: prevLink, + LinkHeader: strings.Join(linkHeaderParts, ", "), + } +} + +// EmptyResponse just returns an empty +// PageableResponse with no link header or items. +func EmptyResponse() *apimodel.PageableResponse { + return &apimodel.PageableResponse{ + Items: []interface{}{}, + } +} diff --git a/internal/paging/response_test.go b/internal/paging/response_test.go new file mode 100644 index 000000000..8eca2a601 --- /dev/null +++ b/internal/paging/response_test.go @@ -0,0 +1,134 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package paging_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/paging" +) + +type PagingSuite struct { + suite.Suite +} + +func (suite *PagingSuite) TestPagingStandard() { + config.SetHost("example.org") + + params := paging.ResponseParams{ + Items: make([]interface{}, 10, 10), + Path: "/api/v1/accounts/01H11KA68PM4NNYJEG0FJQ90R3/statuses", + Next: nextPage("01H11KA1DM2VH3747YDE7FV5HN", 10), + Prev: prevPage("01H11KBBVRRDYYC5KEPME1NP5R", 10), + } + + resp := paging.PackageResponse(params) + + suite.Equal(make([]interface{}, 10, 10), resp.Items) + suite.Equal(`; rel="next", ; rel="prev"`, resp.LinkHeader) + suite.Equal(`https://example.org/api/v1/accounts/01H11KA68PM4NNYJEG0FJQ90R3/statuses?max_id=01H11KA1DM2VH3747YDE7FV5HN&limit=10`, resp.NextLink) + suite.Equal(`https://example.org/api/v1/accounts/01H11KA68PM4NNYJEG0FJQ90R3/statuses?min_id=01H11KBBVRRDYYC5KEPME1NP5R&limit=10`, resp.PrevLink) +} + +func (suite *PagingSuite) TestPagingNoLimit() { + config.SetHost("example.org") + + params := paging.ResponseParams{ + Items: make([]interface{}, 10, 10), + Path: "/api/v1/accounts/01H11KA68PM4NNYJEG0FJQ90R3/statuses", + Next: nextPage("01H11KA1DM2VH3747YDE7FV5HN", 0), + Prev: prevPage("01H11KBBVRRDYYC5KEPME1NP5R", 0), + } + + resp := paging.PackageResponse(params) + + suite.Equal(make([]interface{}, 10, 10), resp.Items) + suite.Equal(`; rel="next", ; rel="prev"`, resp.LinkHeader) + suite.Equal(`https://example.org/api/v1/accounts/01H11KA68PM4NNYJEG0FJQ90R3/statuses?max_id=01H11KA1DM2VH3747YDE7FV5HN`, resp.NextLink) + suite.Equal(`https://example.org/api/v1/accounts/01H11KA68PM4NNYJEG0FJQ90R3/statuses?min_id=01H11KBBVRRDYYC5KEPME1NP5R`, resp.PrevLink) +} + +func (suite *PagingSuite) TestPagingNoNextID() { + config.SetHost("example.org") + + params := paging.ResponseParams{ + Items: make([]interface{}, 10, 10), + Path: "/api/v1/accounts/01H11KA68PM4NNYJEG0FJQ90R3/statuses", + Prev: prevPage("01H11KBBVRRDYYC5KEPME1NP5R", 10), + } + + resp := paging.PackageResponse(params) + + suite.Equal(make([]interface{}, 10, 10), resp.Items) + suite.Equal(`; rel="prev"`, resp.LinkHeader) + suite.Equal(``, resp.NextLink) + suite.Equal(`https://example.org/api/v1/accounts/01H11KA68PM4NNYJEG0FJQ90R3/statuses?min_id=01H11KBBVRRDYYC5KEPME1NP5R&limit=10`, resp.PrevLink) +} + +func (suite *PagingSuite) TestPagingNoPrevID() { + config.SetHost("example.org") + + params := paging.ResponseParams{ + Items: make([]interface{}, 10, 10), + Path: "/api/v1/accounts/01H11KA68PM4NNYJEG0FJQ90R3/statuses", + Next: nextPage("01H11KA1DM2VH3747YDE7FV5HN", 10), + } + + resp := paging.PackageResponse(params) + + suite.Equal(make([]interface{}, 10, 10), resp.Items) + suite.Equal(`; rel="next"`, resp.LinkHeader) + suite.Equal(`https://example.org/api/v1/accounts/01H11KA68PM4NNYJEG0FJQ90R3/statuses?max_id=01H11KA1DM2VH3747YDE7FV5HN&limit=10`, resp.NextLink) + suite.Equal(``, resp.PrevLink) +} + +func (suite *PagingSuite) TestPagingNoItems() { + config.SetHost("example.org") + + params := paging.ResponseParams{ + Next: nextPage("01H11KA1DM2VH3747YDE7FV5HN", 10), + Prev: prevPage("01H11KBBVRRDYYC5KEPME1NP5R", 10), + } + + resp := paging.PackageResponse(params) + + suite.Empty(resp.Items) + suite.Empty(resp.LinkHeader) + suite.Empty(resp.NextLink) + suite.Empty(resp.PrevLink) +} + +func TestPagingSuite(t *testing.T) { + suite.Run(t, &PagingSuite{}) +} + +func nextPage(id string, limit int) *paging.Page { + return &paging.Page{ + Max: paging.MaxID(id), + Limit: limit, + } +} + +func prevPage(id string, limit int) *paging.Page { + return &paging.Page{ + Min: paging.MinID(id, ""), + Limit: limit, + } +} diff --git a/internal/paging/util.go b/internal/paging/util.go new file mode 100644 index 000000000..d9adb9cbf --- /dev/null +++ b/internal/paging/util.go @@ -0,0 +1,49 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package paging + +// Reverse will reverse the given input slice. +func Reverse(in []string) []string { + var ( + // Start at front. + i = 0 + + // Start at back. + j = len(in) - 1 + ) + + for i < j { + // Swap i,j index values in slice. + in[i], in[j] = in[j], in[i] + + // incr + decr, + // looping until + // they meet in + // the middle. + i++ + j-- + } + + return in +} + +// zero is a shorthand to check a generic value is its zero value. +func zero[T comparable](t T) bool { + var z T + return t == z +} diff --git a/internal/processing/blocks.go b/internal/processing/blocks.go index 8996dff92..014b6af21 100644 --- a/internal/processing/blocks.go +++ b/internal/processing/blocks.go @@ -34,11 +34,11 @@ import ( func (p *Processor) BlocksGet( ctx context.Context, requestingAccount *gtsmodel.Account, - page paging.Pager, + page *paging.Page, ) (*apimodel.PageableResponse, gtserror.WithCode) { blocks, err := p.state.DB.GetAccountBlocks(ctx, requestingAccount.ID, - &page, + page, ) if err != nil && !errors.Is(err, db.ErrNoEntries) { return nil, gtserror.NewErrorInternalError(err) @@ -77,13 +77,10 @@ func (p *Processor) BlocksGet( items = append(items, account) } - return util.PackagePageableResponse(util.PageableResponseParams{ - Items: items, - Path: "/api/v1/blocks", - NextMaxIDKey: "max_id", - PrevMinIDKey: "since_id", - NextMaxIDValue: nextMaxIDValue, - PrevMinIDValue: prevMinIDValue, - Limit: page.Limit, - }) + return paging.PackageResponse(paging.ResponseParams{ + Items: items, + Path: "/api/v1/blocks", + Next: page.Next(nextMaxIDValue), + Prev: page.Prev(prevMinIDValue), + }), nil }