Skip to content
This repository was archived by the owner on Mar 11, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 57 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,50 @@ Request: `GET /users?page=1&limit=10&sort=name,-created_at`

Sort fields are comma-separated. Prefix with `-` for descending order.

### Cursor Pagination

For infinite scroll and keyset pagination:

```go
app.Use(spindle.New(spindle.Config{
CursorKey: "cursor", // default
}))

app.Get("/users", func(c fiber.Ctx) error {
pageInfo, ok := spindle.FromContext(c)
if !ok {
return fiber.ErrBadRequest
}

query := db.Model(&User{}).OrderBy("id ASC").Limit(pageInfo.Limit + 1)

if vals := pageInfo.CursorValues(); vals != nil {
query = query.Where("id > ?", vals["id"])
}

var users []User
query.Find(&users)

hasMore := len(users) > pageInfo.Limit
if hasMore {
users = users[:pageInfo.Limit]
last := users[len(users)-1]
pageInfo.SetNextCursor(map[string]any{"id": last.ID})
}

return c.JSON(fiber.Map{
"data": users,
"has_more": pageInfo.HasMore,
"next_cursor": pageInfo.NextCursor,
})
})
```

First request: `GET /users?limit=20`
Next request: `GET /users?cursor=<next_cursor>&limit=20`

Cursor tokens are opaque base64-encoded values. Invalid cursors return 400.

### Custom Config

