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
}