From 75cb6c52291a9d1aa5180923550d1a27a850bb96 Mon Sep 17 00:00:00 2001 From: "Daniel G. Taylor" Date: Thu, 3 Mar 2022 21:58:31 -0800 Subject: [PATCH] feat: make collection paginators customizable --- README.md | 40 ++++++++++++++++-- go.mod | 10 +++-- go.sum | 11 ++--- graphql.go | 30 +++++++++++--- graphql_model.go | 97 ++++++++++++++++++++++++++++---------------- graphql_paginator.go | 77 +++++++++++++++++++++++++++++++++++ graphql_test.go | 17 ++++++-- 7 files changed, 226 insertions(+), 56 deletions(-) create mode 100644 graphql_paginator.go diff --git a/README.md b/README.md index b6ae731..3b4a23c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/go.mod b/go.mod index 35bcaac..067d6d7 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 3bff620..ac96e8e 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/graphql.go b/graphql.go index 8c9aa22..dadb7af 100644 --- a/graphql.go +++ b/graphql.go @@ -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{}) diff --git a/graphql_model.go b/graphql_model.go index 9ceccbb..5d5e50a 100644 --- a/graphql_model.go +++ b/graphql_model.go @@ -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) diff --git a/graphql_paginator.go b/graphql_paginator.go new file mode 100644 index 0000000..11b671f --- /dev/null +++ b/graphql_paginator.go @@ -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 +} diff --git a/graphql_test.go b/graphql_test.go index 7cb7a41..8aeca9b 100644 --- a/graphql_test.go +++ b/graphql_test.go @@ -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", "; rel=\"first\"") + ctx.Header().Set("Link", "; 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: ; rel="first" + link: ; rel="next" + links: + next: + - key: cursor + value: abc123 edges: - categoriesItem: id: video_games