// Package pagination provides cursor-based pagination utilities for GraphQL Relay-style pagination. // Cursors are base64-encoded strings that can be used with first/after and last/before parameters. package pagination import ( "encoding/base64" "errors" "slices" ) // Pagination validation errors. var ( ErrFirstAndLastProvided = errors.New("only one of first and last can be provided") ErrFirstNegative = errors.New("first must be greater than 0") ErrLastNegative = errors.New("last must be greater than 0") ErrAfterAndBeforeProvided = errors.New("only one of after and before can be provided") ErrInvalidAfterCursor = errors.New("after is not a valid cursor") ErrInvalidBeforeCursor = errors.New("before is not a valid cursor") ErrInvalidCursor = errors.New("invalid cursor") ) // Validate checks that the pagination parameters are valid according to Relay specification. // It ensures that first and last are not both provided, that they are non-negative, // and that after and before cursors are valid base64-encoded strings. func Validate(first *int, after *string, last *int, before *string) error { if first != nil && last != nil { return ErrFirstAndLastProvided } if first != nil && *first < 0 { return ErrFirstNegative } if last != nil && *last < 0 { return ErrLastNegative } if after != nil && len(*after) > 0 && before != nil && len(*before) > 0 { return ErrAfterAndBeforeProvided } if ValidateCursor(after) != nil { return ErrInvalidAfterCursor } if ValidateCursor(before) != nil { return ErrInvalidBeforeCursor } return nil } // ValidateCursor checks if a cursor is a valid base64-encoded string. // Returns nil if the cursor is nil or valid, ErrInvalidCursor otherwise. func ValidateCursor(cursor *string) error { _, err := DecodeCursor(cursor) if err != nil { return err } return nil } // DecodeCursor decodes a base64-encoded cursor string. // Returns an empty string if the cursor is nil. func DecodeCursor(cursor *string) (string, error) { if cursor == nil { return "", nil } b64, err := base64.StdEncoding.DecodeString(*cursor) if err != nil { return "", ErrInvalidCursor } return string(b64), nil } // EncodeCursor encodes a string value as a base64 cursor. func EncodeCursor(cursor string) string { return base64.StdEncoding.EncodeToString([]byte(cursor)) } // GetPage returns a paginated slice of items based on the provided pagination parameters. // The fn parameter extracts the cursor value from each item. // If neither first nor last is provided, max is used as the default page size. func GetPage[T any](items []T, first *int, after *string, last *int, before *string, max int, fn func(T) string) ([]T, PageInfo) { if len(items) == 0 { return nil, PageInfo{} } tmp := min(max, len(items)) sIx := 0 eIx := sIx + tmp if first != nil { tmp = *first eIx = sIx + tmp } else if last != nil { tmp = *last sIx = len(items) - tmp if sIx < 0 { sIx = 0 } eIx = len(items) } if cursor, err := DecodeCursor(after); err == nil && cursor != "" { idx := slices.IndexFunc(items, func(item T) bool { return fn(item) == cursor }) idx = idx + 1 if idx+tmp >= len(items) { tmp = len(items) - idx } sIx = idx eIx = idx + tmp } else if cursor, err := DecodeCursor(before); err == nil && cursor != "" { idx := slices.IndexFunc(items, func(item T) bool { return fn(item) == cursor }) f := idx - tmp if f < 0 { f = 0 } sIx = f eIx = idx } page := items[sIx:min(eIx, len(items))] if len(page) == 0 { return nil, PageInfo{} } return page, PageInfo{ StartCursor: ptr(EncodeCursor(fn(page[0]))), HasNextPage: eIx < len(items), HasPreviousPage: sIx > 0, EndCursor: ptr(EncodeCursor(fn(page[len(page)-1]))), TotalCount: len(items), } } func ptr[T any](v T) *T { return &v } // PageInfo contains pagination metadata for a page of results. type PageInfo struct { StartCursor *string HasNextPage bool HasPreviousPage bool EndCursor *string TotalCount int }