feat: make collection paginators customizable

This commit is contained in:
Daniel G. Taylor 2022-03-03 21:58:31 -08:00
parent 31a4f4b510
commit 75cb6c5229
No known key found for this signature in database
GPG key ID: 74AE195C5112E534
7 changed files with 226 additions and 56 deletions

View file

@ -829,8 +829,11 @@ If you want your resources to automatically fill in params, such as an item's ID
```go
app.Resource("/notes").Get("list-notes", "docs",
responses.OK().Model([]NoteSummary{}),
).Run(func(ctx huma.Context) {
responses.OK().Headers("Link").Model([]NoteSummary{}),
).Run(func(ctx huma.Context, input struct {
Cursor string `query:"cursor" doc:"Paginatoin cursor"`
Limit int `query:"limit" doc:"Number of items to return"`
}) {
// Handler implementation goes here...
})
@ -861,18 +864,47 @@ See the `graphql_test.go` file for a full-fledged example.
### GraphQL List Responses
HTTP responses may be lists, such as the `list-notes` example operation above. Since GraphQL responses need to account for more than just the response body (i.e. headers), Huma returns this as a wrapper object similar to [Relay's Cursor Connections](https://relay.dev/graphql/connections.htm) pattern. The structure looks like:
HTTP responses may be lists, such as the `list-notes` example operation above. Since GraphQL responses need to account for more than just the response body (i.e. headers), Huma returns this as a wrapper object similar to but as a more general form of [Relay's Cursor Connections](https://relay.dev/graphql/connections.htm) pattern. The structure knows how to parse link relationship headers and looks like:
```
{
"edges": [... your responses here...],
"links": {
"next": [
{"key": "param1", "value": "value1"},
{"key": "param2", "value": "value2"},
...
]
}
"headers": {
"headerName": "headerValue"
}
}
```
This data structure can be considered experimental and may change in the future based on feedback.
If you want a different paginator then this can be configured by creating your own struct which includes a field of `huma.GraphQLItems` and which implements the `huma.GraphQLPaginator` interface. For example:
```go
// First, define the custom paginator. This does nothing but return the list
// of items and ignores the headers.
type MySimplePaginator struct {
Items huma.GraphQLItems `json:"items"`
}
func (m *MySimplePaginator) Load(headers map[string]string, body []interface{}) error {
// Huma creates a new instance of your paginator before calling `Load`, so
// here you populate the instance with the response data as needed.
m.Items = body
return nil
}
// Then, tell your app to use it when enabling GraphQL.
app.EnableGraphQL(&huma.GraphQLConfig{
Paginator: &MySimplePaginator{},
})
```
Using the same mechanism above you can support Relay Collections or any other pagination spec as long as your underlying HTTP API supports the inputs/outputs required for populating the paginator structs.
### Custom GraphQL Path

10
go.mod
View file

@ -5,15 +5,15 @@ go 1.13
require (
github.com/Jeffail/gabs/v2 v2.6.0
github.com/andybalholm/brotli v1.0.0
github.com/danielgtaylor/casing v0.0.0-20210126043903-4e55e6373ac3 // indirect
github.com/evanphx/json-patch/v5 v5.5.0 // indirect
github.com/danielgtaylor/casing v0.0.0-20210126043903-4e55e6373ac3
github.com/fatih/structs v1.1.0
github.com/fsnotify/fsnotify v1.4.9 // indirect
github.com/fxamacker/cbor v1.5.1
github.com/fxamacker/cbor/v2 v2.2.0
github.com/go-chi/chi v4.1.2+incompatible
github.com/goccy/go-yaml v1.8.1
github.com/graphql-go/graphql v0.8.0 // indirect
github.com/graphql-go/handler v0.2.3 // indirect
github.com/graphql-go/graphql v0.8.0
github.com/graphql-go/handler v0.2.3
github.com/koron-go/gqlcost v0.2.2
github.com/magiconair/properties v1.8.2 // indirect
github.com/mattn/go-isatty v0.0.12
@ -27,10 +27,12 @@ require (
github.com/spf13/pflag v1.0.5 // indirect
github.com/spf13/viper v1.7.1
github.com/stretchr/testify v1.7.0
github.com/tent/http-link-go v0.0.0-20130702225549-ac974c61c2f9
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonschema v1.2.0
go.uber.org/zap v1.15.0
golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
gopkg.in/ini.v1 v1.60.1 // indirect
launchpad.net/gocheck v0.0.0-20140225173054-000000000087 // indirect
)

11
go.sum
View file

@ -46,10 +46,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
github.com/evanphx/json-patch/v5 v5.5.0 h1:bAmFiUJ+o0o2B4OiTFeE3MqCOtyo+jjPP9iZ0VRxYUc=
github.com/evanphx/json-patch/v5 v5.5.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4=
github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
@ -126,7 +126,6 @@ github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2p
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
@ -232,12 +231,12 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/tent/http-link-go v0.0.0-20130702225549-ac974c61c2f9 h1:/Bsw4C+DEdqPjt8vAqaC9LAqpAQnaCQQqmolqq3S1T4=
github.com/tent/http-link-go v0.0.0-20130702225549-ac974c61c2f9/go.mod h1:RHkNRtSLfOK7qBTHaeSX1D6BNpI3qw7NTxsmNr4RvN8=
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
@ -412,4 +411,6 @@ honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWh
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
launchpad.net/gocheck v0.0.0-20140225173054-000000000087 h1:Izowp2XBH6Ya6rv+hqbceQyw/gSGoXfH/UPoTGduL54=
launchpad.net/gocheck v0.0.0-20140225173054-000000000087/go.mod h1:hj7XX3B/0A+80Vse0e+BUHsHMTEhd0O4cpUHr/e/BUM=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=

View file

@ -11,6 +11,7 @@ import (
"strings"
"github.com/danielgtaylor/casing"
"github.com/fatih/structs"
"github.com/graphql-go/graphql"
"github.com/graphql-go/handler"
"github.com/koron-go/gqlcost"
@ -32,6 +33,12 @@ type GraphQLConfig struct {
// created from sub-resource requests.
ComplexityLimit int
// Paginator defines the struct to be used for paginated responses. This
// can be used to conform to different pagination styles if the underlying
// API supports them, such as Relay. If not set, then
// `GraphQLDefaultPaginator` is used.
Paginator GraphQLPaginator
// known keeps track of known structs since they can only be defined once
// per GraphQL endpoint. If used by multiple HTTP operations, they must
// reference the same struct converted to GraphQL schema.
@ -51,6 +58,9 @@ type GraphQLConfig struct {
// costMap tracks the type name -> field cost for any fields that aren't
// the default cost of 1 (i.e. arrays of subresources).
costMap gqlcost.CostMap
// paginatorType stores the type for fast calls to `reflect.New`.
paginatorType reflect.Type
}
// allResources recursively finds all resource and sub-resources and adds them
@ -177,7 +187,7 @@ func (r *Router) handleOperation(config *GraphQLConfig, parentName string, field
continue
}
jsName := casing.LowerCamel(name)
typ, err := r.generateGraphModel(config, param.typ, "", nil, nil)
typ, err := r.generateGraphModel(config, param.typ, "", nil, nil, nil)
if err != nil {
panic(err)
}
@ -197,7 +207,7 @@ func (r *Router) handleOperation(config *GraphQLConfig, parentName string, field
}
// Convert the Go model to GraphQL Schema.
out, err := r.generateGraphModel(config, model, resource.path, headerNames, ignoreParams)
out, err := r.generateGraphModel(config, model, resource.path, headerNames, ignoreParams, nil)
if err != nil {
panic(err)
}
@ -301,10 +311,14 @@ func (r *Router) handleOperation(config *GraphQLConfig, parentName string, field
m["__params"] = newParams
}
}
result = map[string]interface{}{
"edges": s,
"headers": headerMap,
}
paginator := reflect.New(config.paginatorType).Interface().(GraphQLPaginator)
paginator.Load(headerMap, s)
// Other code expects map[string]interface{} not structs, so here we
// convert to a map in case there is further processing to do.
converter := structs.New(paginator)
converter.TagName = "json"
result = converter.Map()
}
return result, nil
}, nil
@ -343,10 +357,14 @@ func (r *Router) EnableGraphQL(config *GraphQLConfig) {
if config.Path == "" {
config.Path = "/graphql"
}
if config.Paginator == nil {
config.Paginator = &GraphQLDefaultPaginator{}
}
config.known = map[string]graphql.Output{}
config.resources = resources
config.paramMappings = map[string]map[string]string{}
config.costMap = gqlcost.CostMap{}
config.paginatorType = reflect.TypeOf(config.Paginator).Elem()
for _, resource := range resources {
r.handleResource(config, "Query", fields, resource, map[string]bool{})

View file

@ -41,7 +41,7 @@ func getFields(typ reflect.Type) []reflect.StructField {
// addHeaderFields will add a `headers` field which is an object with all
// defined headers as string fields.
func addHeaderFields(name string, fields graphql.Fields, headerNames []string) {
if len(headerNames) > 0 {
if len(headerNames) > 0 && fields["headers"] == nil {
headerFields := graphql.Fields{}
for _, name := range headerNames {
headerFields[casing.LowerCamel(strings.ToLower(name))] = &graphql.Field{
@ -60,7 +60,7 @@ func addHeaderFields(name string, fields graphql.Fields, headerNames []string) {
// generateGraphModel converts a Go type to GraphQL Schema. It uses reflection
// to recursively crawl structures and can also handle sub-resources if the
// input type is a struct representing a resource.
func (r *Router) generateGraphModel(config *GraphQLConfig, t reflect.Type, urlTemplate string, headerNames []string, ignoreParams map[string]bool) (graphql.Output, error) {
func (r *Router) generateGraphModel(config *GraphQLConfig, t reflect.Type, urlTemplate string, headerNames []string, ignoreParams map[string]bool, listItems graphql.Output) (graphql.Output, error) {
switch t.Kind() {
case reflect.Struct:
// Handle special cases.
@ -69,11 +69,18 @@ func (r *Router) generateGraphModel(config *GraphQLConfig, t reflect.Type, urlTe
return graphql.DateTime, nil
}
if config.known[t.String()] != nil {
return config.known[t.String()], nil
objectName := casing.Camel(strings.Replace(t.String(), ".", " ", -1))
if _, ok := reflect.New(t).Interface().(GraphQLPaginator); ok {
// Special case: this is a paginator implementation, and we need to
// generate a paginator specific to the item types it contains. This
// sets the name to the item type + a suffix, e.g. `MyItemCollection`.
objectName = listItems.Name() + "Collection"
}
if config.known[objectName] != nil {
return config.known[objectName], nil
}
objectName := casing.Camel(strings.Replace(t.String(), ".", " ", -1))
fields := graphql.Fields{}
paramMap := map[string]string{}
@ -90,7 +97,40 @@ func (r *Router) generateGraphModel(config *GraphQLConfig, t reflect.Type, urlTe
paramMap[mapping] = name
}
out, err := r.generateGraphModel(config, f.Type, "", nil, ignoreParams)
if f.Type == reflect.TypeOf(GraphQLHeaders{}) {
// Special case: generate an object for the known headers
if len(headerNames) > 0 {
headerFields := graphql.Fields{}
for _, name := range headerNames {
headerFields[casing.LowerCamel(strings.ToLower(name))] = &graphql.Field{
Type: graphql.String,
}
}
fields[name] = &graphql.Field{
Name: name,
Description: "HTTP response headers",
Type: graphql.NewObject(graphql.ObjectConfig{
Name: casing.Camel(strings.Replace(objectName+" "+name, ".", " ", -1)),
Fields: headerFields,
}),
}
headerNames = []string{}
}
continue
}
if f.Type == reflect.TypeOf(GraphQLItems{}) {
// Special case: items placeholder for list responses. This should
// be replaced with the generated specific item schema.
fields[name] = &graphql.Field{
Name: name,
Description: "List items",
Type: graphql.NewList(listItems),
}
continue
}
out, err := r.generateGraphModel(config, f.Type, "", nil, ignoreParams, listItems)
if err != nil {
return nil, err
}
@ -120,12 +160,12 @@ func (r *Router) generateGraphModel(config *GraphQLConfig, t reflect.Type, urlTe
if p.Source == nil || p.Source.(map[string]interface{})[name] == nil {
return nil, nil
}
value := p.Source.(map[string]interface{})[name].(map[string]interface{})
entries := []interface{}{}
for k, v := range value {
m := reflect.ValueOf(p.Source.(map[string]interface{})[name])
for _, k := range m.MapKeys() {
entries = append(entries, map[string]interface{}{
"key": k,
"value": v,
"key": k.Interface(),
"value": m.MapIndex(k).Interface(),
})
}
return entries, nil
@ -165,7 +205,7 @@ func (r *Router) generateGraphModel(config *GraphQLConfig, t reflect.Type, urlTe
}
}
addHeaderFields(t.String(), fields, headerNames)
addHeaderFields(objectName, fields, headerNames)
if len(fields) == 0 {
// JSON supports empty object (e.g. for future expansion) but GraphQL
@ -182,7 +222,7 @@ func (r *Router) generateGraphModel(config *GraphQLConfig, t reflect.Type, urlTe
Name: objectName,
Fields: fields,
})
config.known[t.String()] = out
config.known[objectName] = out
return out, nil
case reflect.Map:
// Ruh-roh... GraphQL doesn't support maps. So here we'll convert the map
@ -195,11 +235,11 @@ func (r *Router) generateGraphModel(config *GraphQLConfig, t reflect.Type, urlTe
// map[string]MyObject -> StringMyObjectEntry
name := casing.Camel(strings.Replace(t.Key().String()+" "+t.Elem().String()+" Entry", ".", " ", -1))
keyModel, err := r.generateGraphModel(config, t.Key(), "", nil, ignoreParams)
keyModel, err := r.generateGraphModel(config, t.Key(), "", nil, ignoreParams, listItems)
if err != nil {
return nil, err
}
valueModel, err := r.generateGraphModel(config, t.Elem(), "", nil, ignoreParams)
valueModel, err := r.generateGraphModel(config, t.Elem(), "", nil, ignoreParams, listItems)
if err != nil {
return nil, err
}
@ -226,7 +266,7 @@ func (r *Router) generateGraphModel(config *GraphQLConfig, t reflect.Type, urlTe
return graphql.String, nil
}
items, err := r.generateGraphModel(config, t.Elem(), urlTemplate, nil, ignoreParams)
items, err := r.generateGraphModel(config, t.Elem(), urlTemplate, headerNames, ignoreParams, nil)
if err != nil {
return nil, err
}
@ -234,28 +274,17 @@ func (r *Router) generateGraphModel(config *GraphQLConfig, t reflect.Type, urlTe
if headerNames != nil {
// The presence of headerNames implies this is an HTTP resource and
// not just any normal array within the response structure.
name := items.Name() + "Collection"
if config.known[name] != nil {
return config.known[name], nil
paginator, err := r.generateGraphModel(config, reflect.TypeOf(config.Paginator), "", headerNames, ignoreParams, items)
if err != nil {
return nil, err
}
fields := graphql.Fields{
"edges": &graphql.Field{
Type: graphql.NewList(items),
},
if config.known[paginator.Name()] != nil {
return config.known[paginator.Name()], nil
}
addHeaderFields(name, fields, headerNames)
wrapper := graphql.NewObject(graphql.ObjectConfig{
Name: name,
Fields: fields,
})
config.known[name] = wrapper
return wrapper, nil
config.known[paginator.Name()] = paginator
return paginator, nil
}
return graphql.NewList(items), nil
@ -268,7 +297,7 @@ func (r *Router) generateGraphModel(config *GraphQLConfig, t reflect.Type, urlTe
case reflect.String:
return graphql.String, nil
case reflect.Ptr:
return r.generateGraphModel(config, t.Elem(), urlTemplate, headerNames, ignoreParams)
return r.generateGraphModel(config, t.Elem(), urlTemplate, headerNames, ignoreParams, listItems)
}
return nil, fmt.Errorf("unsupported type %s from %s", t.Kind(), t)

77
graphql_paginator.go Normal file
View file

@ -0,0 +1,77 @@
package huma
import (
"net/url"
"strings"
link "github.com/tent/http-link-go"
)
// GraphQLPaginator defines how to to turn list responses from the HTTP API to
// GraphQL response objects.
type GraphQLPaginator interface {
// Load the paginated response from the given headers and body. After this
// call completes, your struct instance should be ready to send back to
// the client.
Load(headers map[string]string, body []interface{}) error
}
// GraphQLHeaders is a placeholder to be used in `GraphQLPaginator` struct
// implementations which gets replaced with a struct of response headers.
type GraphQLHeaders map[string]string
// GraphQLItems is a placeholder to be used in `GraphQLPaginator` struct
// implementations which gets replaced with a list of the response items model.
type GraphQLItems []interface{}
// GraphQLPaginationParams provides params for link relationships so that
// new GraphQL queries to get e.g. the next page of items are easy to construct.
type GraphQLPaginationParams struct {
First map[string]string `json:"first" doc:"First page link relationship"`
Next map[string]string `json:"next" doc:"Next page link relationship"`
Prev map[string]string `json:"prev" doc:"Previous page link relationship"`
Last map[string]string `json:"last" doc:"Last page link relationship"`
}
// GraphQLDefaultPaginator provides a default generic paginator implementation
// that makes no assumptions about pagination parameter names, headers, etc.
// It enables clients to access the response items (edges) as well as any
// response headers. If a link relation header is found in the response, then
// link relationships are parsed and turned into easy-to-use parameters for
// subsequent requests.
type GraphQLDefaultPaginator struct {
Headers GraphQLHeaders `json:"headers"`
Links GraphQLPaginationParams `json:"links" doc:"Pagination link parameters"`
Edges GraphQLItems `json:"edges"`
}
// Load the paginated response and parse link relationships if available.
func (g *GraphQLDefaultPaginator) Load(headers map[string]string, body []interface{}) error {
g.Headers = headers
if parsed, err := link.Parse(headers["link"]); err == nil && len(parsed) > 0 {
for _, item := range parsed {
parsed, err := url.Parse(item.URI)
if err != nil {
continue
}
params := map[string]string{}
query := parsed.Query()
for k := range query {
params[k] = query.Get(k)
}
switch strings.ToLower(item.Rel) {
case "first":
g.Links.First = params
case "next":
g.Links.Next = params
case "prev":
g.Links.Prev = params
case "last":
g.Links.Last = params
}
}
}
g.Edges = body
return nil
}

View file

@ -85,7 +85,8 @@ func TestGraphQL(t *testing.T) {
categoriesResource.Get("get-categories", "doc",
NewResponse(http.StatusOK, "").Model([]CategorySummary{}).Headers("link"),
).Run(func(ctx Context, input struct {
Limit int `query:"limit" default:"10"`
Cursor string `query:"cursor"`
Limit int `query:"limit" default:"10"`
}) {
summaries := []CategorySummary{}
for _, cat := range categories {
@ -100,7 +101,7 @@ func TestGraphQL(t *testing.T) {
if len(summaries) < input.Limit {
input.Limit = len(summaries)
}
ctx.Header().Set("Link", "</categories>; rel=\"first\"")
ctx.Header().Set("Link", "</categories?cursor=abc123>; rel=\"next\"")
ctx.WriteModel(http.StatusOK, summaries[:input.Limit])
})
@ -202,6 +203,12 @@ func TestGraphQL(t *testing.T) {
headers {
link
}
links {
next {
key
value
}
}
edges {
categoriesItem {
id
@ -245,7 +252,11 @@ func TestGraphQL(t *testing.T) {
data:
categories:
headers:
link: </categories>; rel="first"
link: </categories?cursor=abc123>; rel="next"
links:
next:
- key: cursor
value: abc123
edges:
- categoriesItem:
id: video_games