snapshot of https://github.com/ory/keto.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
CI / Run lints and checks (push) Failing after 47s
CI / Run tests (push) Failing after 1s
CI / Run race tests (push) Failing after 5s
CI / Build CLI docs (push) Has been skipped
CI / Run buf actions (push) Failing after 5s
CodeQL / Analyze (go) (push) Failing after 3s
CodeQL / Analyze (javascript) (push) Failing after 2s
Docker Image Scanners / scanners (push) Failing after 3s
Format / format (push) Failing after 3s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Draft newsletter (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
Some checks failed
CI / Run lints and checks (push) Failing after 47s
CI / Run tests (push) Failing after 1s
CI / Run race tests (push) Failing after 5s
CI / Build CLI docs (push) Has been skipped
CI / Run buf actions (push) Failing after 5s
CodeQL / Analyze (go) (push) Failing after 3s
CodeQL / Analyze (javascript) (push) Failing after 2s
Docker Image Scanners / scanners (push) Failing after 3s
Format / format (push) Failing after 3s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Draft newsletter (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
This commit is contained in:
29
oryx/pagination/README.md
Normal file
29
oryx/pagination/README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# pagination
|
||||
|
||||
A simple helper for dealing with pagination.
|
||||
|
||||
```
|
||||
go get github.com/ory/pagination
|
||||
```
|
||||
|
||||
## Example
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/ory/pagination"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func main() {
|
||||
u, _ := url.Parse("http://localhost/foo?offset=0&limit=10")
|
||||
limit, offset := pagination.Parse(&http.Request{URL: u}, 5, 5, 10)
|
||||
|
||||
items := []string{"a", "b", "c", "d"}
|
||||
start, end := pagination.Index(limit, offset, len(items))
|
||||
fmt.Printf("Got items: %v", items[start:end])
|
||||
}
|
||||
```
|
||||
96
oryx/pagination/header.go
Normal file
96
oryx/pagination/header.go
Normal file
@@ -0,0 +1,96 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pagination
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func header(u *url.URL, rel string, limit, offset int64) string {
|
||||
q := u.Query()
|
||||
q.Set("limit", fmt.Sprintf("%d", limit))
|
||||
q.Set("offset", fmt.Sprintf("%d", offset))
|
||||
u.RawQuery = q.Encode()
|
||||
return fmt.Sprintf("<%s>; rel=\"%s\"", u.String(), rel)
|
||||
}
|
||||
|
||||
type formatter func(location *url.URL, rel string, itemsPerPage int64, offset int64) string
|
||||
|
||||
// HeaderWithFormatter adds an HTTP header for pagination which uses a custom formatter for generating the URL links.
|
||||
func HeaderWithFormatter(w http.ResponseWriter, u *url.URL, total int64, page, itemsPerPage int, f formatter) {
|
||||
if itemsPerPage <= 0 {
|
||||
itemsPerPage = 1
|
||||
}
|
||||
|
||||
itemsPerPage64 := int64(itemsPerPage)
|
||||
offset := int64(page) * itemsPerPage64
|
||||
|
||||
// lastOffset will either equal the offset required to contain the remainder,
|
||||
// or the limit.
|
||||
var lastOffset int64
|
||||
if total%itemsPerPage64 == 0 {
|
||||
lastOffset = total - itemsPerPage64
|
||||
} else {
|
||||
lastOffset = (total / itemsPerPage64) * itemsPerPage64
|
||||
}
|
||||
|
||||
w.Header().Set("X-Total-Count", strconv.FormatInt(total, 10))
|
||||
|
||||
// Check for last page
|
||||
if offset >= lastOffset {
|
||||
if total == 0 {
|
||||
w.Header().Set("Link", strings.Join([]string{
|
||||
f(u, "first", itemsPerPage64, 0),
|
||||
f(u, "next", itemsPerPage64, ((offset/itemsPerPage64)+1)*itemsPerPage64),
|
||||
f(u, "prev", itemsPerPage64, ((offset/itemsPerPage64)-1)*itemsPerPage64),
|
||||
}, ","))
|
||||
return
|
||||
}
|
||||
|
||||
if total <= itemsPerPage64 {
|
||||
w.Header().Set("link", f(u, "first", total, 0))
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Link", strings.Join([]string{
|
||||
f(u, "first", itemsPerPage64, 0),
|
||||
f(u, "prev", itemsPerPage64, lastOffset-itemsPerPage64),
|
||||
}, ","))
|
||||
return
|
||||
}
|
||||
|
||||
if offset < itemsPerPage64 {
|
||||
w.Header().Set("Link", strings.Join([]string{
|
||||
f(u, "next", itemsPerPage64, itemsPerPage64),
|
||||
f(u, "last", itemsPerPage64, lastOffset),
|
||||
}, ","))
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Link", strings.Join([]string{
|
||||
f(u, "first", itemsPerPage64, 0),
|
||||
f(u, "next", itemsPerPage64, ((offset/itemsPerPage64)+1)*itemsPerPage64),
|
||||
f(u, "prev", itemsPerPage64, ((offset/itemsPerPage64)-1)*itemsPerPage64),
|
||||
f(u, "last", itemsPerPage64, lastOffset),
|
||||
}, ","))
|
||||
}
|
||||
|
||||
// Header adds an http header for pagination using a responsewriter where backwards compatibility is required.
|
||||
// The header will contain links any combination of the first, last, next, or previous (prev) pages in a paginated list (given a limit and an offset, and optionally a total).
|
||||
// If total is not set, then no "last" page will be calculated.
|
||||
// If no limit is provided, then it will default to 1.
|
||||
func Header(w http.ResponseWriter, u *url.URL, total int, limit, offset int) {
|
||||
var page int
|
||||
if limit == 0 {
|
||||
limit = 1
|
||||
}
|
||||
|
||||
page = int(math.Floor(float64(offset) / float64(limit)))
|
||||
HeaderWithFormatter(w, u, int64(total), page, limit, header)
|
||||
}
|
||||
12
oryx/pagination/items.go
Normal file
12
oryx/pagination/items.go
Normal file
@@ -0,0 +1,12 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pagination
|
||||
|
||||
// MaxItemsPerPage is used to prevent DoS attacks against large lists by limiting the items per page to 500.
|
||||
func MaxItemsPerPage(max, is int) int {
|
||||
if is > max {
|
||||
return max
|
||||
}
|
||||
return is
|
||||
}
|
||||
48
oryx/pagination/keysetclient/parse_header.go
Normal file
48
oryx/pagination/keysetclient/parse_header.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package keysetclient parses keyset pagination metadata from HTTP responses.
|
||||
// It is the client-side counterpart to keysetpagination and deliberately
|
||||
// carries no SQL dependencies, so API clients such as the Ory CLI do not link
|
||||
// the database layer.
|
||||
package keysetclient
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/peterhellberg/link"
|
||||
)
|
||||
|
||||
// Result represents a parsed result of the link HTTP header.
|
||||
type Result struct {
|
||||
// NextToken is the next page token. If it's empty, there is no next page.
|
||||
NextToken string
|
||||
|
||||
// FirstToken is the first page token.
|
||||
FirstToken string
|
||||
}
|
||||
|
||||
// ParseHeader parses the response header's Link.
|
||||
func ParseHeader(r *http.Response) *Result {
|
||||
links := link.ParseResponse(r)
|
||||
return &Result{
|
||||
NextToken: findRel(links, "next"),
|
||||
FirstToken: findRel(links, "first"),
|
||||
}
|
||||
}
|
||||
|
||||
func findRel(links link.Group, rel string) string {
|
||||
for idx, l := range links {
|
||||
if idx == rel {
|
||||
parsed, err := url.Parse(l.URI)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
return parsed.Query().Get("page_token")
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
112
oryx/pagination/keysetpagination/header.go
Normal file
112
oryx/pagination/keysetpagination/header.go
Normal file
@@ -0,0 +1,112 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package keysetpagination
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// Pagination Request Parameters
|
||||
//
|
||||
// The `Link` HTTP header contains multiple links (`first`, `next`) formatted as:
|
||||
// `<https://{project-slug}.projects.oryapis.com/admin/sessions?page_size=250&page_token=>; rel="first"`
|
||||
//
|
||||
// For details on pagination please head over to the [pagination documentation](https://www.ory.com/docs/ecosystem/api-design#pagination).
|
||||
//
|
||||
// swagger:model keysetPaginationRequestParameters
|
||||
type RequestParameters struct {
|
||||
// Items per Page
|
||||
//
|
||||
// This is the number of items per page to return.
|
||||
// For details on pagination please head over to the [pagination documentation](https://www.ory.com/docs/ecosystem/api-design#pagination).
|
||||
//
|
||||
// required: false
|
||||
// in: query
|
||||
// default: 250
|
||||
// min: 1
|
||||
// max: 1000
|
||||
PageSize int `json:"page_size"`
|
||||
|
||||
// Next Page Token
|
||||
//
|
||||
// The next page token.
|
||||
// For details on pagination please head over to the [pagination documentation](https://www.ory.com/docs/ecosystem/api-design#pagination).
|
||||
//
|
||||
// required: false
|
||||
// in: query
|
||||
PageToken string `json:"page_token"`
|
||||
}
|
||||
|
||||
// Pagination Response Header
|
||||
//
|
||||
// The `Link` HTTP header contains multiple links (`first`, `next`) formatted as:
|
||||
// `<https://{project-slug}.projects.oryapis.com/admin/sessions?page_size=250&page_token=>; rel="first"`
|
||||
//
|
||||
// For details on pagination please head over to the [pagination documentation](https://www.ory.com/docs/ecosystem/api-design#pagination).
|
||||
//
|
||||
// swagger:model keysetPaginationResponseHeaders
|
||||
type ResponseHeaders struct {
|
||||
// The Link HTTP Header
|
||||
//
|
||||
// The `Link` header contains a comma-delimited list of links to the following pages:
|
||||
//
|
||||
// - first: The first page of results.
|
||||
// - next: The next page of results.
|
||||
//
|
||||
// Pages are omitted if they do not exist. For example, if there is no next page, the `next` link is omitted. Examples:
|
||||
//
|
||||
// </admin/sessions?page_size=250&page_token={last_item_uuid}; rel="first",/admin/sessions?page_size=250&page_token=>; rel="next"
|
||||
//
|
||||
Link string `json:"link"`
|
||||
}
|
||||
|
||||
func header(u *url.URL, rel, token string, size int) string {
|
||||
q := u.Query()
|
||||
q.Set("page_token", token)
|
||||
q.Set("page_size", strconv.Itoa(size))
|
||||
u.RawQuery = q.Encode()
|
||||
return fmt.Sprintf("<%s>; rel=\"%s\"", u.String(), rel)
|
||||
}
|
||||
|
||||
// Header adds the Link header for the page encoded by the paginator.
|
||||
// It contains links to the first and next page, if one exists.
|
||||
func Header(w http.ResponseWriter, u *url.URL, p *Paginator) {
|
||||
size := p.Size()
|
||||
link := []string{header(u, "first", p.defaultToken.Encode(), size)}
|
||||
if !p.isLast {
|
||||
link = append(link, header(u, "next", p.Token().Encode(), size))
|
||||
}
|
||||
w.Header().Set("Link", strings.Join(link, ","))
|
||||
}
|
||||
|
||||
// Parse returns the pagination options from the URL query.
|
||||
func Parse(q url.Values, p PageTokenConstructor) ([]Option, error) {
|
||||
var opts []Option
|
||||
if pt := cmp.Or(q["page_token"]...); pt != "" {
|
||||
pageToken, err := url.QueryUnescape(pt)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
parsed, err := p(pageToken)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
opts = append(opts, WithToken(parsed))
|
||||
}
|
||||
if ps := cmp.Or(q["page_size"]...); ps != "" {
|
||||
size, err := strconv.Atoi(ps)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
opts = append(opts, WithSize(size))
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
74
oryx/pagination/keysetpagination/page_token.go
Normal file
74
oryx/pagination/keysetpagination/page_token.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package keysetpagination
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type PageToken = interface {
|
||||
Parse(string) map[string]string
|
||||
Encode() string
|
||||
}
|
||||
|
||||
var _ PageToken = new(StringPageToken)
|
||||
var _ PageToken = new(MapPageToken)
|
||||
|
||||
type StringPageToken string
|
||||
|
||||
func (s StringPageToken) Parse(idField string) map[string]string {
|
||||
return map[string]string{idField: string(s)}
|
||||
}
|
||||
|
||||
func (s StringPageToken) Encode() string {
|
||||
return string(s)
|
||||
}
|
||||
|
||||
func NewStringPageToken(s string) (PageToken, error) {
|
||||
return StringPageToken(s), nil
|
||||
}
|
||||
|
||||
type MapPageToken map[string]string
|
||||
|
||||
func (m MapPageToken) Parse(_ string) map[string]string {
|
||||
return map[string]string(m)
|
||||
}
|
||||
|
||||
const pageTokenColumnDelim = "/"
|
||||
|
||||
func (m MapPageToken) Encode() string {
|
||||
elems := make([]string, 0, len(m))
|
||||
for k, v := range m {
|
||||
elems = append(elems, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
|
||||
// For now: use Base64 instead of URL escaping, as the Timestamp format we need to use can contain a `+` sign,
|
||||
// which represents a space in URLs, so it's not properly encoded by the Go library.
|
||||
return base64.RawStdEncoding.EncodeToString([]byte(strings.Join(elems, pageTokenColumnDelim)))
|
||||
}
|
||||
|
||||
func NewMapPageToken(s string) (PageToken, error) {
|
||||
b, err := base64.RawStdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokens := strings.Split(string(b), pageTokenColumnDelim)
|
||||
|
||||
r := map[string]string{}
|
||||
|
||||
for _, p := range tokens {
|
||||
if columnName, value, found := strings.Cut(p, "="); found {
|
||||
r[columnName] = value
|
||||
}
|
||||
}
|
||||
|
||||
return MapPageToken(r), nil
|
||||
}
|
||||
|
||||
var _ PageTokenConstructor = NewMapPageToken
|
||||
var _ PageTokenConstructor = NewStringPageToken
|
||||
|
||||
type PageTokenConstructor = func(string) (PageToken, error)
|
||||
258
oryx/pagination/keysetpagination/paginator.go
Normal file
258
oryx/pagination/keysetpagination/paginator.go
Normal file
@@ -0,0 +1,258 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package keysetpagination
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/ory/pop/v6"
|
||||
"github.com/ory/pop/v6/columns"
|
||||
)
|
||||
|
||||
type (
|
||||
Item = interface{ PageToken() PageToken }
|
||||
|
||||
Order string
|
||||
|
||||
columnOrdering struct {
|
||||
name string
|
||||
order Order
|
||||
}
|
||||
Paginator struct {
|
||||
token, defaultToken PageToken
|
||||
size, defaultSize, maxSize int
|
||||
isLast bool
|
||||
additionalColumn columnOrdering
|
||||
}
|
||||
Option func(*Paginator) *Paginator
|
||||
)
|
||||
|
||||
var ErrUnknownOrder = errors.New("unknown order")
|
||||
|
||||
const (
|
||||
OrderDescending Order = "DESC"
|
||||
OrderAscending Order = "ASC"
|
||||
|
||||
DefaultSize = 100
|
||||
DefaultMaxSize = 500
|
||||
)
|
||||
|
||||
func (o Order) extract() (string, string, error) {
|
||||
switch o {
|
||||
case OrderAscending:
|
||||
return ">", string(o), nil
|
||||
case OrderDescending:
|
||||
return "<", string(o), nil
|
||||
default:
|
||||
return "", "", ErrUnknownOrder
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Paginator) Token() PageToken {
|
||||
if p.token == nil {
|
||||
return p.defaultToken
|
||||
}
|
||||
return p.token
|
||||
}
|
||||
|
||||
func (p *Paginator) Size() int {
|
||||
size := p.size
|
||||
if size <= 0 {
|
||||
size = p.defaultSize
|
||||
if size == 0 {
|
||||
size = 100
|
||||
}
|
||||
}
|
||||
if size > p.maxSize {
|
||||
size = p.maxSize
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
func (p *Paginator) IsLast() bool {
|
||||
return p.isLast
|
||||
}
|
||||
|
||||
func (p *Paginator) ToOptions() []Option {
|
||||
opts := make([]Option, 0, 7)
|
||||
if p.token != nil {
|
||||
opts = append(opts, WithToken(p.token))
|
||||
}
|
||||
if p.defaultToken != nil {
|
||||
opts = append(opts, WithDefaultToken(p.defaultToken))
|
||||
}
|
||||
if p.size > 0 {
|
||||
opts = append(opts, WithSize(p.size))
|
||||
}
|
||||
if p.defaultSize != DefaultSize {
|
||||
opts = append(opts, WithDefaultSize(p.defaultSize))
|
||||
}
|
||||
if p.maxSize != DefaultMaxSize {
|
||||
opts = append(opts, WithMaxSize(p.maxSize))
|
||||
}
|
||||
if p.additionalColumn.name != "" {
|
||||
opts = append(opts, WithColumn(p.additionalColumn.name, p.additionalColumn.order))
|
||||
}
|
||||
if p.isLast {
|
||||
opts = append(opts, withIsLast(p.isLast))
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
func (p *Paginator) multipleOrderFieldsQuery(q *pop.Query, idField string, cols map[string]*columns.Column, quoteAndContextualize func(string) string) {
|
||||
tokenParts := p.Token().Parse(idField)
|
||||
idValue := tokenParts[idField]
|
||||
|
||||
column, ok := cols[p.additionalColumn.name]
|
||||
if !ok {
|
||||
q.Where(fmt.Sprintf(`%s > ?`, quoteAndContextualize(idField)), idValue)
|
||||
return
|
||||
}
|
||||
|
||||
quoteName := quoteAndContextualize(column.Name)
|
||||
|
||||
value, ok := tokenParts[column.Name]
|
||||
|
||||
if !ok {
|
||||
q.Where(fmt.Sprintf(`%s > ?`, quoteAndContextualize(idField)), idValue)
|
||||
return
|
||||
}
|
||||
|
||||
sign, keyword, err := p.additionalColumn.order.extract()
|
||||
if err != nil {
|
||||
q.Where(fmt.Sprintf(`%s > ?`, quoteAndContextualize(idField)), idValue)
|
||||
return
|
||||
}
|
||||
|
||||
q.
|
||||
Where(fmt.Sprintf("(%s %s ? OR (%s = ? AND %s > ?))", quoteName, sign, quoteName, quoteAndContextualize(idField)), value, value, idValue).
|
||||
Order(fmt.Sprintf("%s %s", quoteName, keyword))
|
||||
|
||||
}
|
||||
|
||||
// Paginate returns a function that paginates a pop.Query.
|
||||
// Usage:
|
||||
//
|
||||
// q := c.Where("foo = ?", foo).Scope(keysetpagination.Paginate[MyItemType](paginator))
|
||||
//
|
||||
// This function works regardless of whether your type implements the Item
|
||||
// interface with pointer or value receivers. To understand the type parameters,
|
||||
// see this document:
|
||||
// https://go.googlesource.com/proposal/+/refs/heads/master/design/43651-type-parameters.md#pointer-method-example
|
||||
func Paginate[I any, PI interface {
|
||||
Item
|
||||
*I
|
||||
}](p *Paginator) pop.ScopeFunc {
|
||||
model := pop.Model{Value: new(I)}
|
||||
id := model.IDField()
|
||||
tableName := model.Alias()
|
||||
return func(q *pop.Query) *pop.Query {
|
||||
quote := q.Connection.Dialect.Quote
|
||||
eid := quote(tableName) + "." + quote(id)
|
||||
|
||||
quoteAndContextualize := func(name string) string {
|
||||
return quote(tableName) + "." + quote(name)
|
||||
}
|
||||
p.multipleOrderFieldsQuery(q, id, model.Columns().Cols, quoteAndContextualize)
|
||||
|
||||
return q.
|
||||
Limit(p.Size() + 1).
|
||||
// we always need to order by the id field last
|
||||
Order(fmt.Sprintf(`%s ASC`, eid))
|
||||
}
|
||||
}
|
||||
|
||||
// Result removes the last item (if applicable) and returns the paginator for the next page.
|
||||
//
|
||||
// This function works regardless of whether your type implements the Item
|
||||
// interface with pointer or value receivers. To understand the type parameters,
|
||||
// see this document:
|
||||
// https://go.googlesource.com/proposal/+/refs/heads/master/design/43651-type-parameters.md#pointer-method-example
|
||||
func Result[I any, PI interface {
|
||||
Item
|
||||
*I
|
||||
}](items []I, p *Paginator) ([]I, *Paginator) {
|
||||
if len(items) > p.Size() {
|
||||
items = items[:p.Size()]
|
||||
return items, &Paginator{
|
||||
token: PI(&items[len(items)-1]).PageToken(),
|
||||
defaultToken: p.defaultToken,
|
||||
size: p.size,
|
||||
defaultSize: p.defaultSize,
|
||||
maxSize: p.maxSize,
|
||||
}
|
||||
}
|
||||
return items, &Paginator{
|
||||
defaultToken: p.defaultToken,
|
||||
size: p.size,
|
||||
defaultSize: p.defaultSize,
|
||||
maxSize: p.maxSize,
|
||||
isLast: true,
|
||||
}
|
||||
}
|
||||
|
||||
func WithDefaultToken(t PageToken) Option {
|
||||
return func(opts *Paginator) *Paginator {
|
||||
opts.defaultToken = t
|
||||
return opts
|
||||
}
|
||||
}
|
||||
|
||||
func WithDefaultSize(size int) Option {
|
||||
return func(opts *Paginator) *Paginator {
|
||||
opts.defaultSize = size
|
||||
return opts
|
||||
}
|
||||
}
|
||||
|
||||
func WithMaxSize(size int) Option {
|
||||
return func(opts *Paginator) *Paginator {
|
||||
opts.maxSize = size
|
||||
return opts
|
||||
}
|
||||
}
|
||||
|
||||
func WithToken(t PageToken) Option {
|
||||
return func(opts *Paginator) *Paginator {
|
||||
opts.token = t
|
||||
return opts
|
||||
}
|
||||
}
|
||||
|
||||
func WithSize(size int) Option {
|
||||
return func(opts *Paginator) *Paginator {
|
||||
opts.size = size
|
||||
return opts
|
||||
}
|
||||
}
|
||||
|
||||
func WithColumn(name string, order Order) Option {
|
||||
return func(opts *Paginator) *Paginator {
|
||||
opts.additionalColumn = columnOrdering{
|
||||
name: name,
|
||||
order: order,
|
||||
}
|
||||
return opts
|
||||
}
|
||||
}
|
||||
|
||||
func withIsLast(isLast bool) Option {
|
||||
return func(opts *Paginator) *Paginator {
|
||||
opts.isLast = isLast
|
||||
return opts
|
||||
}
|
||||
}
|
||||
|
||||
func GetPaginator(modifiers ...Option) *Paginator {
|
||||
opts := &Paginator{
|
||||
// these can still be overridden by the modifiers, but they should never be unset
|
||||
maxSize: DefaultMaxSize,
|
||||
defaultSize: DefaultSize,
|
||||
}
|
||||
for _, f := range modifiers {
|
||||
opts = f(opts)
|
||||
}
|
||||
return opts
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"e": "2023-01-01T01:00:00Z",
|
||||
"c": [
|
||||
{
|
||||
"n": "id",
|
||||
"v": "token"
|
||||
},
|
||||
{
|
||||
"n": "string",
|
||||
"o": 1,
|
||||
"v": "str"
|
||||
},
|
||||
{
|
||||
"n": "string-zero",
|
||||
"o": 1,
|
||||
"v": ""
|
||||
},
|
||||
{
|
||||
"n": "time",
|
||||
"vt": "2023-01-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"n": "uuid",
|
||||
"vu": "b652bbbd-1a84-4fb7-9437-6b78ba2686db"
|
||||
},
|
||||
{
|
||||
"n": "nulluuid",
|
||||
"vu": "b652bbbd-1a84-4fb7-9437-6b78ba2686da"
|
||||
},
|
||||
{
|
||||
"n": "int64",
|
||||
"vi": 64
|
||||
},
|
||||
{
|
||||
"n": "int64-zero",
|
||||
"vi": 0
|
||||
},
|
||||
{
|
||||
"n": "empty-nullint64",
|
||||
"vi": 0
|
||||
},
|
||||
{
|
||||
"n": "nullint64",
|
||||
"vi": 64
|
||||
},
|
||||
{
|
||||
"n": "nullstring",
|
||||
"v": "str"
|
||||
},
|
||||
{
|
||||
"n": "empty-nullstring",
|
||||
"v": ""
|
||||
},
|
||||
{
|
||||
"n": "null-nulluuid",
|
||||
"vn": true
|
||||
},
|
||||
{
|
||||
"n": "null-nullstring",
|
||||
"vn": true
|
||||
},
|
||||
{
|
||||
"n": "null-nullint64",
|
||||
"vn": true
|
||||
}
|
||||
]
|
||||
}
|
||||
245
oryx/pagination/keysetpagination_v2/page_token.go
Normal file
245
oryx/pagination/keysetpagination_v2/page_token.go
Normal file
@@ -0,0 +1,245 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package keysetpagination
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/uuid"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
|
||||
"github.com/ory/x/aead"
|
||||
)
|
||||
|
||||
// pageTokenContext is passed as additional authenticated data to the AEAD.
|
||||
// It binds the ciphertext to its purpose: even if the encryption key is
|
||||
// reused in another context (for example when it is derived from a shared
|
||||
// system secret), ciphertexts from that context are rejected as page tokens
|
||||
// and vice versa.
|
||||
const pageTokenContext = "ory/keysetpagination_v2/page_token"
|
||||
|
||||
// fallbackEncryptionKey seals page tokens when no pagination secrets are
|
||||
// configured. It is well known, so those tokens are only obfuscated, not
|
||||
// authenticated. Configure pagination secrets to authenticate tokens.
|
||||
var fallbackEncryptionKey [32]byte
|
||||
|
||||
type (
|
||||
PageToken struct {
|
||||
testNow func() time.Time
|
||||
cols []Column
|
||||
}
|
||||
jsonPageToken = struct {
|
||||
ExpiresAt time.Time `json:"e"`
|
||||
Cols []jsonColumn `json:"c"`
|
||||
}
|
||||
|
||||
jsonColumn = struct {
|
||||
Name string `json:"n"`
|
||||
Order Order `json:"o,omitempty"`
|
||||
Nullable bool `json:"nl,omitempty"`
|
||||
HasConstraint bool `json:"hc,omitempty"`
|
||||
|
||||
ValueAny any `json:"v,omitempty"`
|
||||
ValueTime *time.Time `json:"vt,omitempty"`
|
||||
ValueUUID *uuid.UUID `json:"vu,omitempty"`
|
||||
ValueInt64 *int64 `json:"vi,omitempty"`
|
||||
ValueNull bool `json:"vn,omitempty"`
|
||||
}
|
||||
Column struct {
|
||||
Name string
|
||||
Order Order
|
||||
Value any
|
||||
Nullable bool
|
||||
// HasConstraint marks if the column is already constrained by WHERE condition.
|
||||
HasConstraint bool
|
||||
}
|
||||
)
|
||||
|
||||
func (t PageToken) Columns() []Column { return t.cols }
|
||||
|
||||
// Encrypt encrypts the page token using the first key in the provided keyset.
|
||||
// It uses a fallback key if no keys are provided.
|
||||
func (t PageToken) Encrypt(keys [][32]byte) string {
|
||||
key := fallbackEncryptionKey
|
||||
if len(keys) > 0 {
|
||||
key = keys[0]
|
||||
}
|
||||
enc, err := t.encrypt(key)
|
||||
if err != nil {
|
||||
// This should basically never happen, only if reading from the random source or marshaling the token fails.
|
||||
// In both cases, we have a bigger problem than just not being able to generate the page token.
|
||||
// Therefore, if we do get an error, there is no point in returning it to the client,
|
||||
// as we already have a working result set. With this string, the next page will return an error,
|
||||
// but that is better than breaking the current page.
|
||||
return "internal error: failed to generate page token"
|
||||
}
|
||||
return enc
|
||||
}
|
||||
|
||||
func (t PageToken) MarshalJSON() ([]byte, error) {
|
||||
now := time.Now
|
||||
if t.testNow != nil {
|
||||
now = t.testNow
|
||||
}
|
||||
toEncode := jsonPageToken{
|
||||
ExpiresAt: now().Add(time.Hour).UTC(),
|
||||
Cols: make([]jsonColumn, len(t.cols)),
|
||||
}
|
||||
for i, col := range t.cols {
|
||||
toEncode.Cols[i] = jsonColumn{
|
||||
Name: col.Name,
|
||||
Order: col.Order,
|
||||
Nullable: col.Nullable,
|
||||
HasConstraint: col.HasConstraint,
|
||||
}
|
||||
switch v := col.Value.(type) {
|
||||
case time.Time:
|
||||
toEncode.Cols[i].ValueTime = new(v)
|
||||
case uuid.UUID:
|
||||
toEncode.Cols[i].ValueUUID = new(v)
|
||||
case uuid.NullUUID:
|
||||
if v.Valid {
|
||||
toEncode.Cols[i].ValueUUID = new(v.UUID)
|
||||
} else {
|
||||
toEncode.Cols[i].ValueNull = true
|
||||
}
|
||||
case sql.NullString:
|
||||
if v.Valid {
|
||||
toEncode.Cols[i].ValueAny = new(v.String)
|
||||
} else {
|
||||
toEncode.Cols[i].ValueNull = true
|
||||
}
|
||||
case int64:
|
||||
toEncode.Cols[i].ValueInt64 = new(v)
|
||||
case sql.NullInt64:
|
||||
if v.Valid {
|
||||
toEncode.Cols[i].ValueInt64 = new(v.Int64)
|
||||
} else {
|
||||
toEncode.Cols[i].ValueNull = true
|
||||
}
|
||||
default:
|
||||
toEncode.Cols[i].ValueAny = v
|
||||
}
|
||||
}
|
||||
return json.Marshal(toEncode)
|
||||
}
|
||||
|
||||
func ErrPageTokenExpired() *herodot.DefaultError {
|
||||
return herodot.ErrBadRequest().WithError("page token expired, do not persist page tokens")
|
||||
}
|
||||
|
||||
func (t *PageToken) UnmarshalJSON(data []byte) error {
|
||||
rawToken := jsonPageToken{}
|
||||
if err := json.Unmarshal(data, &rawToken); err != nil {
|
||||
return err
|
||||
}
|
||||
t.cols = make([]Column, len(rawToken.Cols))
|
||||
for i, col := range rawToken.Cols {
|
||||
t.cols[i] = Column{
|
||||
Name: col.Name,
|
||||
Order: col.Order,
|
||||
Nullable: col.Nullable,
|
||||
HasConstraint: col.HasConstraint,
|
||||
}
|
||||
switch {
|
||||
case col.ValueNull:
|
||||
t.cols[i].Value = nil
|
||||
case col.ValueAny != nil:
|
||||
t.cols[i].Value = col.ValueAny
|
||||
case col.ValueUUID != nil:
|
||||
t.cols[i].Value = *col.ValueUUID
|
||||
case col.ValueTime != nil:
|
||||
t.cols[i].Value = *col.ValueTime
|
||||
case col.ValueInt64 != nil:
|
||||
t.cols[i].Value = *col.ValueInt64
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now
|
||||
if t.testNow != nil {
|
||||
now = t.testNow
|
||||
}
|
||||
if rawToken.ExpiresAt.Before(now().UTC()) {
|
||||
return errors.WithStack(ErrPageTokenExpired())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewPageToken(cols ...Column) PageToken { return PageToken{cols: cols} }
|
||||
|
||||
func (t *PageToken) encrypt(key [32]byte) (string, error) {
|
||||
raw, err := json.Marshal(t)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "cannot marshal page token")
|
||||
}
|
||||
|
||||
a, err := aead.New(key)
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "cannot create AEAD")
|
||||
}
|
||||
|
||||
// The nonce is prepended to the ciphertext. AEADs that manage the nonce
|
||||
// internally report a nonce size of zero, so this also covers them.
|
||||
nonce := make([]byte, a.NonceSize())
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", errors.Wrap(err, "cannot generate nonce")
|
||||
}
|
||||
|
||||
return base64.URLEncoding.EncodeToString(a.Seal(nonce, nonce, raw, []byte(pageTokenContext))), nil
|
||||
}
|
||||
|
||||
func (t *PageToken) decrypt(key [32]byte, s string) error {
|
||||
if s == "" {
|
||||
return errors.WithStack(ErrInvalidPaginationToken())
|
||||
}
|
||||
|
||||
raw, err := base64.URLEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return errors.WithStack(ErrInvalidPaginationToken())
|
||||
}
|
||||
|
||||
dec, err := openAEAD(key, raw)
|
||||
if err != nil {
|
||||
// Tokens issued before the switch to a context-bound AEAD are sealed
|
||||
// with NaCl secretbox. Remove this fallback once all tokens issued by
|
||||
// the previous version have expired.
|
||||
var ok bool
|
||||
dec, ok = aead.OpenLegacySecretbox(key, raw)
|
||||
if !ok {
|
||||
return errors.WithStack(ErrInvalidPaginationToken())
|
||||
}
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(dec, t); err != nil {
|
||||
if errors.As(err, new(*herodot.DefaultError)) {
|
||||
return err
|
||||
}
|
||||
return errors.WithStack(herodot.ErrInternalServerError().WithReason("unable to unmarshal page token").WithDebug(err.Error()))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func openAEAD(key [32]byte, raw []byte) ([]byte, error) {
|
||||
a, err := aead.New(key)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "cannot create AEAD")
|
||||
}
|
||||
if len(raw) < a.NonceSize() {
|
||||
return nil, errors.New("ciphertext too short")
|
||||
}
|
||||
nonce, ciphertext := raw[:a.NonceSize()], raw[a.NonceSize():]
|
||||
bs, err := a.Open(nil, nonce, ciphertext, []byte(pageTokenContext))
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "cannot open AEAD")
|
||||
}
|
||||
|
||||
return bs, nil
|
||||
}
|
||||
192
oryx/pagination/keysetpagination_v2/paginator.go
Normal file
192
oryx/pagination/keysetpagination_v2/paginator.go
Normal file
@@ -0,0 +1,192 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package keysetpagination
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"reflect"
|
||||
|
||||
"github.com/jmoiron/sqlx/reflectx"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
)
|
||||
|
||||
func ErrInvalidPaginationToken() *herodot.DefaultError {
|
||||
return herodot.ErrBadRequest().WithError("invalid pagination token")
|
||||
}
|
||||
|
||||
type (
|
||||
Paginator struct {
|
||||
token, defaultToken PageToken
|
||||
size, defaultSize, maxSize int
|
||||
isLast bool
|
||||
}
|
||||
Option func(*Paginator)
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultSize = 100
|
||||
DefaultMaxSize = 500
|
||||
)
|
||||
|
||||
var dbStructTagMapper = reflectx.NewMapper("db")
|
||||
|
||||
func (p *Paginator) DefaultToken() PageToken { return p.defaultToken }
|
||||
func (p *Paginator) IsLast() bool { return p.isLast }
|
||||
|
||||
func (p *Paginator) PageToken() PageToken {
|
||||
if p.token.cols != nil {
|
||||
return p.token
|
||||
}
|
||||
return p.defaultToken
|
||||
}
|
||||
|
||||
func (p *Paginator) Size() int {
|
||||
defaultSize := cmp.Or(p.defaultSize, DefaultSize)
|
||||
maxSize := cmp.Or(p.maxSize, DefaultMaxSize)
|
||||
|
||||
size := p.size
|
||||
if size <= 0 {
|
||||
size = defaultSize
|
||||
}
|
||||
if size > maxSize {
|
||||
size = maxSize
|
||||
}
|
||||
|
||||
return size
|
||||
}
|
||||
|
||||
func (p *Paginator) ToOptions() []Option {
|
||||
opts := make([]Option, 0, 6)
|
||||
if p.token.cols != nil {
|
||||
opts = append(opts, WithToken(p.token))
|
||||
}
|
||||
if p.defaultToken.cols != nil {
|
||||
opts = append(opts, WithDefaultToken(p.defaultToken))
|
||||
}
|
||||
if p.size > 0 {
|
||||
opts = append(opts, WithSize(p.size))
|
||||
}
|
||||
if p.defaultSize != DefaultSize {
|
||||
opts = append(opts, WithDefaultSize(p.defaultSize))
|
||||
}
|
||||
if p.maxSize != DefaultMaxSize {
|
||||
opts = append(opts, WithMaxSize(p.maxSize))
|
||||
}
|
||||
if p.isLast {
|
||||
opts = append(opts, withIsLast(p.isLast))
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
// Result removes the last item (if applicable) and returns the paginator for the next page.
|
||||
func Result[I any](items []I, p *Paginator) ([]I, *Paginator) {
|
||||
return ResultFunc(items, p, func(last I, colName string) any {
|
||||
lastItemVal := reflect.ValueOf(last)
|
||||
return dbStructTagMapper.FieldByName(lastItemVal, colName).Interface()
|
||||
})
|
||||
}
|
||||
|
||||
// ResultFunc removes the last item (if applicable) and returns the paginator for the next page.
|
||||
// The extractor function is used to extract the column values from the last item.
|
||||
func ResultFunc[I any](items []I, p *Paginator, extractor func(last I, colName string) any) ([]I, *Paginator) {
|
||||
if len(items) <= p.Size() {
|
||||
return items, &Paginator{
|
||||
isLast: true,
|
||||
|
||||
defaultToken: p.defaultToken,
|
||||
size: p.size,
|
||||
defaultSize: p.defaultSize,
|
||||
maxSize: p.maxSize,
|
||||
}
|
||||
}
|
||||
|
||||
items = items[:p.Size()]
|
||||
lastItem := items[len(items)-1]
|
||||
|
||||
currentCols := p.PageToken().Columns()
|
||||
newCols := make([]Column, len(currentCols))
|
||||
for i, col := range currentCols {
|
||||
newCols[i] = Column{
|
||||
Name: col.Name,
|
||||
Order: col.Order,
|
||||
Nullable: col.Nullable,
|
||||
HasConstraint: col.HasConstraint,
|
||||
Value: extractor(lastItem, col.Name),
|
||||
}
|
||||
}
|
||||
|
||||
return items, &Paginator{
|
||||
token: NewPageToken(newCols...),
|
||||
defaultToken: p.defaultToken,
|
||||
size: p.size,
|
||||
defaultSize: p.defaultSize,
|
||||
maxSize: p.maxSize,
|
||||
}
|
||||
}
|
||||
|
||||
func WithSize(size int) Option {
|
||||
return func(p *Paginator) { p.size = size }
|
||||
}
|
||||
|
||||
func WithDefaultSize(size int) Option {
|
||||
return func(p *Paginator) { p.defaultSize = size }
|
||||
}
|
||||
|
||||
func WithMaxSize(size int) Option {
|
||||
return func(p *Paginator) { p.maxSize = size }
|
||||
}
|
||||
|
||||
func WithToken(t PageToken) Option {
|
||||
return func(p *Paginator) { p.token = t }
|
||||
}
|
||||
|
||||
func WithDefaultToken(t PageToken) Option {
|
||||
return func(p *Paginator) { p.defaultToken = t }
|
||||
}
|
||||
|
||||
func withIsLast(isLast bool) Option {
|
||||
return func(p *Paginator) { p.isLast = isLast }
|
||||
}
|
||||
|
||||
func NewPaginator(modifiers ...Option) (*Paginator, error) {
|
||||
p := &Paginator{
|
||||
// these can still be overridden by the modifiers, but they should never be unset
|
||||
maxSize: DefaultMaxSize,
|
||||
defaultSize: DefaultSize,
|
||||
}
|
||||
for _, f := range modifiers {
|
||||
f(p)
|
||||
}
|
||||
if err := p.validatePageToken(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// validatePageToken ensures the page token columns strictly match DefaultToken's schema in order.
|
||||
func (p *Paginator) validatePageToken() error {
|
||||
tokenCols := p.PageToken().Columns()
|
||||
if len(tokenCols) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if columnsMatch(tokenCols, p.defaultToken.Columns()) {
|
||||
return nil
|
||||
}
|
||||
return ErrInvalidPaginationToken()
|
||||
}
|
||||
|
||||
func columnsMatch(tokenCols, templateCols []Column) bool {
|
||||
if len(templateCols) == 0 || len(tokenCols) != len(templateCols) {
|
||||
return false
|
||||
}
|
||||
for i, col := range tokenCols {
|
||||
def := templateCols[i]
|
||||
if def.Name != col.Name || def.Order != col.Order || def.Nullable != col.Nullable {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
35
oryx/pagination/keysetpagination_v2/parse_header.go
Normal file
35
oryx/pagination/keysetpagination_v2/parse_header.go
Normal file
@@ -0,0 +1,35 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package keysetpagination
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/peterhellberg/link"
|
||||
)
|
||||
|
||||
// ParseHeader parses the response header's Link and returns the first and next page tokens.
|
||||
func ParseHeader(r *http.Response) (first, next string, isLast bool) {
|
||||
links := link.ParseResponse(r)
|
||||
first, _ = findRel(links, "first")
|
||||
next, hasNext := findRel(links, "next")
|
||||
return first, next, !hasNext
|
||||
}
|
||||
|
||||
func findRel(links link.Group, rel string) (string, bool) {
|
||||
for idx, l := range links {
|
||||
if idx == rel {
|
||||
parsed, err := url.Parse(l.URI)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
q := parsed.Query()
|
||||
|
||||
return q.Get("page_token"), q.Has("page_token")
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
163
oryx/pagination/keysetpagination_v2/query_builder.go
Normal file
163
oryx/pagination/keysetpagination_v2/query_builder.go
Normal file
@@ -0,0 +1,163 @@
|
||||
// Copyright © 2025 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package keysetpagination
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/ory/pop/v6"
|
||||
)
|
||||
|
||||
type Order int
|
||||
|
||||
const (
|
||||
OrderAscending Order = iota
|
||||
OrderDescending
|
||||
)
|
||||
|
||||
func (o Order) extract() (string, string) {
|
||||
switch o {
|
||||
case OrderAscending:
|
||||
return ">", "ASC"
|
||||
case OrderDescending:
|
||||
return "<", "DESC"
|
||||
default:
|
||||
panic(fmt.Sprintf("keyset pagination: unknown order %d", o))
|
||||
}
|
||||
}
|
||||
|
||||
// Paginate returns a function that paginates a pop.Query.
|
||||
// Usage:
|
||||
//
|
||||
// q := c.Where("foo = ?", foo).Scope(keysetpagination.Paginate[MyItemType](paginator))
|
||||
func Paginate[I any](p *Paginator) pop.ScopeFunc {
|
||||
model := pop.Model{Value: *new(I)}
|
||||
tableName := model.Alias()
|
||||
return func(q *pop.Query) *pop.Query {
|
||||
quoteAndContextualize := func(name string) string {
|
||||
quote := q.Connection.Dialect.Quote
|
||||
return quote(tableName) + "." + quote(name)
|
||||
}
|
||||
dialect := q.Connection.Dialect.Name()
|
||||
where, args, order := BuildWhereAndOrder(p.PageToken().Columns(), quoteAndContextualize, dialect)
|
||||
// IMPORTANT: Ensures correct query logic by grouping conditions.
|
||||
// Without parentheses, `WHERE otherCond AND pageCond1 OR pageCond2` would be
|
||||
// evaluated as `(otherCond = ? AND pageCond1) OR pageCond2`, potentially returning
|
||||
// rows that do not match `otherCond`.
|
||||
// We fix it by forcing the query to be: `WHERE otherCond AND (paginationCond1 OR paginationCond2)`.
|
||||
where = "(" + where + ")"
|
||||
|
||||
return q.
|
||||
Where(where, args...).
|
||||
Order(order).
|
||||
Limit(p.Size() + 1)
|
||||
}
|
||||
}
|
||||
|
||||
func BuildWhereAndOrder(columns []Column, quote func(string) string, dialect string) (string, []any, string) {
|
||||
var whereBuilder, orderByBuilder, prevEqual strings.Builder
|
||||
|
||||
keysetCols := make([]Column, 0, len(columns))
|
||||
|
||||
// ORDER BY includes all columns (even constrained ones)
|
||||
for i, part := range columns {
|
||||
column := quote(part.Name)
|
||||
_, keyword := part.Order.extract()
|
||||
|
||||
if i > 0 {
|
||||
orderByBuilder.WriteString(", ")
|
||||
}
|
||||
|
||||
orderByBuilder.WriteString(column + " " + keyword)
|
||||
|
||||
// Postgres orders NULLs differently depending on sort direction;
|
||||
// (ASC → NULLS LAST, DESC → NULLS FIRST), which does not match the
|
||||
// assumptions of our keyset pagination logic and other supported DBs.
|
||||
// We therefore make NULL ordering explicit on Postgres (and YugabyteDB,
|
||||
// which shares Postgres NULL ordering) to keep pagination stable and
|
||||
// consistent with sqlite/mysql/cockroachdb.
|
||||
if (dialect == "postgres" || dialect == "yugabyte") && part.Nullable {
|
||||
if part.Order == OrderAscending {
|
||||
orderByBuilder.WriteString(" NULLS FIRST")
|
||||
} else {
|
||||
orderByBuilder.WriteString(" NULLS LAST")
|
||||
}
|
||||
}
|
||||
|
||||
// Build keyset WHERE only from unconstrained columns
|
||||
if !part.HasConstraint {
|
||||
keysetCols = append(keysetCols, part)
|
||||
}
|
||||
}
|
||||
|
||||
// If everything is constrained, no keyset predicate is needed.
|
||||
if len(keysetCols) == 0 {
|
||||
return "", nil, orderByBuilder.String()
|
||||
}
|
||||
|
||||
args := make([]any, 0, len(keysetCols)*(len(keysetCols)+1)/2)
|
||||
prevEqualArgs := make([]any, 0, len(keysetCols))
|
||||
|
||||
whereBuilder.WriteRune('(')
|
||||
|
||||
for i, part := range keysetCols {
|
||||
column := quote(part.Name)
|
||||
sign, _ := part.Order.extract()
|
||||
|
||||
if i > 0 {
|
||||
whereBuilder.WriteString(") OR (")
|
||||
}
|
||||
|
||||
whereBuilder.WriteString(prevEqual.String())
|
||||
if prevEqual.Len() > 0 {
|
||||
whereBuilder.WriteString(" AND ")
|
||||
}
|
||||
|
||||
isNull := part.Nullable && isSQLNull(part.Value)
|
||||
|
||||
if !part.Nullable {
|
||||
whereBuilder.WriteString(column + " " + sign + " ?")
|
||||
} else if !isNull {
|
||||
whereBuilder.WriteString(column + " IS NOT NULL AND " + column + " " + sign + " ?")
|
||||
} else {
|
||||
whereBuilder.WriteString(column + " IS NOT NULL")
|
||||
}
|
||||
|
||||
if i > 0 {
|
||||
prevEqual.WriteString(" AND ")
|
||||
}
|
||||
|
||||
if !part.Nullable {
|
||||
prevEqual.WriteString(column + " = ?")
|
||||
prevEqualArgs = append(prevEqualArgs, part.Value)
|
||||
} else if !isNull {
|
||||
prevEqual.WriteString(column + " = ?")
|
||||
prevEqualArgs = append(prevEqualArgs, part.Value)
|
||||
} else {
|
||||
prevEqual.WriteString(column + " IS NULL")
|
||||
}
|
||||
|
||||
args = append(args, prevEqualArgs...)
|
||||
}
|
||||
|
||||
whereBuilder.WriteRune(')')
|
||||
|
||||
return whereBuilder.String(), args, orderByBuilder.String()
|
||||
}
|
||||
|
||||
// isSQLNull reports whether v represents a SQL NULL value.
|
||||
func isSQLNull(v any) bool {
|
||||
if v == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
if valuer, ok := v.(driver.Valuer); ok {
|
||||
val, err := valuer.Value()
|
||||
return err == nil && val == nil
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
126
oryx/pagination/keysetpagination_v2/request_params.go
Normal file
126
oryx/pagination/keysetpagination_v2/request_params.go
Normal file
@@ -0,0 +1,126 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package keysetpagination
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// Pagination Request Parameters
|
||||
//
|
||||
// For details on pagination please head over to the [pagination documentation](https://www.ory.com/docs/ecosystem/api-design#pagination).
|
||||
//
|
||||
// swagger:model keysetPaginationRequestParameters
|
||||
type RequestParameters struct {
|
||||
// Items per Page
|
||||
//
|
||||
// This is the number of items per page to return.
|
||||
// For details on pagination please head over to the [pagination documentation](https://www.ory.com/docs/ecosystem/api-design#pagination).
|
||||
//
|
||||
// required: false
|
||||
// in: query
|
||||
// default: 250
|
||||
// min: 1
|
||||
// max: 1000
|
||||
PageSize int `json:"page_size"`
|
||||
|
||||
// Next Page Token
|
||||
//
|
||||
// The next page token.
|
||||
// For details on pagination please head over to the [pagination documentation](https://www.ory.com/docs/ecosystem/api-design#pagination).
|
||||
//
|
||||
// required: false
|
||||
// in: query
|
||||
PageToken string `json:"page_token"`
|
||||
}
|
||||
|
||||
// Pagination Response Header
|
||||
//
|
||||
// The `Link` HTTP header contains multiple links (`first`, `next`) formatted as:
|
||||
// `<https://{project-slug}.projects.oryapis.com/admin/sessions?page_size=250&page_token=>; rel="first"`
|
||||
//
|
||||
// For details on pagination please head over to the [pagination documentation](https://www.ory.com/docs/ecosystem/api-design#pagination).
|
||||
//
|
||||
// swagger:model keysetPaginationResponseHeaders
|
||||
type ResponseHeaders struct {
|
||||
// The Link HTTP Header
|
||||
//
|
||||
// The `Link` header contains a comma-delimited list of links to the following pages:
|
||||
//
|
||||
// - first: The first page of results.
|
||||
// - next: The next page of results.
|
||||
//
|
||||
// Pages are omitted if they do not exist. For example, if there is no next page, the `next` link is omitted. Examples:
|
||||
//
|
||||
// </admin/sessions?page_size=250&page_token={last_item_uuid}; rel="first",/admin/sessions?page_size=250&page_token=>; rel="next"
|
||||
//
|
||||
Link string `json:"link"`
|
||||
}
|
||||
|
||||
// SetLinkHeader adds the Link header for the page encoded by the paginator.
|
||||
// It contains links to the first and next page, if one exists.
|
||||
func SetLinkHeader(w http.ResponseWriter, keys [][32]byte, u *url.URL, p *Paginator) {
|
||||
size := p.Size()
|
||||
link := []string{linkPart(u, "first", p.DefaultToken().Encrypt(keys), size)}
|
||||
if !p.isLast {
|
||||
link = append(link, linkPart(u, "next", p.PageToken().Encrypt(keys), size))
|
||||
}
|
||||
w.Header().Set("Link", strings.Join(link, ","))
|
||||
}
|
||||
|
||||
func linkPart(u *url.URL, rel, token string, size int) string {
|
||||
q := u.Query()
|
||||
q.Set("page_token", token)
|
||||
q.Set("page_size", strconv.Itoa(size))
|
||||
u.RawQuery = q.Encode()
|
||||
return fmt.Sprintf("<%s>; rel=%q", u.String(), rel)
|
||||
}
|
||||
|
||||
// ParseQueryParams extracts the pagination options from the URL query.
|
||||
func ParseQueryParams(keys [][32]byte, q url.Values) ([]Option, error) {
|
||||
var opts []Option
|
||||
if t := cmp.Or(q["page_token"]...); t != "" {
|
||||
raw, err := url.QueryUnescape(t)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
token, err := ParsePageToken(keys, raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
opts = append(opts, WithToken(token))
|
||||
}
|
||||
if s := cmp.Or(q["page_size"]...); s != "" {
|
||||
size, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return nil, errors.WithStack(err)
|
||||
}
|
||||
opts = append(opts, WithSize(size))
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
// ParsePageToken parses a page token from the given raw string using the provided keys.
|
||||
// If decryption fails with all provided keys (including when no keys are given), it
|
||||
// falls back to using the fallbackEncryptionKey and returns an error if that also fails.
|
||||
func ParsePageToken(keys [][32]byte, raw string) (t PageToken, err error) {
|
||||
for i := range keys {
|
||||
err = errors.WithStack(t.decrypt(keys[i], raw))
|
||||
if errors.Is(err, ErrInvalidPaginationToken()) {
|
||||
continue
|
||||
}
|
||||
// either we successfully decrypted the token, or we got an error that is not ErrInvalidPaginationToken, in both cases we should return immediately
|
||||
return t, err
|
||||
}
|
||||
// as a last resort, try the fallback key
|
||||
err = t.decrypt(fallbackEncryptionKey, raw)
|
||||
return t, errors.WithStack(err)
|
||||
}
|
||||
16
oryx/pagination/limit.go
Normal file
16
oryx/pagination/limit.go
Normal file
@@ -0,0 +1,16 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package pagination provides helpers for dealing with pagination.
|
||||
package pagination
|
||||
|
||||
// Index uses limit, offset, and a slice's length to compute start and end indices for said slice.
|
||||
func Index(limit, offset, length int) (start, end int) {
|
||||
if offset > length {
|
||||
return length, length
|
||||
} else if limit+offset > length {
|
||||
return offset, length
|
||||
}
|
||||
|
||||
return offset, offset + limit
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
[
|
||||
"\u003chttp://example.com?page=1\u0026page_size=50\u0026page_token=eyJvZmZzZXQiOiI1MCIsInYiOjJ9\u0026per_page=50\u003e",
|
||||
"rel=\"next\",\u003chttp://example.com?page=2\u0026page_size=50\u0026page_token=eyJvZmZzZXQiOiIxMDAiLCJ2IjoyfQ\u0026per_page=50\u003e",
|
||||
"rel=\"last\""
|
||||
]
|
||||
@@ -0,0 +1,4 @@
|
||||
[
|
||||
"\u003chttp://example.com?page=0\u0026page_size=5\u0026page_token=eyJvZmZzZXQiOiIwIiwidiI6Mn0\u0026per_page=5\u003e",
|
||||
"rel=\"first\""
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
[
|
||||
"\u003chttp://example.com?page=0\u0026page_size=50\u0026page_token=eyJvZmZzZXQiOiIwIiwidiI6Mn0\u0026per_page=50\u003e",
|
||||
"rel=\"first\",\u003chttp://example.com?page=4\u0026page_size=50\u0026page_token=eyJvZmZzZXQiOiIyMDAiLCJ2IjoyfQ\u0026per_page=50\u003e",
|
||||
"rel=\"next\",\u003chttp://example.com?page=2\u0026page_size=50\u0026page_token=eyJvZmZzZXQiOiIxMDAiLCJ2IjoyfQ\u0026per_page=50\u003e",
|
||||
"rel=\"prev\",\u003chttp://example.com?page=5\u0026page_size=50\u0026page_token=eyJvZmZzZXQiOiIyNTAiLCJ2IjoyfQ\u0026per_page=50\u003e",
|
||||
"rel=\"last\""
|
||||
]
|
||||
@@ -0,0 +1,6 @@
|
||||
[
|
||||
"\u003chttp://example.com?page=0\u0026page_size=50\u0026page_token=eyJvZmZzZXQiOiIwIiwidiI6Mn0\u0026per_page=50\u003e",
|
||||
"rel=\"first\",\u003chttp://example.com?page=4\u0026page_size=50\u0026page_token=eyJvZmZzZXQiOiIyMDAiLCJ2IjoyfQ\u0026per_page=50\u003e",
|
||||
"rel=\"next\",\u003chttp://example.com?page=2\u0026page_size=50\u0026page_token=eyJvZmZzZXQiOiIxMDAiLCJ2IjoyfQ\u0026per_page=50\u003e",
|
||||
"rel=\"prev\""
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
[
|
||||
"\u003chttp://example.com?page=0\u0026page_size=50\u0026page_token=eyJvZmZzZXQiOiIwIiwidiI6Mn0\u0026per_page=50\u003e",
|
||||
"rel=\"first\",\u003chttp://example.com?page=1\u0026page_size=50\u0026page_token=eyJvZmZzZXQiOiI1MCIsInYiOjJ9\u0026per_page=50\u003e",
|
||||
"rel=\"prev\""
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
[
|
||||
"\u003chttp://example.com?page=0\u0026page_size=1\u0026page_token=eyJvZmZzZXQiOiIwIiwidiI6Mn0\u0026per_page=1\u003e",
|
||||
"rel=\"first\",\u003chttp://example.com?page=21\u0026page_size=1\u0026page_token=eyJvZmZzZXQiOiIyMSIsInYiOjJ9\u0026per_page=1\u003e",
|
||||
"rel=\"next\",\u003chttp://example.com?page=19\u0026page_size=1\u0026page_token=eyJvZmZzZXQiOiIxOSIsInYiOjJ9\u0026per_page=1\u003e",
|
||||
"rel=\"prev\",\u003chttp://example.com?page=99\u0026page_size=1\u0026page_token=eyJvZmZzZXQiOiI5OSIsInYiOjJ9\u0026per_page=1\u003e",
|
||||
"rel=\"last\""
|
||||
]
|
||||
90
oryx/pagination/migrationpagination/header.go
Normal file
90
oryx/pagination/migrationpagination/header.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package migrationpagination
|
||||
|
||||
// swagger:model mixedPaginationRequestParameters
|
||||
type RequestParameters struct {
|
||||
// Deprecated Items per Page
|
||||
//
|
||||
// DEPRECATED: Please use `page_token` instead. This parameter will be removed in the future.
|
||||
//
|
||||
// This is the number of items per page.
|
||||
//
|
||||
// required: false
|
||||
// in: query
|
||||
// default: 250
|
||||
// min: 1
|
||||
// max: 1000
|
||||
PerPage int `json:"per_page"`
|
||||
|
||||
// Deprecated Pagination Page
|
||||
//
|
||||
// DEPRECATED: Please use `page_token` instead. This parameter will be removed in the future.
|
||||
//
|
||||
// This value is currently an integer, but it is not sequential. The value is not the page number, but a
|
||||
// reference. The next page can be any number and some numbers might return an empty list.
|
||||
//
|
||||
// For example, page 2 might not follow after page 1. And even if page 3 and 5 exist, but page 4 might not exist.
|
||||
// The first page can be retrieved by omitting this parameter. Following page pointers will be returned in the
|
||||
// `Link` header.
|
||||
//
|
||||
// required: false
|
||||
// in: query
|
||||
Page int `json:"page"`
|
||||
|
||||
// Page Size
|
||||
//
|
||||
// This is the number of items per page to return. For details on pagination please head over to the
|
||||
// [pagination documentation](https://www.ory.com/docs/ecosystem/api-design#pagination).
|
||||
//
|
||||
// required: false
|
||||
// in: query
|
||||
// default: 250
|
||||
// min: 1
|
||||
// max: 500
|
||||
PageSize int `json:"page_size"`
|
||||
|
||||
// Next Page Token
|
||||
//
|
||||
// The next page token. For details on pagination please head over to the
|
||||
// [pagination documentation](https://www.ory.com/docs/ecosystem/api-design#pagination).
|
||||
//
|
||||
// required: false
|
||||
// in: query
|
||||
PageToken string `json:"page_token"`
|
||||
}
|
||||
|
||||
// Pagination Response Header
|
||||
//
|
||||
// The `Link` HTTP header contains multiple links (`first`, `next`, `last`, `previous`) formatted as:
|
||||
// `<https://{project-slug}.projects.oryapis.com/admin/clients?page_size={limit}&page_token={offset}>; rel="{page}"`
|
||||
//
|
||||
// For details on pagination please head over to the [pagination documentation](https://www.ory.com/docs/ecosystem/api-design#pagination).
|
||||
//
|
||||
// swagger:model mixedPagePaginationResponseHeaders
|
||||
type ResponseHeaderAnnotation struct {
|
||||
// The Link HTTP Header
|
||||
//
|
||||
// The `Link` header contains a comma-delimited list of links to the following pages:
|
||||
//
|
||||
// - first: The first page of results.
|
||||
// - next: The next page of results.
|
||||
// - prev: The previous page of results.
|
||||
// - last: The last page of results.
|
||||
//
|
||||
// Pages are omitted if they do not exist. For example, if there is no next page, the `next` link is omitted.
|
||||
//
|
||||
// The header value may look like follows:
|
||||
//
|
||||
// </clients?limit=5&offset=0>; rel="first",</clients?limit=5&offset=15>; rel="next",</clients?limit=5&offset=5>; rel="prev",</clients?limit=5&offset=20>; rel="last"
|
||||
Link string `json:"link"`
|
||||
|
||||
// The X-Total-Count HTTP Header
|
||||
//
|
||||
// The `X-Total-Count` header contains the total number of items in the collection.
|
||||
//
|
||||
// DEPRECATED: This header will be removed eventually. Please use the `Link` header
|
||||
// instead to check whether you are on the last page.
|
||||
TotalCount int `json:"x-total-count"`
|
||||
}
|
||||
48
oryx/pagination/migrationpagination/pagination.go
Normal file
48
oryx/pagination/migrationpagination/pagination.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package migrationpagination
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/ory/x/pagination"
|
||||
"github.com/ory/x/pagination/pagepagination"
|
||||
"github.com/ory/x/pagination/tokenpagination"
|
||||
)
|
||||
|
||||
type Paginator struct {
|
||||
p *pagepagination.PagePaginator
|
||||
t *tokenpagination.TokenPaginator
|
||||
}
|
||||
|
||||
func NewPaginator(p *pagepagination.PagePaginator, t *tokenpagination.TokenPaginator) *Paginator {
|
||||
return &Paginator{p: p, t: t}
|
||||
}
|
||||
|
||||
func NewDefaultPaginator() *Paginator {
|
||||
return &Paginator{p: new(pagepagination.PagePaginator), t: new(tokenpagination.TokenPaginator)}
|
||||
}
|
||||
|
||||
func (p *Paginator) ParsePagination(r *http.Request) (page, itemsPerPage int) {
|
||||
if r.URL.Query().Has("page_token") || r.URL.Query().Has("page_size") {
|
||||
return p.t.ParsePagination(r)
|
||||
}
|
||||
return p.p.ParsePagination(r)
|
||||
}
|
||||
|
||||
func header(u *url.URL, rel string, itemsPerPage, offset int64) string {
|
||||
q := u.Query()
|
||||
q.Set("page_size", fmt.Sprintf("%d", itemsPerPage))
|
||||
q.Set("page_token", tokenpagination.Encode(offset))
|
||||
q.Set("per_page", fmt.Sprintf("%d", itemsPerPage))
|
||||
q.Set("page", fmt.Sprintf("%d", offset/itemsPerPage))
|
||||
u.RawQuery = q.Encode()
|
||||
return fmt.Sprintf("<%s>; rel=\"%s\"", u.String(), rel)
|
||||
}
|
||||
|
||||
func PaginationHeader(w http.ResponseWriter, u *url.URL, total int64, page, itemsPerPage int) {
|
||||
pagination.HeaderWithFormatter(w, u, total, page, itemsPerPage, header)
|
||||
}
|
||||
82
oryx/pagination/pagepagination/header.go
Normal file
82
oryx/pagination/pagepagination/header.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pagepagination
|
||||
|
||||
// Pagination Request Parameters
|
||||
//
|
||||
// The `Link` HTTP header contains multiple links (`first`, `next`, `last`, `previous`) formatted as:
|
||||
// `<https://{project-slug}.projects.oryapis.com/admin/clients?page_size={limit}&page_token={offset}>; rel="{page}"`
|
||||
//
|
||||
// For details on pagination please head over to the [pagination documentation](https://www.ory.com/docs/ecosystem/api-design#pagination).
|
||||
//
|
||||
// swagger:model pagePaginationRequestParameters
|
||||
type RequestParameters struct {
|
||||
// Legacy Items per Page
|
||||
//
|
||||
// A DEPRECATED alias for `page_size`. Please transition to using `page_size` going forward.
|
||||
//
|
||||
// required: false
|
||||
// in: query
|
||||
// default: 250
|
||||
// min: 1
|
||||
// max: 1000
|
||||
PerPage int `json:"per_page"`
|
||||
|
||||
// Legacy Pagination Page
|
||||
//
|
||||
// A DEPRECATED alias for `page_token`. Please transition to using `page_token` going forward.
|
||||
//
|
||||
// required: false
|
||||
// in: query
|
||||
// default: 1
|
||||
// min: 1
|
||||
Page int `json:"page"`
|
||||
|
||||
// Items per Page
|
||||
//
|
||||
// This is the number of items per page to return. For details on pagination please head over to the
|
||||
// [pagination documentation](https://www.ory.com/docs/ecosystem/api-design#pagination).
|
||||
//
|
||||
// required: false
|
||||
// in: query
|
||||
// default: 250
|
||||
// min: 1
|
||||
// max: 500
|
||||
PageSize int `json:"page_size"`
|
||||
|
||||
// Next Page Token
|
||||
//
|
||||
// The next page token. For details on pagination please head over to the
|
||||
// [pagination documentation](https://www.ory.com/docs/ecosystem/api-design#pagination).
|
||||
//
|
||||
// required: false
|
||||
// in: query
|
||||
PageToken string `json:"page_token"`
|
||||
}
|
||||
|
||||
// swagger:model pagePaginationResponseHeaders
|
||||
type ResponseHeaderAnnotation struct {
|
||||
// The Link HTTP Header
|
||||
//
|
||||
// The `Link` header contains a comma-delimited list of links to the following pages:
|
||||
//
|
||||
// - first: The first page of results.
|
||||
// - next: The next page of results.
|
||||
// - prev: The previous page of results.
|
||||
// - last: The last page of results.
|
||||
//
|
||||
// Pages are omitted if they do not exist. For example, if there is no next page, the `next` link is omitted.
|
||||
//
|
||||
// This header will include the `per_page` and `page` parameters for legacy reasons, but these parameters will eventually be removed.
|
||||
//
|
||||
// Example: Link: </clients?page_size=5&page_token=0>; rel="first",</clients?page_size=5&page_token=15>; rel="next",</clients?page_size=5&page_token=5>; rel="prev",</clients?page_size=5&page_token=20>; rel="last"
|
||||
Link string `json:"link"`
|
||||
|
||||
// The X-Total-Count HTTP Header
|
||||
//
|
||||
// The `X-Total-Count` header contains the total number of items in the collection.
|
||||
//
|
||||
// Example: 123
|
||||
TotalCount int `json:"x-total-count"`
|
||||
}
|
||||
79
oryx/pagination/pagepagination/pagination.go
Normal file
79
oryx/pagination/pagepagination/pagination.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pagepagination
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/ory/x/pagination"
|
||||
)
|
||||
|
||||
type PagePaginator struct {
|
||||
MaxItems int
|
||||
DefaultItems int
|
||||
}
|
||||
|
||||
func (p *PagePaginator) defaults() {
|
||||
if p.MaxItems == 0 {
|
||||
p.MaxItems = 1000
|
||||
}
|
||||
|
||||
if p.DefaultItems == 0 {
|
||||
p.DefaultItems = 250
|
||||
}
|
||||
}
|
||||
|
||||
// ParsePagination parses limit and page from *http.Request with given limits and defaults.
|
||||
func (p *PagePaginator) ParsePagination(r *http.Request) (page, itemsPerPage int) {
|
||||
p.defaults()
|
||||
|
||||
if offsetParam := r.URL.Query().Get("page"); offsetParam == "" {
|
||||
page = 0
|
||||
} else {
|
||||
if offset, err := strconv.ParseInt(offsetParam, 10, 0); err != nil {
|
||||
page = 0
|
||||
} else {
|
||||
page = int(offset)
|
||||
}
|
||||
}
|
||||
|
||||
if limitParam := r.URL.Query().Get("per_page"); limitParam == "" {
|
||||
itemsPerPage = p.DefaultItems
|
||||
} else {
|
||||
if limit, err := strconv.ParseInt(limitParam, 10, 0); err != nil {
|
||||
itemsPerPage = p.DefaultItems
|
||||
} else {
|
||||
itemsPerPage = int(limit)
|
||||
}
|
||||
}
|
||||
|
||||
if itemsPerPage > p.MaxItems {
|
||||
itemsPerPage = p.MaxItems
|
||||
}
|
||||
|
||||
if itemsPerPage < 1 {
|
||||
itemsPerPage = 1
|
||||
}
|
||||
|
||||
if page < 0 {
|
||||
page = 0
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func header(u *url.URL, rel string, limit, offset int64) string {
|
||||
q := u.Query()
|
||||
q.Set("per_page", fmt.Sprintf("%d", limit))
|
||||
q.Set("page", fmt.Sprintf("%d", offset/limit))
|
||||
u.RawQuery = q.Encode()
|
||||
return fmt.Sprintf("<%s>; rel=\"%s\"", u.String(), rel)
|
||||
}
|
||||
|
||||
func PaginationHeader(w http.ResponseWriter, u *url.URL, total int64, page, itemsPerPage int) {
|
||||
pagination.HeaderWithFormatter(w, u, total, page, itemsPerPage, header)
|
||||
}
|
||||
197
oryx/pagination/paginationplanner/planner.go
Normal file
197
oryx/pagination/paginationplanner/planner.go
Normal file
@@ -0,0 +1,197 @@
|
||||
// Copyright © 2026 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package paginationplanner
|
||||
|
||||
import (
|
||||
"github.com/pkg/errors"
|
||||
|
||||
keysetpagination "github.com/ory/x/pagination/keysetpagination_v2"
|
||||
)
|
||||
|
||||
// PaginationPlanner chooses the best [PaginationPlan] for a queriedColumns.
|
||||
// Plan is eligible when its required constraint set is satisfied (and an optional Condition matches).
|
||||
// If no plan matches, the FallbackPlan is used.
|
||||
type PaginationPlanner struct {
|
||||
Plans []PaginationPlan
|
||||
FallbackPlan PaginationPlan
|
||||
}
|
||||
|
||||
func NewPaginationPlanner(fallbackPlan PaginationPlan, plans []PaginationPlan) (*PaginationPlanner, error) {
|
||||
if len(fallbackPlan.DefaultPageToken.Columns()) == 0 {
|
||||
return nil, errors.New("plan must define at least one PageTokenColumn")
|
||||
}
|
||||
|
||||
for i := range plans {
|
||||
plan := &plans[i]
|
||||
if len(plan.ApplicableQueries) == 0 {
|
||||
return nil, errors.New("plan must define at least one ApplicableQueries")
|
||||
}
|
||||
if len(plan.DefaultPageToken.Columns()) == 0 {
|
||||
return nil, errors.New("plan must define a DefaultPageToken")
|
||||
}
|
||||
plan.populateInternals()
|
||||
}
|
||||
|
||||
return &PaginationPlanner{
|
||||
Plans: plans,
|
||||
FallbackPlan: fallbackPlan,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetPaginator selects the first eligible plan for the given queriedColumns constraints.
|
||||
// Eligibility requires an exact ColumnSet match and (if set) Condition match.
|
||||
// If none match, the FallbackPlan is used for building the Paginator.
|
||||
func (p *PaginationPlanner) GetPaginator(q Query, pageOpts ...keysetpagination.Option) (*keysetpagination.Paginator, error) {
|
||||
if len(q) == 0 {
|
||||
return keysetpagination.NewPaginator(append(pageOpts, keysetpagination.WithDefaultToken(p.FallbackPlan.DefaultPageToken))...)
|
||||
}
|
||||
|
||||
plan := p.pickPlan(q)
|
||||
|
||||
origCols := plan.DefaultPageToken.Columns()
|
||||
|
||||
// Make a fresh copy so we don't mutate the original backing array.
|
||||
cols := make([]keysetpagination.Column, len(origCols))
|
||||
copy(cols, origCols)
|
||||
|
||||
for i, col := range cols {
|
||||
if val, ok := q.colByName(col.Name); ok && val.IsConstrained() {
|
||||
cols[i].HasConstraint = true
|
||||
}
|
||||
}
|
||||
|
||||
defaultToken := keysetpagination.WithDefaultToken(keysetpagination.NewPageToken(cols...))
|
||||
return keysetpagination.NewPaginator(append(pageOpts, defaultToken)...)
|
||||
}
|
||||
|
||||
func (p *PaginationPlanner) pickPlan(q Query) PaginationPlan {
|
||||
constrainedCols := q.constrainedCols()
|
||||
|
||||
for _, plan := range p.Plans {
|
||||
if _, ok := plan.applicableQueries[constrainedCols]; !ok {
|
||||
continue
|
||||
}
|
||||
if plan.Condition != nil && !plan.Condition(q) {
|
||||
continue
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
return p.FallbackPlan
|
||||
}
|
||||
|
||||
type PaginationPlan struct {
|
||||
Name string
|
||||
|
||||
// DefaultPageToken defines the columns that the pagination will be made of
|
||||
DefaultPageToken keysetpagination.PageToken
|
||||
|
||||
// ApplicableQueries defines the exact sets of columns that this pagination plan is applicable for.
|
||||
ApplicableQueries [][]Column
|
||||
applicableQueries map[uint]struct{}
|
||||
|
||||
// Condition further restricts the Plan eligibility for given queriedColumns.
|
||||
// This can be used for plan-specific logic, e.g. verifying partial-index predicates match.
|
||||
Condition func(q Query) bool
|
||||
}
|
||||
|
||||
func (pp *PaginationPlan) populateInternals() {
|
||||
pp.applicableQueries = make(map[uint]struct{}, len(pp.ApplicableQueries))
|
||||
for _, cols := range pp.ApplicableQueries {
|
||||
var colSet uint
|
||||
for _, col := range cols {
|
||||
colSet |= col.bit
|
||||
}
|
||||
pp.applicableQueries[colSet] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
type Table struct {
|
||||
nextBit uint
|
||||
usedNames map[string]struct{}
|
||||
}
|
||||
|
||||
func NewTable() *Table {
|
||||
return &Table{
|
||||
nextBit: 1,
|
||||
usedNames: make(map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Table) NewColumn(name string) Column {
|
||||
defer func() { t.nextBit = t.nextBit << 1 }()
|
||||
if _, exists := t.usedNames[name]; exists {
|
||||
panic("column name already used: " + name)
|
||||
}
|
||||
t.usedNames[name] = struct{}{}
|
||||
return Column{
|
||||
name: name,
|
||||
bit: t.nextBit,
|
||||
}
|
||||
}
|
||||
|
||||
type Column struct {
|
||||
bit uint
|
||||
name string
|
||||
}
|
||||
|
||||
func (c Column) Name() string {
|
||||
return c.name
|
||||
}
|
||||
|
||||
type ColumnConstraint uint8
|
||||
|
||||
const (
|
||||
colUnconstrained ColumnConstraint = iota
|
||||
colConstraintEq // col = ?
|
||||
colConstraintIsNull // col IS NULL
|
||||
)
|
||||
|
||||
func (cs ColumnConstraint) IsConstrained() bool {
|
||||
return cs == colConstraintEq || cs == colConstraintIsNull
|
||||
}
|
||||
|
||||
func (cs ColumnConstraint) IsNull() bool {
|
||||
return cs == colConstraintIsNull
|
||||
}
|
||||
|
||||
// Query is used to track which column constraints are set.
|
||||
type Query map[Column]ColumnConstraint
|
||||
|
||||
func NewQuery() Query { return make(Query) }
|
||||
|
||||
func (qc Query) SetEq(cols ...Column) Query {
|
||||
return qc.set(colConstraintEq, cols...)
|
||||
}
|
||||
|
||||
func (qc Query) SetIsNull(cols ...Column) Query {
|
||||
return qc.set(colConstraintIsNull, cols...)
|
||||
}
|
||||
|
||||
func (qc Query) set(c ColumnConstraint, cols ...Column) Query {
|
||||
for _, col := range cols {
|
||||
qc[col] = c
|
||||
}
|
||||
return qc
|
||||
}
|
||||
|
||||
func (qc Query) colByName(name string) (ColumnConstraint, bool) {
|
||||
for col, constraint := range qc {
|
||||
if col.name == name {
|
||||
return constraint, true
|
||||
}
|
||||
}
|
||||
return colUnconstrained, false
|
||||
}
|
||||
|
||||
func (qc Query) constrainedCols() uint {
|
||||
var cols uint
|
||||
for col, state := range qc {
|
||||
if !state.IsConstrained() {
|
||||
continue
|
||||
}
|
||||
cols |= col.bit
|
||||
}
|
||||
return cols
|
||||
}
|
||||
48
oryx/pagination/parse.go
Normal file
48
oryx/pagination/parse.go
Normal file
@@ -0,0 +1,48 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pagination
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Parse parses limit and offset from *http.Request with given limits and defaults.
|
||||
func Parse(r *http.Request, defaultLimit, defaultOffset, maxLimit int) (int, int) {
|
||||
var offset, limit int
|
||||
|
||||
if offsetParam := r.URL.Query().Get("offset"); offsetParam == "" {
|
||||
offset = defaultOffset
|
||||
} else {
|
||||
if offset64, err := strconv.ParseInt(offsetParam, 10, 64); err != nil {
|
||||
offset = defaultOffset
|
||||
} else {
|
||||
offset = int(offset64)
|
||||
}
|
||||
}
|
||||
|
||||
if limitParam := r.URL.Query().Get("limit"); limitParam == "" {
|
||||
limit = defaultLimit
|
||||
} else {
|
||||
if limit64, err := strconv.ParseInt(limitParam, 10, 64); err != nil {
|
||||
limit = defaultLimit
|
||||
} else {
|
||||
limit = int(limit64)
|
||||
}
|
||||
}
|
||||
|
||||
if limit > maxLimit {
|
||||
limit = maxLimit
|
||||
}
|
||||
|
||||
if limit < 0 {
|
||||
limit = 0
|
||||
}
|
||||
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
return limit, offset
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
[
|
||||
"\u003chttp://example.com?page_size=50\u0026page_token=eyJvZmZzZXQiOiI1MCIsInYiOjJ9\u003e",
|
||||
"rel=\"next\",\u003chttp://example.com?page_size=50\u0026page_token=eyJvZmZzZXQiOiIxMDAiLCJ2IjoyfQ\u003e",
|
||||
"rel=\"last\""
|
||||
]
|
||||
@@ -0,0 +1,4 @@
|
||||
[
|
||||
"\u003chttp://example.com?page_size=5\u0026page_token=eyJvZmZzZXQiOiIwIiwidiI6Mn0\u003e",
|
||||
"rel=\"first\""
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
[
|
||||
"\u003chttp://example.com?page_size=50\u0026page_token=eyJvZmZzZXQiOiIwIiwidiI6Mn0\u003e",
|
||||
"rel=\"first\",\u003chttp://example.com?page_size=50\u0026page_token=eyJvZmZzZXQiOiIyMDAiLCJ2IjoyfQ\u003e",
|
||||
"rel=\"next\",\u003chttp://example.com?page_size=50\u0026page_token=eyJvZmZzZXQiOiIxMDAiLCJ2IjoyfQ\u003e",
|
||||
"rel=\"prev\",\u003chttp://example.com?page_size=50\u0026page_token=eyJvZmZzZXQiOiIyNTAiLCJ2IjoyfQ\u003e",
|
||||
"rel=\"last\""
|
||||
]
|
||||
@@ -0,0 +1,6 @@
|
||||
[
|
||||
"\u003chttp://example.com?page_size=50\u0026page_token=eyJvZmZzZXQiOiIwIiwidiI6Mn0\u003e",
|
||||
"rel=\"first\",\u003chttp://example.com?page_size=50\u0026page_token=eyJvZmZzZXQiOiIyMDAiLCJ2IjoyfQ\u003e",
|
||||
"rel=\"next\",\u003chttp://example.com?page_size=50\u0026page_token=eyJvZmZzZXQiOiIxMDAiLCJ2IjoyfQ\u003e",
|
||||
"rel=\"prev\""
|
||||
]
|
||||
@@ -0,0 +1,5 @@
|
||||
[
|
||||
"\u003chttp://example.com?page_size=50\u0026page_token=eyJvZmZzZXQiOiIwIiwidiI6Mn0\u003e",
|
||||
"rel=\"first\",\u003chttp://example.com?page_size=50\u0026page_token=eyJvZmZzZXQiOiI1MCIsInYiOjJ9\u003e",
|
||||
"rel=\"prev\""
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
[
|
||||
"\u003chttp://example.com?page_size=1\u0026page_token=eyJvZmZzZXQiOiIwIiwidiI6Mn0\u003e",
|
||||
"rel=\"first\",\u003chttp://example.com?page_size=1\u0026page_token=eyJvZmZzZXQiOiIyMSIsInYiOjJ9\u003e",
|
||||
"rel=\"next\",\u003chttp://example.com?page_size=1\u0026page_token=eyJvZmZzZXQiOiIxOSIsInYiOjJ9\u003e",
|
||||
"rel=\"prev\",\u003chttp://example.com?page_size=1\u0026page_token=eyJvZmZzZXQiOiI5OSIsInYiOjJ9\u003e",
|
||||
"rel=\"last\""
|
||||
]
|
||||
65
oryx/pagination/tokenpagination/header.go
Normal file
65
oryx/pagination/tokenpagination/header.go
Normal file
@@ -0,0 +1,65 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package tokenpagination
|
||||
|
||||
// Pagination Request Parameters
|
||||
//
|
||||
// The `Link` HTTP header contains multiple links (`first`, `next`, `last`, `previous`) formatted as:
|
||||
// `<https://{project-slug}.projects.oryapis.com/admin/clients?page_size={limit}&page_token={offset}>; rel="{page}"`
|
||||
//
|
||||
// For details on pagination please head over to the [pagination documentation](https://www.ory.com/docs/ecosystem/api-design#pagination).
|
||||
//
|
||||
// swagger:model tokenPaginationRequestParameters
|
||||
type RequestParameters struct {
|
||||
// Items per Page
|
||||
//
|
||||
// This is the number of items per page to return.
|
||||
// For details on pagination please head over to the [pagination documentation](https://www.ory.com/docs/ecosystem/api-design#pagination).
|
||||
//
|
||||
// required: false
|
||||
// in: query
|
||||
// default: 250
|
||||
// min: 1
|
||||
// max: 500
|
||||
PageSize int `json:"page_size"`
|
||||
|
||||
// Next Page Token
|
||||
//
|
||||
// The next page token.
|
||||
// For details on pagination please head over to the [pagination documentation](https://www.ory.com/docs/ecosystem/api-design#pagination).
|
||||
//
|
||||
// required: false
|
||||
// in: query
|
||||
PageToken string `json:"page_token"`
|
||||
}
|
||||
|
||||
// Pagination Response Header
|
||||
//
|
||||
// The `Link` HTTP header contains multiple links (`first`, `next`, `last`, `previous`) formatted as:
|
||||
// `<https://{project-slug}.projects.oryapis.com/admin/clients?page_size={limit}&page_token={offset}>; rel="{page}"`
|
||||
//
|
||||
// For details on pagination please head over to the [pagination documentation](https://www.ory.com/docs/ecosystem/api-design#pagination).
|
||||
//
|
||||
// swagger:model tokenPaginationResponseHeaders
|
||||
type ResponseHeaders struct {
|
||||
// The Link HTTP Header
|
||||
//
|
||||
// The `Link` header contains a comma-delimited list of links to the following pages:
|
||||
//
|
||||
// - first: The first page of results.
|
||||
// - next: The next page of results.
|
||||
// - prev: The previous page of results.
|
||||
// - last: The last page of results.
|
||||
//
|
||||
// Pages are omitted if they do not exist. For example, if there is no next page, the `next` link is omitted. Examples:
|
||||
//
|
||||
// </clients?page_size=5&page_token=0>; rel="first",</clients?page_size=5&page_token=15>; rel="next",</clients?page_size=5&page_token=5>; rel="prev",</clients?page_size=5&page_token=20>; rel="last"
|
||||
//
|
||||
Link string `json:"link"`
|
||||
|
||||
// The X-Total-Count HTTP Header
|
||||
//
|
||||
// The `X-Total-Count` header contains the total number of items in the collection.
|
||||
TotalCount int `json:"x-total-count"`
|
||||
}
|
||||
93
oryx/pagination/tokenpagination/pagination.go
Normal file
93
oryx/pagination/tokenpagination/pagination.go
Normal file
@@ -0,0 +1,93 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package tokenpagination
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/tidwall/gjson"
|
||||
|
||||
"github.com/ory/x/pagination"
|
||||
|
||||
"github.com/ory/herodot"
|
||||
)
|
||||
|
||||
func Encode(offset int64) string {
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf(`{"offset":"%d","v":2}`, offset)))
|
||||
}
|
||||
|
||||
func decode(s string) (int, error) {
|
||||
b, err := base64.RawURLEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return 0, errors.WithStack(herodot.ErrBadRequest().WithWrap(err).WithReasonf("Unable to parse pagination token: %s", err))
|
||||
}
|
||||
|
||||
return int(gjson.Get(string(b), "offset").Int()), nil
|
||||
}
|
||||
|
||||
type TokenPaginator struct {
|
||||
MaxItems int
|
||||
DefaultItems int
|
||||
}
|
||||
|
||||
func (p *TokenPaginator) defaults() {
|
||||
if p.MaxItems == 0 {
|
||||
p.MaxItems = 1000
|
||||
}
|
||||
|
||||
if p.DefaultItems == 0 {
|
||||
p.DefaultItems = 250
|
||||
}
|
||||
}
|
||||
|
||||
// ParsePagination parses limit and page from *http.Request with given limits and defaults.
|
||||
func (p *TokenPaginator) ParsePagination(r *http.Request) (page, itemsPerPage int) {
|
||||
p.defaults()
|
||||
|
||||
var offset int
|
||||
if offsetParam := r.URL.Query().Get("page_token"); len(offsetParam) > 0 {
|
||||
offset, _ = decode(offsetParam)
|
||||
}
|
||||
|
||||
if gotLimit, err := strconv.ParseInt(r.URL.Query().Get("page_size"), 10, 0); err == nil {
|
||||
itemsPerPage = int(gotLimit)
|
||||
} else {
|
||||
itemsPerPage = p.DefaultItems
|
||||
}
|
||||
|
||||
if itemsPerPage > p.MaxItems {
|
||||
itemsPerPage = p.MaxItems
|
||||
}
|
||||
|
||||
if itemsPerPage < 1 {
|
||||
itemsPerPage = 1
|
||||
}
|
||||
|
||||
if offset > 0 {
|
||||
page = offset / itemsPerPage
|
||||
}
|
||||
|
||||
if page < 0 {
|
||||
page = 0
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func header(u *url.URL, rel string, itemsPerPage, offset int64) string {
|
||||
q := u.Query()
|
||||
q.Set("page_size", fmt.Sprintf("%d", itemsPerPage))
|
||||
q.Set("page_token", Encode(offset))
|
||||
u.RawQuery = q.Encode()
|
||||
return fmt.Sprintf("<%s>; rel=\"%s\"", u.String(), rel)
|
||||
}
|
||||
|
||||
func PaginationHeader(w http.ResponseWriter, u *url.URL, total int64, page, itemsPerPage int) {
|
||||
pagination.HeaderWithFormatter(w, u, total, page, itemsPerPage, header)
|
||||
}
|
||||
Reference in New Issue
Block a user