```go
Expand Down Expand Up @@ -96,17 +140,22 @@ app.Use(spindle.New(spindle.Config{
| SortKey | `string` | Query key for sort | `""` |
| DefaultSort | `string` | Default sort field | `"id"` |
| AllowedSorts | `[]string` | Allowed sort field names | `[]` |
| CursorKey | `string` | Query key for cursor token | `"cursor"` |
| CursorParam | `string` | Optional alias for cursor key | `""` |

## PageInfo

Retrieved via `spindle.FromContext(c)`:

```go
type PageInfo struct {
Page int // Current page number
Limit int // Items per page (capped at 100)
Offset int // Direct offset
Sort []SortField // Sort fields with direction
Page int // Current page number
Limit int // Items per page (capped at 100)
Offset int // Direct offset
Sort []SortField // Sort fields with direction
Cursor string // Cursor token (empty if not in cursor mode)
HasMore bool // True if more results exist (set by handler)
NextCursor string // Opaque cursor for next page (set by handler)
}
```

Expand All @@ -116,13 +165,17 @@ type PageInfo struct {
- `SortBy(field string, order SortOrder) *PageInfo` - Adds a sort field. Chainable.
- `NextPageURL(baseURL string) string` - Returns the URL for the next page.
- `PreviousPageURL(baseURL string) string` - Returns the URL for the previous page. Empty string if on page 1.
- `CursorValues() map[string]any` - Decodes the cursor into key-value pairs. Returns nil if empty or invalid.
- `SetNextCursor(values map[string]any) *PageInfo` - Encodes values into an opaque cursor and sets HasMore. Chainable.
- `NextCursorURL(baseURL string) string` - Returns the URL for the next cursor page. Empty string if HasMore is false.

## Safety

- Limit is capped at `MaxLimit` (100) to prevent excessive memory usage
- Page values below 1 are reset to 1
- Negative offsets are reset to 0
- Sort fields are validated against `AllowedSorts`
- Invalid cursor tokens return 400 Bad Request

## Development

Expand Down
10 changes: 10 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ type Config struct {

// AllowedSorts is the list of allowed sort fields.
AllowedSorts []string

// CursorKey is the query string key for cursor-based pagination.
CursorKey string

// CursorParam is an optional alias for the cursor query key.
CursorParam string
}

// ConfigDefault is the default config.
Expand All @@ -36,6 +42,7 @@ var ConfigDefault = Config{
DefaultPage: 1,
LimitKey: "limit",
DefaultLimit: 10,
CursorKey: "cursor",
}

func configDefault(config ...Config) Config {
Expand All @@ -60,6 +67,9 @@ func configDefault(config ...Config) Config {
if cfg.DefaultPage < 1 {
cfg.DefaultPage = ConfigDefault.DefaultPage
}
if cfg.CursorKey == "" {
cfg.CursorKey = ConfigDefault.CursorKey
}

return cfg
}
24 changes: 24 additions & 0 deletions config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,30 @@ func TestConfigOverride(t *testing.T) {
}
}

func TestConfigDefaultCursorKey(t *testing.T) {
t.Parallel()

cfg := configDefault()
if cfg.CursorKey != "cursor" {
t.Errorf("CursorKey = %q, want %q", cfg.CursorKey, "cursor")
}
}

func TestConfigOverrideCursorKey(t *testing.T) {
t.Parallel()

cfg := configDefault(Config{
CursorKey: "after",
CursorParam: "starting_after",
})
if cfg.CursorKey != "after" {
t.Errorf("CursorKey = %q, want %q", cfg.CursorKey, "after")
}
if cfg.CursorParam != "starting_after" {
t.Errorf("CursorParam = %q, want %q", cfg.CursorParam, "starting_after")
}
}

func TestConfigNegativeDefaults(t *testing.T) {
t.Parallel()

Expand Down
60 changes: 55 additions & 5 deletions page_info.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package spindle

import "fmt"
import (
"encoding/base64"
"encoding/json"
"fmt"
)

// SortOrder represents sort order.
type SortOrder string
Expand Down Expand Up @@ -30,10 +34,13 @@ func SortOrderFromString(s string) SortOrder {

// PageInfo contains pagination information.
type PageInfo struct {
Page int `json:"page"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Sort []SortField `json:"sort"`
Page int `json:"page"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Sort []SortField `json:"sort"`
Cursor string `json:"cursor,omitempty"`
HasMore bool `json:"has_more,omitempty"`
NextCursor string `json:"next_cursor,omitempty"`
}

// NewPageInfo creates a new PageInfo.
Expand Down Expand Up @@ -73,3 +80,46 @@ func (p *PageInfo) PreviousPageURL(baseURL string) string {
}
return ""
}

// NextCursorURL returns the URL for the next cursor page.
// Returns empty string if HasMore is false.
func (p *PageInfo) NextCursorURL(baseURL string) string {
if !p.HasMore {
return ""
}
return fmt.Sprintf("%s?cursor=%s&limit=%d", baseURL, p.NextCursor, p.Limit)
}

// CursorValues decodes the opaque cursor into a key-value map.
// Returns nil if cursor is empty or invalid.
func (p *PageInfo) CursorValues() map[string]any {
if p.Cursor == "" {
return nil
}

data, err := base64.RawURLEncoding.DecodeString(p.Cursor)
if err != nil {
return nil
}

var values map[string]any
if err := json.Unmarshal(data, &values); err != nil {
return nil
}

return values
}

// SetNextCursor encodes a key-value map into an opaque cursor token
// and sets both NextCursor and HasMore on the PageInfo. Chainable.
func (p *PageInfo) SetNextCursor(values map[string]any) *PageInfo {
data, err := json.Marshal(values)
if err != nil {
return p
}

p.NextCursor = base64.RawURLEncoding.EncodeToString(data)
p.HasMore = true

return p
}
122 changes: 121 additions & 1 deletion page_info_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package spindle

import "testing"
import (
"fmt"
"testing"
)

func TestSortOrderFromString(t *testing.T) {
t.Parallel()
Expand Down Expand Up @@ -99,6 +102,26 @@ func TestPageInfoNextPageURL(t *testing.T) {
}
}

func TestPageInfoCursorFields(t *testing.T) {
t.Parallel()

p := &PageInfo{
Cursor: "abc123",
HasMore: true,
NextCursor: "def456",
}

if p.Cursor != "abc123" {
t.Errorf("Cursor = %q, want %q", p.Cursor, "abc123")
}
if !p.HasMore {
t.Error("HasMore = false, want true")
}
if p.NextCursor != "def456" {
t.Errorf("NextCursor = %q, want %q", p.NextCursor, "def456")
}
}

func TestPageInfoPreviousPageURL(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -131,3 +154,100 @@ func TestPageInfoPreviousPageURL(t *testing.T) {
})
}
}

func TestCursorValuesRoundTrip(t *testing.T) {
t.Parallel()

original := map[string]any{
"id": float64(42),
"created_at": "2026-01-01T00:00:00Z",
}

p := &PageInfo{}
p.SetNextCursor(original)

if !p.HasMore {
t.Error("HasMore = false, want true after SetNextCursor")
}
if p.NextCursor == "" {
t.Fatal("NextCursor is empty after SetNextCursor")
}

// Simulate next request: cursor from previous response becomes input
p2 := &PageInfo{Cursor: p.NextCursor}
decoded := p2.CursorValues()

if decoded == nil {
t.Fatal("CursorValues() returned nil")
}
if decoded["id"] != float64(42) {
t.Errorf("decoded[id] = %v, want 42", decoded["id"])
}
if decoded["created_at"] != "2026-01-01T00:00:00Z" {
t.Errorf("decoded[created_at] = %v, want 2026-01-01T00:00:00Z", decoded["created_at"])
}
}

func TestCursorValuesEmptyCursor(t *testing.T) {
t.Parallel()

p := &PageInfo{Cursor: ""}
if vals := p.CursorValues(); vals != nil {
t.Errorf("CursorValues() = %v, want nil for empty cursor", vals)
}
}

func TestCursorValuesInvalidBase64(t *testing.T) {
t.Parallel()

p := &PageInfo{Cursor: "not-valid-base64!!!"}
if vals := p.CursorValues(); vals != nil {
t.Errorf("CursorValues() = %v, want nil for invalid base64", vals)
}
}

func TestCursorValuesInvalidJSON(t *testing.T) {
t.Parallel()

// Valid base64 but not valid JSON
p := &PageInfo{Cursor: "bm90LWpzb24"}
if vals := p.CursorValues(); vals != nil {
t.Errorf("CursorValues() = %v, want nil for invalid JSON", vals)
}
}

func TestNextCursorURL(t *testing.T) {
t.Parallel()

t.Run("with HasMore", func(t *testing.T) {
p := &PageInfo{Limit: 20}
p.SetNextCursor(map[string]any{"id": float64(42)})

url := p.NextCursorURL("https://example.com/users")

expected := fmt.Sprintf("https://example.com/users?cursor=%s&limit=20", p.NextCursor)
if url != expected {
t.Errorf("NextCursorURL() = %q, want %q", url, expected)
}
})

t.Run("without HasMore", func(t *testing.T) {
p := &PageInfo{Limit: 20}

url := p.NextCursorURL("https://example.com/users")
if url != "" {
t.Errorf("NextCursorURL() = %q, want empty string when HasMore is false", url)
}
})
}

func TestSetNextCursorChainable(t *testing.T) {
t.Parallel()

p := &PageInfo{Limit: 10}
result := p.SetNextCursor(map[string]any{"id": float64(1)})

if result != p {
t.Error("SetNextCursor should return the same PageInfo for chaining")
}
}
Loading