-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
3 changed files
with
164 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") | ||
} |