mirror of
https://github.com/Fishwaldo/huma.git
synced 2025-03-15 11:21:42 +00:00
feat: make collection paginators customizable
This commit is contained in:
parent
31a4f4b510
commit
75cb6c5229
7 changed files with 226 additions and 56 deletions
40
README.md
40
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
|
||||
|
||||
|
|
10
go.mod
10
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
|
||||
)
|
||||
|
|
11
go.sum
11
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=
|
||||
|
|
30
graphql.go
30
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{})
|
||||
|
|
|
@ -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
77
graphql_paginator.go
Normal 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
|
||||
}
|
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue