go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/tree_status/rpc/paginator/paginator.go (about) 1 // Copyright 2024 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 // Package paginator contains helpers to convert between AIP-158 page_size/page_token/next_page_token fields 16 // and simple offset/limit values. 17 package paginator 18 19 import ( 20 "encoding/base64" 21 "encoding/json" 22 "hash/fnv" 23 "sort" 24 "strconv" 25 26 "google.golang.org/grpc/codes" 27 "google.golang.org/protobuf/proto" 28 "google.golang.org/protobuf/reflect/protoreflect" 29 30 "go.chromium.org/luci/common/errors" 31 "go.chromium.org/luci/grpc/appstatus" 32 ) 33 34 // Paginator converts between AIP-158 page_size/page_token/next_page_token fields 35 // and a simple offset/limit. 36 // Note that offset/limit is susceptible to page tearing during pagination if new 37 // items are added to the list during iteration. This paginator makes no attempt 38 // to solve this at present, but can be extended to take additional state in the 39 // future to address this if necessary. 40 type Paginator struct { 41 // The default page size to use if the user does not specify a page size. 42 DefaultPageSize int32 43 // The max page size to allow the user to request. 44 MaxPageSize int32 45 } 46 47 // The structure that is encoded in the page token. 48 type token struct { 49 // A hash of the request message to ensure all parameters are the same. 50 Hash string 51 // The offset of the next page. 52 Offset int64 53 } 54 55 // Offset gets the offset given a request message with an AIP-158 page_token field. 56 func (p Paginator) Offset(request proto.Message) (int64, error) { 57 // Make a copy of the request as we are going to mutate it. 58 msg := request.ProtoReflect() 59 field := msg.Descriptor().Fields().ByName("page_token") 60 if field == nil { 61 return 0, errors.New("request message does not have a page_token field") 62 } 63 tokenString := msg.Get(field).String() 64 if tokenString == "" { 65 return 0, nil 66 } 67 tokenBytes, err := base64.URLEncoding.DecodeString(tokenString) 68 if err != nil { 69 return 0, InvalidTokenError(err) 70 } 71 t := &token{} 72 if err := json.Unmarshal(tokenBytes, &t); err != nil { 73 return 0, InvalidTokenError(err) 74 } 75 if t.Hash != hashMessage(msg) { 76 return 0, InvalidTokenError(errors.New("request message fields do not match page token")) 77 } 78 return t.Offset, nil 79 } 80 81 // NextPageToken gets the value to use for the next_page_token field in a response to remember the 82 // given offset for the next request. The request message is required to ensure that 83 // the user does not change request parameters in the next request (hence making the 84 // offset invalid). 85 func (p Paginator) NextPageToken(request proto.Message, offset int64) (string, error) { 86 t := &token{ 87 Hash: hashMessage(request.ProtoReflect()), 88 Offset: offset, 89 } 90 bytes, err := json.Marshal(t) 91 if err != nil { 92 return "", errors.Annotate(err, "creating next_page_token").Err() 93 } 94 return base64.URLEncoding.EncodeToString(bytes), nil 95 } 96 97 // Limit gets the limit given the page_size field and the configuration stored 98 // in the paginator. 99 func (p Paginator) Limit(requestedPageSize int32) int32 { 100 if requestedPageSize <= 0 { 101 requestedPageSize = p.DefaultPageSize 102 } 103 if requestedPageSize > p.MaxPageSize { 104 requestedPageSize = p.MaxPageSize 105 } 106 return requestedPageSize 107 } 108 109 // hashMessage hashes all the fields in a request message except 110 // the AIP-158 page_size and page_token fields (which are allowed to change 111 // between requests). 112 func hashMessage(msg protoreflect.Message) string { 113 keys := []string{} 114 values := map[string]string{} 115 msg.Range(func(fd protoreflect.FieldDescriptor, v protoreflect.Value) bool { 116 name := fd.Name() 117 if name == "page_token" || name == "page_size" { 118 return true 119 } 120 keys = append(keys, string(name)) 121 values[string(name)] = v.String() 122 return true 123 }) 124 hash := fnv.New64a() 125 sortedKeys := sort.StringSlice(keys) 126 for _, key := range sortedKeys { 127 hash.Write([]byte(key)) 128 hash.Write([]byte(values[key])) 129 } 130 return strconv.FormatUint(hash.Sum64(), 36) 131 } 132 133 // InvalidTokenError annotates the error with InvalidArgument appstatus. 134 func InvalidTokenError(err error) error { 135 return appstatus.Attachf(err, codes.InvalidArgument, "invalid page_token") 136 }