mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2024-11-22 03:36:39 +00:00
[chore] much improved paging package (#2182)
This commit is contained in:
parent
14ef098099
commit
b093947d84
|
@ -103,8 +103,12 @@ func (m *Module) BlocksGETHandler(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
limit, errWithCode := apiutil.ParseLimit(c.Query(LimitKey), 20, 100, 2)
|
page, errWithCode := paging.ParseIDPage(c,
|
||||||
if err != nil {
|
1, // min limit
|
||||||
|
100, // max limit
|
||||||
|
20, // default limit
|
||||||
|
)
|
||||||
|
if errWithCode != nil {
|
||||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -112,11 +116,7 @@ func (m *Module) BlocksGETHandler(c *gin.Context) {
|
||||||
resp, errWithCode := m.processor.BlocksGet(
|
resp, errWithCode := m.processor.BlocksGet(
|
||||||
c.Request.Context(),
|
c.Request.Context(),
|
||||||
authed.Account,
|
authed.Account,
|
||||||
paging.Pager{
|
page,
|
||||||
SinceID: c.Query(SinceIDKey),
|
|
||||||
MaxID: c.Query(MaxIDKey),
|
|
||||||
Limit: limit,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
if errWithCode != nil {
|
if errWithCode != nil {
|
||||||
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1)
|
||||||
|
|
25
internal/cache/slice.go
vendored
25
internal/cache/slice.go
vendored
|
@ -49,28 +49,3 @@ func (c *SliceCache[T]) Load(key string, load func() ([]T, error)) ([]T, error)
|
||||||
// Return data clone for safety.
|
// Return data clone for safety.
|
||||||
return slices.Clone(data), nil
|
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
|
|
||||||
}
|
|
||||||
|
|
|
@ -150,9 +150,9 @@ func (r *relationshipDB) GetAccountFollowRequesting(ctx context.Context, account
|
||||||
return r.GetFollowRequestsByIDs(ctx, followReqIDs)
|
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.
|
// 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
|
var blockIDs []string
|
||||||
|
|
||||||
// Block IDs not in cache, perform DB query!
|
// Block IDs not in cache, perform DB query!
|
||||||
|
@ -162,11 +162,22 @@ func (r *relationshipDB) GetAccountBlocks(ctx context.Context, accountID string,
|
||||||
}
|
}
|
||||||
|
|
||||||
return blockIDs, nil
|
return blockIDs, nil
|
||||||
}, page.PageDesc)
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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.
|
// Convert these IDs to full block objects.
|
||||||
return r.GetBlocksByIDs(ctx, blockIDs)
|
return r.GetBlocksByIDs(ctx, blockIDs)
|
||||||
}
|
}
|
||||||
|
|
|
@ -174,7 +174,7 @@ type Relationship interface {
|
||||||
CountAccountFollowRequesting(ctx context.Context, accountID string) (int, error)
|
CountAccountFollowRequesting(ctx context.Context, accountID string) (int, error)
|
||||||
|
|
||||||
// GetAccountBlocks returns all blocks originating from the given account, with given optional paging parameters.
|
// 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 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)
|
GetNote(ctx context.Context, sourceAccountID string, targetAccountID string) (*gtsmodel.AccountNote, error)
|
||||||
|
|
135
internal/paging/boundary.go
Normal file
135
internal/paging/boundary.go
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
55
internal/paging/order.go
Normal file
55
internal/paging/order.go
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
251
internal/paging/page.go
Normal file
251
internal/paging/page.go
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
298
internal/paging/page_test.go
Normal file
298
internal/paging/page_test.go
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
111
internal/paging/parse.go
Normal file
111
internal/paging/parse.go
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
91
internal/paging/response.go
Normal file
91
internal/paging/response.go
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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{}{},
|
||||||
|
}
|
||||||
|
}
|
134
internal/paging/response_test.go
Normal file
134
internal/paging/response_test.go
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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(`<https://example.org/api/v1/accounts/01H11KA68PM4NNYJEG0FJQ90R3/statuses?max_id=01H11KA1DM2VH3747YDE7FV5HN&limit=10>; rel="next", <https://example.org/api/v1/accounts/01H11KA68PM4NNYJEG0FJQ90R3/statuses?min_id=01H11KBBVRRDYYC5KEPME1NP5R&limit=10>; 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(`<https://example.org/api/v1/accounts/01H11KA68PM4NNYJEG0FJQ90R3/statuses?max_id=01H11KA1DM2VH3747YDE7FV5HN>; rel="next", <https://example.org/api/v1/accounts/01H11KA68PM4NNYJEG0FJQ90R3/statuses?min_id=01H11KBBVRRDYYC5KEPME1NP5R>; 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(`<https://example.org/api/v1/accounts/01H11KA68PM4NNYJEG0FJQ90R3/statuses?min_id=01H11KBBVRRDYYC5KEPME1NP5R&limit=10>; 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(`<https://example.org/api/v1/accounts/01H11KA68PM4NNYJEG0FJQ90R3/statuses?max_id=01H11KA1DM2VH3747YDE7FV5HN&limit=10>; 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,
|
||||||
|
}
|
||||||
|
}
|
49
internal/paging/util.go
Normal file
49
internal/paging/util.go
Normal file
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
|
@ -34,11 +34,11 @@
|
||||||
func (p *Processor) BlocksGet(
|
func (p *Processor) BlocksGet(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
requestingAccount *gtsmodel.Account,
|
requestingAccount *gtsmodel.Account,
|
||||||
page paging.Pager,
|
page *paging.Page,
|
||||||
) (*apimodel.PageableResponse, gtserror.WithCode) {
|
) (*apimodel.PageableResponse, gtserror.WithCode) {
|
||||||
blocks, err := p.state.DB.GetAccountBlocks(ctx,
|
blocks, err := p.state.DB.GetAccountBlocks(ctx,
|
||||||
requestingAccount.ID,
|
requestingAccount.ID,
|
||||||
&page,
|
page,
|
||||||
)
|
)
|
||||||
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
if err != nil && !errors.Is(err, db.ErrNoEntries) {
|
||||||
return nil, gtserror.NewErrorInternalError(err)
|
return nil, gtserror.NewErrorInternalError(err)
|
||||||
|
@ -77,13 +77,10 @@ func (p *Processor) BlocksGet(
|
||||||
items = append(items, account)
|
items = append(items, account)
|
||||||
}
|
}
|
||||||
|
|
||||||
return util.PackagePageableResponse(util.PageableResponseParams{
|
return paging.PackageResponse(paging.ResponseParams{
|
||||||
Items: items,
|
Items: items,
|
||||||
Path: "/api/v1/blocks",
|
Path: "/api/v1/blocks",
|
||||||
NextMaxIDKey: "max_id",
|
Next: page.Next(nextMaxIDValue),
|
||||||
PrevMinIDKey: "since_id",
|
Prev: page.Prev(prevMinIDValue),
|
||||||
NextMaxIDValue: nextMaxIDValue,
|
}), nil
|
||||||
PrevMinIDValue: prevMinIDValue,
|
|
||||||
Limit: page.Limit,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue