diff --git a/README.md b/README.md index c6f9171..7df252b 100644 --- a/README.md +++ b/README.md @@ -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=&limit=20` + +Cursor tokens are opaque base64-encoded values. Invalid cursors return 400. + ### Custom Config ```go @@ -96,6 +140,8 @@ 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 @@ -103,10 +149,13 @@ 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) } ``` @@ -116,6 +165,9 @@ 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 @@ -123,6 +175,7 @@ type PageInfo struct { - 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 diff --git a/config.go b/config.go index 4cad19a..92d0c81 100644 --- a/config.go +++ b/config.go @@ -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. @@ -36,6 +42,7 @@ var ConfigDefault = Config{ DefaultPage: 1, LimitKey: "limit", DefaultLimit: 10, + CursorKey: "cursor", } func configDefault(config ...Config) Config { @@ -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 } diff --git a/config_test.go b/config_test.go index 8977d45..08be7a2 100644 --- a/config_test.go +++ b/config_test.go @@ -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() diff --git a/page_info.go b/page_info.go index fb1b7d4..1eb2ab9 100644 --- a/page_info.go +++ b/page_info.go @@ -1,6 +1,10 @@ package spindle -import "fmt" +import ( + "encoding/base64" + "encoding/json" + "fmt" +) // SortOrder represents sort order. type SortOrder string @@ -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. @@ -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 +} diff --git a/page_info_test.go b/page_info_test.go index a084ee2..f61af2c 100644 --- a/page_info_test.go +++ b/page_info_test.go @@ -1,6 +1,9 @@ package spindle -import "testing" +import ( + "fmt" + "testing" +) func TestSortOrderFromString(t *testing.T) { t.Parallel() @@ -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() @@ -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") + } +} diff --git a/paginate.go b/paginate.go index de2743f..a78842b 100644 --- a/paginate.go +++ b/paginate.go @@ -1,6 +1,8 @@ package spindle import ( + "encoding/base64" + "encoding/json" "slices" "strings" @@ -26,8 +28,6 @@ func New(config ...Config) fiber.Handler { return c.Next() } - page := max(fiber.Query(c, cfg.PageKey, cfg.DefaultPage), 1) - limit := fiber.Query(c, cfg.LimitKey, cfg.DefaultLimit) if limit < 1 { limit = cfg.DefaultLimit @@ -36,12 +36,36 @@ func New(config ...Config) fiber.Handler { limit = MaxLimit } - offset := max(fiber.Query(c, "offset", 0), 0) - sorts := parseSortQuery(c.Query(cfg.SortKey), cfg.AllowedSorts, cfg.DefaultSort) - c.Locals(pageInfoKey, NewPageInfo(page, limit, offset, sorts)) + cursorRaw := c.Query(cfg.CursorKey) + if cursorRaw == "" && cfg.CursorParam != "" { + cursorRaw = c.Query(cfg.CursorParam) + } + if cursorRaw != "" { + data, err := base64.RawURLEncoding.DecodeString(cursorRaw) + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid cursor"}) + } + var obj map[string]any + if err := json.Unmarshal(data, &obj); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid cursor"}) + } + + pageInfo := &PageInfo{ + Limit: limit, + Sort: sorts, + Cursor: cursorRaw, + } + c.Locals(pageInfoKey, pageInfo) + return c.Next() + } + + page := max(fiber.Query(c, cfg.PageKey, cfg.DefaultPage), 1) + offset := max(fiber.Query(c, "offset", 0), 0) + + c.Locals(pageInfoKey, NewPageInfo(page, limit, offset, sorts)) return c.Next() } } diff --git a/paginate_test.go b/paginate_test.go index e37e467..a9ab3bc 100644 --- a/paginate_test.go +++ b/paginate_test.go @@ -1,6 +1,7 @@ package spindle import ( + "encoding/base64" "encoding/json" "net/http/httptest" "reflect" @@ -643,3 +644,333 @@ func BenchmarkPaginateMiddlewareWithCustomConfig(b *testing.B) { } } } + +type CursorResponse struct { + Cursor string `json:"cursor"` + Limit int `json:"limit"` + HasMore bool `json:"has_more"` + NextCursor string `json:"next_cursor"` + Sort []SortField `json:"sort"` +} + +func Test_PaginateWithCursor(t *testing.T) { + t.Parallel() + app := fiber.New() + app.Use(New(Config{ + DefaultSort: "id", + })) + + app.Get("/", func(c fiber.Ctx) error { + pageInfo, ok := FromContext(c) + if !ok { + return fiber.ErrBadRequest + } + return c.JSON(CursorResponse{ + Cursor: pageInfo.Cursor, + Limit: pageInfo.Limit, + Sort: pageInfo.Sort, + }) + }) + + // Encode a valid cursor: {"id": 42} + cursorJSON := `{"id":42}` + cursor := base64.RawURLEncoding.EncodeToString([]byte(cursorJSON)) + + resp, err := app.Test(httptest.NewRequest("GET", "/?cursor="+cursor+"&limit=20", nil)) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != 200 { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + + var result CursorResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatal(err) + } + if result.Cursor != cursor { + t.Errorf("Cursor = %q, want %q", result.Cursor, cursor) + } + if result.Limit != 20 { + t.Errorf("Limit = %d, want 20", result.Limit) + } +} + +func Test_PaginateCursorPriorityOverPage(t *testing.T) { + t.Parallel() + app := fiber.New() + app.Use(New()) + + app.Get("/", func(c fiber.Ctx) error { + pageInfo, ok := FromContext(c) + if !ok { + return fiber.ErrBadRequest + } + return c.JSON(pageInfo) + }) + + cursorJSON := `{"id":42}` + cursor := base64.RawURLEncoding.EncodeToString([]byte(cursorJSON)) + + // Both cursor and page present — cursor should win + resp, err := app.Test(httptest.NewRequest("GET", "/?cursor="+cursor+"&page=5&limit=10", nil)) + if err != nil { + t.Fatal(err) + } + + var result PageInfo + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatal(err) + } + if result.Cursor != cursor { + t.Errorf("Cursor = %q, want %q", result.Cursor, cursor) + } + // Page should be 0 since cursor mode ignores it + if result.Page != 0 { + t.Errorf("Page = %d, want 0 in cursor mode", result.Page) + } +} + +func Test_PaginateEmptyCursorIsFirstPage(t *testing.T) { + t.Parallel() + app := fiber.New() + app.Use(New()) + + app.Get("/", func(c fiber.Ctx) error { + pageInfo, ok := FromContext(c) + if !ok { + return fiber.ErrBadRequest + } + return c.JSON(pageInfo) + }) + + // Empty cursor = first page, should not error + resp, err := app.Test(httptest.NewRequest("GET", "/?cursor=&limit=10", nil)) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != 200 { + t.Fatalf("status = %d, want 200 for empty cursor", resp.StatusCode) + } + + var result PageInfo + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatal(err) + } + if result.Cursor != "" { + t.Errorf("Cursor = %q, want empty string", result.Cursor) + } +} + +func Test_PaginateInvalidCursorReturns400(t *testing.T) { + t.Parallel() + app := fiber.New() + app.Use(New()) + + app.Get("/", func(c fiber.Ctx) error { + pageInfo, _ := FromContext(c) + return c.JSON(pageInfo) + }) + + testCases := []struct { + name string + cursor string + }{ + {"Invalid base64", "not-valid!!!"}, + {"Valid base64 but invalid JSON", base64.RawURLEncoding.EncodeToString([]byte("not-json"))}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + resp, err := app.Test(httptest.NewRequest("GET", "/?cursor="+tc.cursor, nil)) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != 400 { + t.Errorf("status = %d, want 400 for %s", resp.StatusCode, tc.name) + } + }) + } +} + +func Test_PaginateCursorWithSort(t *testing.T) { + t.Parallel() + app := fiber.New() + app.Use(New(Config{ + SortKey: "sort", + DefaultSort: "id", + AllowedSorts: []string{"id", "name"}, + })) + + app.Get("/", func(c fiber.Ctx) error { + pageInfo, ok := FromContext(c) + if !ok { + return fiber.ErrBadRequest + } + return c.JSON(CursorResponse{ + Cursor: pageInfo.Cursor, + Sort: pageInfo.Sort, + }) + }) + + cursorJSON := `{"id":42}` + cursor := base64.RawURLEncoding.EncodeToString([]byte(cursorJSON)) + + resp, err := app.Test(httptest.NewRequest("GET", "/?cursor="+cursor+"&sort=name,-id", nil)) + if err != nil { + t.Fatal(err) + } + + var result CursorResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatal(err) + } + + expectedSort := []SortField{{Field: "name", Order: ASC}, {Field: "id", Order: DESC}} + if !reflect.DeepEqual(result.Sort, expectedSort) { + t.Errorf("Sort = %v, want %v", result.Sort, expectedSort) + } +} + +func Test_PaginateCursorWithCustomKey(t *testing.T) { + t.Parallel() + app := fiber.New() + app.Use(New(Config{ + CursorKey: "after", + })) + + app.Get("/", func(c fiber.Ctx) error { + pageInfo, ok := FromContext(c) + if !ok { + return fiber.ErrBadRequest + } + return c.JSON(CursorResponse{ + Cursor: pageInfo.Cursor, + Limit: pageInfo.Limit, + }) + }) + + cursorJSON := `{"id":1}` + cursor := base64.RawURLEncoding.EncodeToString([]byte(cursorJSON)) + + resp, err := app.Test(httptest.NewRequest("GET", "/?after="+cursor, nil)) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != 200 { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + + var result CursorResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatal(err) + } + if result.Cursor != cursor { + t.Errorf("Cursor = %q, want %q", result.Cursor, cursor) + } +} + +func Test_PaginateCursorWithParamAlias(t *testing.T) { + t.Parallel() + app := fiber.New() + app.Use(New(Config{ + CursorParam: "starting_after", + })) + + app.Get("/", func(c fiber.Ctx) error { + pageInfo, ok := FromContext(c) + if !ok { + return fiber.ErrBadRequest + } + return c.JSON(CursorResponse{ + Cursor: pageInfo.Cursor, + }) + }) + + cursorJSON := `{"id":1}` + cursor := base64.RawURLEncoding.EncodeToString([]byte(cursorJSON)) + + // Use the alias param name + resp, err := app.Test(httptest.NewRequest("GET", "/?starting_after="+cursor, nil)) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != 200 { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + + var result CursorResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatal(err) + } + if result.Cursor != cursor { + t.Errorf("Cursor = %q, want %q", result.Cursor, cursor) + } +} + +func Test_PaginateNoCursorFallsBackToPageMode(t *testing.T) { + t.Parallel() + app := fiber.New() + app.Use(New(Config{ + DefaultSort: "id", + })) + + app.Get("/", func(c fiber.Ctx) error { + pageInfo, ok := FromContext(c) + if !ok { + return fiber.ErrBadRequest + } + return c.JSON(Response{ + Page: pageInfo.Page, + Limit: pageInfo.Limit, + Start: pageInfo.Start(), + }) + }) + + // No cursor param — should behave exactly as before + resp, err := app.Test(httptest.NewRequest("GET", "/?page=3&limit=15", nil)) + if err != nil { + t.Fatal(err) + } + + var result Response + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatal(err) + } + if result.Page != 3 { + t.Errorf("Page = %d, want 3", result.Page) + } + if result.Limit != 15 { + t.Errorf("Limit = %d, want 15", result.Limit) + } + if result.Start != 30 { + t.Errorf("Start = %d, want 30", result.Start) + } +} + +func BenchmarkPaginateCursorMiddleware(b *testing.B) { + app := fiber.New() + app.Use(New(Config{ + SortKey: "sort", + DefaultSort: "id", + AllowedSorts: []string{"id", "name", "date"}, + })) + + app.Get("/", func(c fiber.Ctx) error { + pageInfo, _ := FromContext(c) + return c.JSON(pageInfo) + }) + + cursorJSON := `{"id":42,"created_at":"2026-01-01T00:00:00Z"}` + cursor := base64.RawURLEncoding.EncodeToString([]byte(cursorJSON)) + + b.ResetTimer() + + for i := 0; i < b.N; i++ { + req := httptest.NewRequest("GET", "/?cursor="+cursor+"&limit=20&sort=name,-id", nil) + _, err := app.Test(req, fiber.TestConfig{Timeout: 0}) + if err != nil { + b.Fatal(err) + } + } +}