Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Pagination Module #29

Merged
merged 2 commits into from
Sep 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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())
}
59 changes: 40 additions & 19 deletions pagination/pagination.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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")
}
8 changes: 8 additions & 0 deletions proto/buf.lock
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Generated by buf. DO NOT EDIT.
version: v1
deps:
- remote: buf.build
owner: googleapis
repository: googleapis
commit: 28151c0d0a1641bf938a7672c500e01d
digest: shake256:49215edf8ef57f7863004539deff8834cfb2195113f0b890dd1f67815d9353e28e668019165b9d872395871eeafcbab3ccfdb2b5f11734d3cca95be9e8d139de
2 changes: 2 additions & 0 deletions proto/buf.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
version: v1
name: buf.build/rotational/honudb
deps:
- buf.build/googleapis/googleapis
breaking:
use:
- FILE
Expand Down
19 changes: 15 additions & 4 deletions proto/pagination/v1/pagination.proto
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
syntax = "proto3";

package honu.pagination.v1;

import "google/protobuf/timestamp.proto";

option go_package = "github.com/rotationalio/honu/pagination";

// Implements a protocol buffer struct for state managed pagination. This struct will be
Expand All @@ -10,7 +13,15 @@ option go_package = "github.com/rotationalio/honu/pagination";
// size in the cursor matches the page size in the request.
// See https://cloud.google.com/apis/design/design_patterns#list_pagination for more.
message PageCursor {
int32 page_size = 1; // the number of results returned on each iteration.
bytes next_key = 2; // the key to start the iteration from
string namespace = 3; // the namespace the cursor is iterating on
}
// The number of results returned on each iteration.
int32 page_size = 1;

// The key to start the iteration from for forward iteration (e.g. the seek key).
bytes next_key = 2;

// The namespace the cursor is iterating on
string namespace = 3;

// The timestamp when the cursor is no longer valid.
google.protobuf.Timestamp expires = 4;
}
Loading