Skip to content

Commit

Permalink
Pagination Module
Browse files Browse the repository at this point in the history
  • Loading branch information
bbengfort committed Sep 30, 2023
1 parent b48b52c commit 5ff0044
Show file tree
Hide file tree
Showing 3 changed files with 164 additions and 1 deletion.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ go 1.19

require (
github.com/cockroachdb/pebble v0.0.0-20230906203007-2129a6e99d0f
github.com/golang/protobuf v1.5.3
github.com/stretchr/testify v1.8.4
github.com/syndtr/goleveldb v1.0.0
google.golang.org/grpc v1.58.0
Expand All @@ -22,6 +21,7 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/getsentry/sentry-go v0.24.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/klauspost/compress v1.16.7 // indirect
github.com/kr/pretty v0.3.1 // indirect
Expand Down
88 changes: 88 additions & 0 deletions pagination/pagination.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package pagination

import (
"encoding/base64"
"errors"
"time"

"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
)

const (
DefaultPageSize int32 = 100
MaximumPageSize int32 = 5000
CursorDuration = 24 * time.Hour
)

var (
ErrMissingExpiration = errors.New("cursor does not have an expires timestamp")
ErrCursorExpired = errors.New("cursor has expired and is no longer useable")
ErrUnparsableToken = errors.New("could not parse the next page token")
ErrTokenQueryMismatch = errors.New("cannot change query parameters during pagination")
)

func New(nextKey []byte, namespace string, pageSize int32) *PageCursor {
if pageSize == 0 {
pageSize = DefaultPageSize
}

return &PageCursor{
PageSize: pageSize,
NextKey: nextKey,
Namespace: namespace,
Expires: timestamppb.New(time.Now().Add(CursorDuration)),
}
}

func Parse(token string) (cursor *PageCursor, err error) {
var data []byte
if data, err = base64.RawURLEncoding.DecodeString(token); err != nil {
return nil, ErrUnparsableToken
}

cursor = &PageCursor{}
if err = proto.Unmarshal(data, cursor); err != nil {
return nil, ErrUnparsableToken
}

var expired bool
if expired, err = cursor.HasExpired(); err != nil {
return nil, err
}

if expired {
return nil, ErrCursorExpired
}

return cursor, nil
}

func (c *PageCursor) NextPageToken() (_ string, err error) {
var expired bool
if expired, err = c.HasExpired(); err != nil {
return "", err
}

if expired {
return "", ErrCursorExpired
}

var data []byte
if data, err = proto.Marshal(c); err != nil {
return "", err
}

return base64.RawURLEncoding.EncodeToString(data), nil
}

func (c *PageCursor) HasExpired() (bool, error) {
if c.Expires == nil {
return false, ErrMissingExpiration
}
return time.Now().After(c.Expires.AsTime()), nil
}

func (c *PageCursor) IsZero() bool {
return c.PageSize == 0 && len(c.NextKey) == 0 && c.Namespace == "" && (c.Expires == nil || c.Expires.AsTime().IsZero())
}
75 changes: 75 additions & 0 deletions pagination/pagination_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package pagination_test

import (
"encoding/base64"
"testing"
"time"

"github.com/rotationalio/honu/pagination"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
)

func TestPaginationToken(t *testing.T) {
cursor := pagination.New([]byte("foo"), "bar", 0)
token, err := cursor.NextPageToken()
require.NoError(t, err, "could not create next page token")
require.Greater(t, len(token), 32, "the token created above should at least be 32 characters")
require.Less(t, len(token), 64, "the token create above should be less than 64 characters")

parsed, err := pagination.Parse(token)
require.NoError(t, err, "could not parse token")
require.True(t, proto.Equal(cursor, parsed), "parsed token should match cursor")

cursor.Expires = nil
_, err = cursor.NextPageToken()
require.ErrorIs(t, err, pagination.ErrMissingExpiration, "should not be able to create a token with no expiration")

data, err := proto.Marshal(cursor)
require.NoError(t, err, "could not marshal protocol buffers")
token = base64.RawURLEncoding.EncodeToString(data)
_, err = pagination.Parse(token)
require.ErrorIs(t, err, pagination.ErrMissingExpiration, "should not be able to parse a token with no expiration")

_, err = pagination.Parse("ab")
require.ErrorIs(t, err, pagination.ErrUnparsableToken, "should not be able to parse an invalid token")

_, err = pagination.Parse(base64.RawStdEncoding.EncodeToString([]byte("badtokendata")))
require.ErrorIs(t, err, pagination.ErrUnparsableToken, "should not be able to parse an invalid token")

cursor.Expires = timestamppb.New(time.Now().Add(-5 * time.Minute))
_, err = cursor.NextPageToken()
require.ErrorIs(t, err, pagination.ErrCursorExpired, "should not be able to create an expired token")

data, err = proto.Marshal(cursor)
require.NoError(t, err, "could not marshal protocol buffers")
token = base64.RawURLEncoding.EncodeToString(data)
_, err = pagination.Parse(token)
require.ErrorIs(t, err, pagination.ErrCursorExpired, "should not be able to parse an expired token")
}

func TestPaginationExpired(t *testing.T) {
cursor := &pagination.PageCursor{}
expired, err := cursor.HasExpired()
require.ErrorIs(t, err, pagination.ErrMissingExpiration)
require.False(t, expired, "if err is not nil, expired should be false")

cursor.Expires = timestamppb.New(time.Now().Add(5 * time.Minute))
expired, err = cursor.HasExpired()
require.NoError(t, err, "cursor should compute expiration without error")
require.False(t, expired, "cusor should not be expired for 5 minutes")

cursor.Expires = timestamppb.New(time.Now().Add(-5 * time.Minute))
expired, err = cursor.HasExpired()
require.NoError(t, err, "cursor should compute expiration without error")
require.True(t, expired, "cusor should have expired 5 minutes ago")
}

func TestPaginationIsZero(t *testing.T) {
cursor := &pagination.PageCursor{}
require.True(t, cursor.IsZero(), "empty cursor should be zero valued")

cursor = pagination.New([]byte("foo"), "bar", 0)
require.False(t, cursor.IsZero(), "new cursor should not be zero valued")
}

0 comments on commit 5ff0044

Please sign in to comment.