diff --git a/go.mod b/go.mod index ceece5b..e638bcf 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 diff --git a/pagination/pagination.go b/pagination/pagination.go new file mode 100644 index 0000000..d299c92 --- /dev/null +++ b/pagination/pagination.go @@ -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()) +} diff --git a/pagination/pagination_test.go b/pagination/pagination_test.go new file mode 100644 index 0000000..abb0c82 --- /dev/null +++ b/pagination/pagination_test.go @@ -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") +}