blob: ab0f31d1f1a202a1ee740087d9dab07cd8e2126b [file] [log] [blame]
// Copyright 2021 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package pagination
import (
"encoding/base64"
"fmt"
"reflect"
"google.golang.org/grpc/codes"
"google.golang.org/protobuf/proto"
"go.chromium.org/luci/common/errors"
"go.chromium.org/luci/grpc/appstatus"
)
// InvalidToken annotates the error with InvalidArgument appstatus.
func InvalidToken(err error) error {
return appstatus.Attachf(err, codes.InvalidArgument, "invalid page token")
}
type requestWithPageToken interface {
GetPageToken() string
}
// ValidatePageToken validates & extracts page token from the request
// into the given proto.
func ValidatePageToken(req requestWithPageToken, dst proto.Message) error {
return parse(req.GetPageToken(), dst)
}
// schemaVersion is the version of the schema of the token.
//
// Version 1 schema is:
// Outer envelope: base64.RawURLEncoding.
// Why: safe to use in URLs as is.
//
// Inner 1 byte prefix: version.
// Why: support evolution of the format, e.g. HMAC-ing the cursor.
//
// Inner remaining: envelope of the serialized binary proto.
// Why: support arbitrary page tokens.
//
const schemaVersion = 1
// TokenString serializes a generic page token to an opaque URL-safe string.
//
// Input proto can be nil, in which case resulting page token is empty.
func TokenString(src proto.Message) (string, error) {
if src == nil || reflect.ValueOf(src).IsNil() {
return "", nil
}
// Pre-allocate space to avoid needless re-allocations later.
// Reserve 1 byte for version set below.
// 64 bytes total because most tokens should be short.
bytes := make([]byte, 1, 64)
bytes[0] = schemaVersion
bytes, err := proto.MarshalOptions{}.MarshalAppend(bytes, src)
if err != nil {
return "", errors.Annotate(err, "failed to serialize page token").Err()
}
return base64.RawURLEncoding.EncodeToString(bytes), nil
}
// parse parses a generic page token into the proto.
//
// Empty page token is valid and results in no changes to the destination proto.
func parse(token string, dst proto.Message) error {
if token == "" {
return nil
}
bytes, err := base64.RawURLEncoding.DecodeString(token)
if err != nil {
return InvalidToken(err)
}
switch {
case len(bytes) == 0:
return InvalidToken(fmt.Errorf("invalid inner format"))
case bytes[0] != schemaVersion:
return InvalidToken(fmt.Errorf("unknown version %d", bytes[0]))
default:
bytes = bytes[1:]
}
if err = proto.Unmarshal(bytes, dst); err != nil {
return InvalidToken(err)
}
return nil
}