From b3e6e1a1c06925d08f3c93f76d09edab58f88b4e Mon Sep 17 00:00:00 2001 From: "Daniel G. Taylor" Date: Sat, 26 Feb 2022 09:42:44 -0800 Subject: [PATCH 01/12] feat: add basic read-only GraphQL support --- README.md | 49 +++++++++ go.mod | 5 +- go.sum | 10 ++ graphql.go | 271 +++++++++++++++++++++++++++++++++++++++++++++++ graphql_model.go | 242 ++++++++++++++++++++++++++++++++++++++++++ graphql_test.go | 258 ++++++++++++++++++++++++++++++++++++++++++++ openapi.go | 2 + resolver.go | 2 + 8 files changed, 838 insertions(+), 1 deletion(-) create mode 100644 graphql.go create mode 100644 graphql_model.go create mode 100644 graphql_test.go diff --git a/README.md b/README.md index 87cc80d3..a2ee5a56 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ A modern, simple, fast & opinionated REST API framework for Go with batteries in Features include: - HTTP, HTTPS (TLS), and [HTTP/2](https://http2.github.io/) built-in +- Optional read-only GraphQL interface built-in - Declarative interface on top of [Chi](https://github.com/go-chi/chi) - Operation & model documentation - Request params (path, query, or header) @@ -820,6 +821,54 @@ Then run the service: $ go run yourservice.go --help ``` +## GraphQL + +Huma includes an optional, built-in, read-only GraphQL interface that can be enabled via `app.EnableGraphQL(config)`. It is mostly automatic and will re-use all your defined resources, read operations, and their params, headers, and models. By default it is available at `/graphql`. + +If you want your resources to automatically fill in params, such as an item's ID from a list result, you must tell Huma how to map fields of the response to the correct parameter name. This is accomplished via the `graphParam` struct field tag. For example, given the following resources: + +```go +app.Resource("/notes").Get("list-notes", "docs", + responses.OK().Model([]NoteSummary{}), +).Run(func(ctx huma.Context) { + // Handler implementation goes here... +}) + +app.Resource("/notes/{note-id}").Get("get-note", "docs", + responses.OK().Model(Note{}), +).Run(func(ctx huma.Context, input struct { + NodeID string `path:"note-id"` +}) { + // Handler implementation goes here... +}) +``` + +You would map the `/notes` response to the `/notes/{note-id}` request with a tag on the response struct's field: + +```go +type NoteSummary struct { + ID string `json:"id" graphParam:"note-id"` +} +``` + +Whenever a list of items is returned, you can access the detailed item via the name+"Item", e.g. `notesItem` would return the `get-note` response. + +Then you can make requests against the service like `http://localhost:8888/graphql?query={notes{id%20notesItem{contents}}}`. + +See the `graphql_test.go` file for a full-fledged example. + +> :whale: Note that because Huma knows nothing about your database, there is no way to make efficient queries to only select the fields that were requested. This GraphQL layer works by making normal HTTP requests to your service as needed to fulfill the query. Even with that caveat it can greatly simplify and speed up frontend requests. + +### Enabling the GraphiQL UI + +You can turn on a UI for writing and making queries with schema documentation via the GraphQL config: + +```go +app.EnableGraphQL(&huma.GraphQLConfig{ + GraphiQL: true, +}) +``` + ## CLI Runtime Arguments & Configuration The CLI can be configured in multiple ways. In order of decreasing precedence: diff --git a/go.mod b/go.mod index fc06c5a3..0fe48268 100644 --- a/go.mod +++ b/go.mod @@ -5,12 +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/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/magiconair/properties v1.8.2 // indirect github.com/mattn/go-isatty v0.0.12 github.com/mitchellh/mapstructure v1.3.3 // indirect @@ -22,7 +25,7 @@ require ( github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/viper v1.7.1 - github.com/stretchr/testify v1.5.1 + github.com/stretchr/testify v1.7.0 github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect github.com/xeipuuv/gojsonschema v1.2.0 go.uber.org/zap v1.15.0 diff --git a/go.sum b/go.sum index 424c0b45..1537939e 100644 --- a/go.sum +++ b/go.sum @@ -39,6 +39,8 @@ github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3Ee github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA= github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/danielgtaylor/casing v0.0.0-20210126043903-4e55e6373ac3 h1:qDsADtCM9A6UfvHje3eD91dufI9nVSwHWEqqhAvh28U= +github.com/danielgtaylor/casing v0.0.0-20210126043903-4e55e6373ac3/go.mod h1:eFdYmNxcuLDrRNW0efVoxSaApmvGXfHZ9k2CT/RSUF0= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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= @@ -93,6 +95,10 @@ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGa github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/graphql-go/graphql v0.8.0 h1:JHRQMeQjofwqVvGwYnr8JnPTY0AxgVy1HpHSGPLdH0I= +github.com/graphql-go/graphql v0.8.0/go.mod h1:nKiHzRM0qopJEwCITUuIsxk9PlVlwIiiI8pnJEhordQ= +github.com/graphql-go/handler v0.2.3 h1:CANh8WPnl5M9uA25c2GBhPqJhE53Fg0Iue/fRNla71E= +github.com/graphql-go/handler v0.2.3/go.mod h1:leLF6RpV5uZMN1CdImAxuiayrYYhOk33bZciaUGaXeU= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= @@ -225,6 +231,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV 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/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= @@ -394,6 +402,8 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/graphql.go b/graphql.go new file mode 100644 index 00000000..986fff8d --- /dev/null +++ b/graphql.go @@ -0,0 +1,271 @@ +package huma + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "sort" + "strings" + + "github.com/danielgtaylor/casing" + "github.com/graphql-go/graphql" + "github.com/graphql-go/handler" +) + +type graphContextKey string + +var graphKeyHeaders graphContextKey = "headers" + +type GraphQLConfig struct { + // Path where the GraphQL endpoint is available. Defaults to `/graphql`. + Path string + + // GraphiQL sets whether the UI is available at the path. Defaults to off. + GraphiQL bool + + // 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. + known map[string]graphql.Output + + // resources is a list of all resources in the router. + resources []*Resource + + // paramMappings are a mapping of URL template to a map of OpenAPI param name + // to Go struct field JSON name. For example, `/items` could have a + // mapping of `item-id` -> `id` if the structs returned for each item have + // a field named `id` that should be used as input to e.g. + // `/items/{item-id}/prices`. These mappings are configured by putting a + // tag `graphParam` on your go struct fields. + paramMappings map[string]map[string]string +} + +// allResources recursively finds all resource and sub-resources and adds them +// to the `result` slice. +func allResources(result []*Resource, r *Resource) { + for _, sub := range r.subResources { + result = append(result, sub) + allResources(result, sub) + } +} + +// fetch from a Huma router. Returns the parsed JSON. +func (r *Router) fetch(headers http.Header, path string, query map[string]interface{}) (interface{}, http.Header, error) { + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, path, nil) + // Keep it simple & fast for these internal requests. + headers.Set("Accept", "application/json") + headers.Set("Accept-Encoding", "none") + req.Header = headers + q := req.URL.Query() + for k, v := range query { + q.Set(k, fmt.Sprintf("%v", v)) + } + req.URL.RawQuery = q.Encode() + r.ServeHTTP(w, req) + if w.Result().StatusCode >= 400 { + return nil, nil, fmt.Errorf("error response from server while fetching %s: %d\n%s", path, w.Result().StatusCode, w.Body.String()) + } + var body interface{} + err := json.Unmarshal(w.Body.Bytes(), &body) + return body, w.Result().Header, err +} + +// getModel returns the schema and model for the operation's first HTTP 2xx +// response that is found. +func getModel(op *Operation) (reflect.Type, []string, error) { + for _, resp := range op.responses { + if resp.status >= 200 && resp.status < 300 && resp.model != nil { + return resp.model, resp.headers, nil + } + } + return nil, nil, fmt.Errorf("no model found for %s", op.id) +} + +func (r *Router) handleResource(config *GraphQLConfig, fields graphql.Fields, resource *Resource, ignoreParams map[string]bool) { + for _, op := range resource.operations { + if op.method != http.MethodGet { + continue + } + + model, headerNames, err := getModel(op) + if err != nil || model == nil { + // This is a GET but returns nothing??? + continue + } + + // `/things` -> `things` + // `/things/{thing-id}` -> `thingsItem(thingId)` + // `/things/{thing-id}/sub` -> `sub(thingId)` + parts := strings.Split(strings.Trim(resource.path, "/"), "/") + last := parts[len(parts)-1] + for i := len(parts) - 1; i >= 0; i-- { + if parts[i][0] == '{' { + if i > 0 { + last = parts[i-1] + "Item" + } + continue + } + break + } + + // Setup input arguments (i.e. OpenAPI operation params). + args := graphql.FieldConfigArgument{} + argsNameMap := map[string]string{} + for name, param := range op.params { + if ignoreParams[name] || param.Internal { + // This will be handled automatically. + continue + } + jsName := casing.LowerCamel(name) + typ, err := r.generateGraphModel(config, param.typ, "", nil, nil) + if err != nil { + panic(err) + } + argsNameMap[jsName] = name + args[jsName] = &graphql.ArgumentConfig{ + Type: typ, + Description: param.Description, + } + } + + // Convert the Go model to GraphQL Schema. + out, err := r.generateGraphModel(config, model, resource.path, headerNames, ignoreParams) + if err != nil { + panic(err) + } + + fields[last] = &graphql.Field{ + Type: out, + Description: op.description, + Args: args, + Resolve: func(p graphql.ResolveParams) (interface{}, error) { + // Fetch and populate this resource from the underlying REST API. + headers := p.Context.Value(graphKeyHeaders).(http.Header).Clone() + path := resource.path + queryParams := map[string]interface{}{} + + // Handle pre-filled args, then passed args + params := map[string]interface{}{} + if p.Source != nil { + if m, ok := p.Source.(map[string]interface{}); ok { + if m["__params"] != nil { + params = m["__params"].(map[string]interface{}) + for k, v := range params { + path = strings.Replace(path, "{"+k+"}", fmt.Sprintf("%v", v), 1) + } + } + } + } + + for arg := range p.Args { + // Passed args get saved for later use. + params[argsNameMap[arg]] = p.Args[arg] + + // Apply the arg to the request. + param := op.params[argsNameMap[arg]] + if param.In == inPath { + path = strings.Replace(path, "{"+argsNameMap[arg]+"}", fmt.Sprintf("%v", p.Args[arg]), 1) + } else if param.In == inQuery { + queryParams[argsNameMap[arg]] = p.Args[arg] + } else if param.In == inHeader { + headers.Set(argsNameMap[arg], fmt.Sprintf("%v", p.Args[arg])) + } + } + + result, respHeader, err := r.fetch(headers, path, queryParams) + if err != nil { + return nil, err + } + + paramMap := config.paramMappings[resource.path] + + if m, ok := result.(map[string]interface{}); ok { + // Save params for child requests to use. + newParams := map[string]interface{}{} + for k, v := range params { + newParams[k] = v + } + for paramName, fieldName := range paramMap { + newParams[paramName] = m[fieldName] + } + m["__params"] = newParams + + // Set headers so they can be queried. + headerMap := map[string]string{} + for headerName := range respHeader { + headerMap[casing.LowerCamel(strings.ToLower(headerName))] = respHeader.Get(headerName) + } + m["headers"] = headerMap + } else if s, ok := result.([]interface{}); ok { + // Since this is a list, we set params on each item. + for _, item := range s { + if m, ok := item.(map[string]interface{}); ok { + newParams := map[string]interface{}{} + for k, v := range params { + newParams[k] = v + } + for paramName, fieldName := range paramMap { + newParams[paramName] = m[fieldName] + } + m["__params"] = newParams + } + } + } + return result, nil + }, + } + } +} + +// EnableGraphQL turns on a read-only GraphQL endpoint. +func (r *Router) EnableGraphQL(config *GraphQLConfig) { + fields := graphql.Fields{} + + if config == nil { + config = &GraphQLConfig{} + } + + // Collect all resources for the top-level operations. + resources := []*Resource{} + for _, resource := range r.resources { + resources = append(resources, resource) + allResources(resources, resource) + } + sort.Slice(resources, func(i, j int) bool { + return len(resources[i].path) < len(resources[j].path) + }) + + if config.Path == "" { + config.Path = "/graphql" + } + config.known = map[string]graphql.Output{} + config.resources = resources + config.paramMappings = map[string]map[string]string{} + + for _, resource := range resources { + r.handleResource(config, fields, resource, map[string]bool{}) + } + + root := graphql.ObjectConfig{Name: "Query", Fields: fields} + schemaConfig := graphql.SchemaConfig{Query: graphql.NewObject(root)} + schema, err := graphql.NewSchema(schemaConfig) + if err != nil { + panic(err) + } + + h := handler.New(&handler.Config{ + Schema: &schema, + Pretty: true, + GraphiQL: config.GraphiQL, + }) + r.mux.HandleFunc(config.Path, func(w http.ResponseWriter, r *http.Request) { + // Save the headers for future requests as they can contain important + // information. + r = r.WithContext(context.WithValue(r.Context(), graphKeyHeaders, r.Header)) + h.ServeHTTP(w, r) + }) +} diff --git a/graphql_model.go b/graphql_model.go new file mode 100644 index 00000000..2113334c --- /dev/null +++ b/graphql_model.go @@ -0,0 +1,242 @@ +package huma + +import ( + "fmt" + "net" + "net/url" + "reflect" + "strings" + "time" + + "github.com/danielgtaylor/casing" + "github.com/graphql-go/graphql" +) + +var ( + ipType = reflect.TypeOf(net.IP{}) + uriType = reflect.TypeOf(url.URL{}) +) + +// getFields performs a breadth-first search for all fields including embedded +// ones. It may return multiple fields with the same name, the first of which +// represents the outer-most declaration. +func getFields(typ reflect.Type) []reflect.StructField { + fields := make([]reflect.StructField, 0, typ.NumField()) + embedded := []reflect.StructField{} + + for i := 0; i < typ.NumField(); i++ { + f := typ.Field(i) + if f.Anonymous { + embedded = append(embedded, f) + continue + } + + fields = append(fields, f) + } + + for _, f := range embedded { + newTyp := f.Type + if newTyp.Kind() == reflect.Ptr { + newTyp = newTyp.Elem() + } + fields = append(fields, getFields(newTyp)...) + } + + return fields +} + +// generateGraphModel converts a Go type to GraphQL Schema. +func (r *Router) generateGraphModel(config *GraphQLConfig, t reflect.Type, urlTemplate string, headerNames []string, ignoreParams map[string]bool) (graphql.Output, error) { + if t == ipType { + return graphql.String, nil + } + + switch t.Kind() { + case reflect.Struct: + // Handle special cases. + switch t { + case timeType: + return graphql.DateTime, nil + case uriType: + return graphql.String, nil + } + + if config.known[t.String()] != nil { + return config.known[t.String()], nil + } + + fields := graphql.Fields{} + + paramMap := map[string]string{} + for _, f := range getFields(t) { + jsonTags := strings.Split(f.Tag.Get("json"), ",") + name := strings.ToLower(f.Name) + if len(jsonTags) > 0 && jsonTags[0] != "" { + name = jsonTags[0] + } + + // JSON "-" means to ignore the field. + if name != "-" { + if mapping := f.Tag.Get("graphParam"); mapping != "" { + paramMap[mapping] = name + } + + out, err := r.generateGraphModel(config, f.Type, "", nil, ignoreParams) + + if err != nil { + return nil, err + } + if name != "" { + fields[name] = &graphql.Field{ + Name: name, + Type: out, + Description: f.Tag.Get("doc"), + } + + if out == graphql.DateTime { + // Since graphql expects a `time.Time` we have to parse it here. + // TODO: figure out some way to pass-through the string? + fields[name].Resolve = func(p graphql.ResolveParams) (interface{}, error) { + if p.Source == nil || p.Source.(map[string]interface{})[name] == nil { + return nil, nil + } + return time.Parse(time.RFC3339Nano, p.Source.(map[string]interface{})[name].(string)) + } + } + + if f.Type.Kind() == reflect.Map { + // Use a resolver to convert between the Go map and the GraphQL + // list of {key, value} objects. + fields[name].Resolve = func(p graphql.ResolveParams) (interface{}, error) { + 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 { + entries = append(entries, map[string]interface{}{ + "key": k, + "value": v, + }) + } + return entries, nil + } + } + } + } + } + + config.paramMappings[urlTemplate] = paramMap + for k := range paramMap { + ignoreParams[k] = true + } + + if urlTemplate != "" { + for _, resource := range config.resources { + if len(resource.path) > len(urlTemplate) { + // This could be a child resource. Let's find the longest prefix match + // among all the resources and if that value matches the current + // resources's URL template then this is a direct child. + var best *Resource + for _, sub := range config.resources { + if len(resource.path) > len(sub.path) && strings.HasPrefix(resource.path, sub.path) { + if best == nil || len(best.path) < len(sub.path) { + best = sub + } + } + } + if best != nil && best.path == urlTemplate { + r.handleResource(config, fields, resource, ignoreParams) + } + } + } + } + + if len(headerNames) > 0 { + headerFields := graphql.Fields{} + for _, name := range headerNames { + headerFields[casing.LowerCamel(strings.ToLower(name))] = &graphql.Field{ + Type: graphql.String, + } + } + fields["headers"] = &graphql.Field{ + Type: graphql.NewObject(graphql.ObjectConfig{ + Name: casing.Camel(strings.Replace(t.String()+" Headers", ".", " ", -1)), + Fields: headerFields, + }), + } + } + + if len(fields) == 0 { + fields["_"] = &graphql.Field{ + Type: graphql.Boolean, + } + } + + out := graphql.NewObject(graphql.ObjectConfig{ + Name: casing.Camel(strings.Replace(t.String(), ".", " ", -1)), + Fields: fields, + }) + config.known[t.String()] = out + return out, nil + case reflect.Map: + // Ruh-roh... GraphQL doesn't support maps. So here we'll convert the map + // into a list of objects with a key and value, then later use a resolver + // function to convert from the map to this list of objects. + if config.known[t.String()] != nil { + return config.known[t.String()], nil + } + + // 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) + if err != nil { + return nil, err + } + valueModel, err := r.generateGraphModel(config, t.Elem(), "", nil, ignoreParams) + if err != nil { + return nil, err + } + + fields := graphql.Fields{ + "key": &graphql.Field{ + Type: keyModel, + }, + "value": &graphql.Field{ + Type: valueModel, + }, + } + + out := graphql.NewList(graphql.NewObject(graphql.ObjectConfig{ + Name: name, + Fields: fields, + })) + + config.known[t.String()] = out + return out, nil + case reflect.Slice, reflect.Array: + if t.Elem().Kind() == reflect.Uint8 { + // Special case: `[]byte` should be a Base-64 string. + return graphql.String, nil + } + + items, err := r.generateGraphModel(config, t.Elem(), urlTemplate, nil, ignoreParams) + if err != nil { + return nil, err + } + return graphql.NewList(items), nil + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return graphql.Int, nil + case reflect.Float32, reflect.Float64: + return graphql.Float, nil + case reflect.Bool: + return graphql.Boolean, nil + case reflect.String: + return graphql.String, nil + case reflect.Ptr: + return r.generateGraphModel(config, t.Elem(), urlTemplate, headerNames, ignoreParams) + } + + return nil, fmt.Errorf("unsupported type %s from %s", t.Kind(), t) +} diff --git a/graphql_test.go b/graphql_test.go new file mode 100644 index 00000000..83900a14 --- /dev/null +++ b/graphql_test.go @@ -0,0 +1,258 @@ +package huma + +import ( + "net/http" + "net/http/httptest" + "sort" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +type CategoryParam struct { + CategoryID string `path:"category-id"` +} + +type CategorySummary struct { + ID string `json:"id" graphParam:"category-id" doc:"Category ID"` +} + +type Category struct { + CategorySummary + Featured bool `json:"featured" doc:"Display as featured in the app"` + + products map[string]*Product `json:"-"` +} + +type ProductParam struct { + ProductID string `path:"product-id"` +} + +type ProductSummary struct { + ID string `json:"id" graphParam:"product-id" doc:"Product ID"` +} + +type Product struct { + ProductSummary + SuggestedPrice float32 `json:"suggested_price"` + Created time.Time `json:"created" doc:"When this product was created"` + Metadata map[string]string `json:"metadata,omitempty" doc:"Additional information about the product"` + + stores map[string]*Store `json:"-"` +} + +type StoreSummary struct { + ID string `json:"id" graphParam:"store-id" doc:"Store ID"` +} + +type Store struct { + StoreSummary + URL string `json:"url" doc:"Web link to buy product"` +} + +func TestGraphQL(t *testing.T) { + now, _ := time.Parse(time.RFC3339, "2022-02-22T22:22:22Z") + + amazon := &Store{StoreSummary: StoreSummary{ID: "amazon"}, URL: "https://www.amazon.com/"} + target := &Store{StoreSummary: StoreSummary{ID: "target"}, URL: "https://www.target.com/"} + + xsx := &Product{ProductSummary: ProductSummary{ID: "xbox_series_x"}, SuggestedPrice: 499.99, Created: now, Metadata: map[string]string{"foo": "bar"}, stores: map[string]*Store{"amazon": amazon, "target": target}} + ps5 := &Product{ProductSummary: ProductSummary{ID: "playstation_ps5"}, SuggestedPrice: 499.99, Created: now, stores: map[string]*Store{"amazon": amazon}} + ns := &Product{ProductSummary: ProductSummary{ID: "nintendo_switch"}, SuggestedPrice: 349.99, Created: now, stores: map[string]*Store{"target": target}} + + videoGames := &Category{ + CategorySummary: CategorySummary{ID: "video_games"}, + Featured: true, + products: map[string]*Product{ + "xbox_series_x": xsx, + "playstation_ps5": ps5, + "nintendo_switch": ns, + }, + } + + categories := map[string]*Category{ + "video_games": videoGames, + } + + app := newTestRouter() + + app.Resource("/categories").Get("get-categories", "doc", + NewResponse(http.StatusOK, "").Model([]CategorySummary{}), + ).Run(func(ctx Context, input struct { + Limit int `query:"limit" default:"10"` + }) { + summaries := []CategorySummary{} + for _, cat := range categories { + summaries = append(summaries, cat.CategorySummary) + } + sort.Slice(summaries, func(i, j int) bool { + return summaries[i].ID < summaries[j].ID + }) + if input.Limit == 0 { + input.Limit = 10 + } + if len(summaries) < input.Limit { + input.Limit = len(summaries) + } + ctx.WriteModel(http.StatusOK, summaries[:input.Limit]) + }) + + app.Resource("/categories/{category-id}").Get("get-category", "doc", + NewResponse(http.StatusOK, "").Model(&Category{}), + NewResponse(http.StatusNotFound, "").Model(&ErrorModel{}), + ).Run(func(ctx Context, input struct { + CategoryParam + }) { + if categories[input.CategoryID] == nil { + ctx.WriteError(http.StatusNotFound, "Not found") + return + } + ctx.WriteModel(http.StatusOK, categories[input.CategoryID]) + }) + + app.Resource("/categories/{category-id}/products").Get("get-items", "doc", + NewResponse(http.StatusOK, "").Model([]ProductSummary{}), + NewResponse(http.StatusNotFound, "").Model(&ErrorModel{}), + ).Run(func(ctx Context, input struct { + CategoryParam + }) { + if categories[input.CategoryID] == nil { + ctx.WriteError(http.StatusNotFound, "Not found") + return + } + summaries := []ProductSummary{} + for _, item := range categories[input.CategoryID].products { + summaries = append(summaries, item.ProductSummary) + } + sort.Slice(summaries, func(i, j int) bool { + return summaries[i].ID < summaries[j].ID + }) + ctx.WriteModel(http.StatusOK, summaries) + }) + + app.Resource("/categories/{category-id}/products/{product-id}").Get("get-item", "doc", + NewResponse(http.StatusOK, "").Model(&Product{}), + NewResponse(http.StatusNotFound, "").Model(&ErrorModel{}), + ).Run(func(ctx Context, input struct { + CategoryParam + ProductParam + }) { + if categories[input.CategoryID] == nil || categories[input.CategoryID].products[input.ProductID] == nil { + ctx.WriteError(http.StatusNotFound, "Not found") + return + } + ctx.WriteModel(http.StatusOK, categories[input.CategoryID].products[input.ProductID]) + }) + + app.Resource("/categories/{category-id}/products/{product-id}/stores").Get("get-stores", "doc", + NewResponse(http.StatusOK, "").Model([]StoreSummary{}), + NewResponse(http.StatusNotFound, "").Model(&ErrorModel{}), + ).Run(func(ctx Context, input struct { + CategoryParam + ProductParam + }) { + if categories[input.CategoryID] == nil || categories[input.CategoryID].products[input.ProductID] == nil { + ctx.WriteError(http.StatusNotFound, "Not found") + return + } + summaries := []StoreSummary{} + for _, store := range categories[input.CategoryID].products[input.ProductID].stores { + summaries = append(summaries, store.StoreSummary) + } + sort.Slice(summaries, func(i, j int) bool { + return summaries[i].ID < summaries[j].ID + }) + ctx.WriteModel(http.StatusOK, summaries) + }) + + app.Resource("/categories/{category-id}/products/{product-id}/stores/{store-id}").Get("get-store", "doc", + NewResponse(http.StatusOK, "").Model(&Store{}), + NewResponse(http.StatusNotFound, "").Model(&ErrorModel{}), + ).Run(func(ctx Context, input struct { + CategoryParam + ProductParam + StoreID string `path:"store-id" doc:"Store ID"` + }) { + if categories[input.CategoryID] == nil || categories[input.CategoryID].products[input.ProductID] == nil { + ctx.WriteError(http.StatusNotFound, "Not found") + return + } + ctx.WriteModel(http.StatusOK, categories[input.CategoryID].products[input.ProductID].stores[input.StoreID]) + }) + + app.EnableGraphQL(nil) + + query := strings.Replace(strings.Replace(`{ + categories(limit: 1) { + categoriesItem { + id + featured + products { + productsItem { + id + suggested_price + created + metadata{ + key + value + } + stores { + storesItem { + id + url + } + } + } + } + } + } + }`, "\n", " ", -1), "\t", "", -1) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/graphql?query="+query, nil) + app.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.YAMLEq(t, strings.Replace(` +data: + categories: + - categoriesItem: + id: video_games + featured: true + products: + - productsItem: + id: nintendo_switch + suggested_price: 349.99 + created: "2022-02-22T22:22:22Z" + metadata: null + stores: + - storesItem: + id: target + url: https://www.target.com/ + - productsItem: + id: playstation_ps5 + suggested_price: 499.99 + created: "2022-02-22T22:22:22Z" + metadata: null + stores: + - storesItem: + id: amazon + url: https://www.amazon.com/ + - productsItem: + id: xbox_series_x + suggested_price: 499.99 + created: "2022-02-22T22:22:22Z" + metadata: + - key: foo + value: bar + stores: + - storesItem: + id: amazon + url: https://www.amazon.com/ + - storesItem: + id: target + url: https://www.target.com/ +`, "\t", " ", -1), w.Body.String()) +} diff --git a/openapi.go b/openapi.go index 58db74af..6e7157b6 100644 --- a/openapi.go +++ b/openapi.go @@ -44,6 +44,8 @@ type oaParam struct { // Internal params are excluded from the OpenAPI document and can set up // params sent between a load balander / proxy and the service internally. Internal bool `json:"-"` + + typ reflect.Type } type oaComponents struct { diff --git a/resolver.go b/resolver.go index 9c667621..bae143c1 100644 --- a/resolver.go +++ b/resolver.go @@ -491,6 +491,8 @@ func getParamInfo(t reflect.Type) map[string]oaParam { } p.Schema = s + p.typ = f.Type + params[p.Name] = p } From 9711a48852a65ca9a2660b014cf756bb53897bc1 Mon Sep 17 00:00:00 2001 From: "Daniel G. Taylor" Date: Sat, 26 Feb 2022 23:26:19 -0800 Subject: [PATCH 02/12] feat: list response wrapper to expose headers --- README.md | 2 + graphql.go | 21 +++++--- graphql_model.go | 62 ++++++++++++++++++------ graphql_test.go | 121 +++++++++++++++++++++++++++-------------------- 4 files changed, 133 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index a2ee5a56..243378cc 100644 --- a/README.md +++ b/README.md @@ -869,6 +869,8 @@ app.EnableGraphQL(&huma.GraphQLConfig{ }) ``` +It is [recommended](https://graphql.org/learn/serving-over-http/#graphiql) to turn GraphiQL off in production. Instead a tool like [graphqurl](https://github.com/hasura/graphqurl) can be useful for using GraphiQL in production on the client side, and it supports custom headers for e.g. auth. + ## CLI Runtime Arguments & Configuration The CLI can be configured in multiple ways. In order of decreasing precedence: diff --git a/graphql.go b/graphql.go index 986fff8d..909cfe1a 100644 --- a/graphql.go +++ b/graphql.go @@ -181,10 +181,19 @@ func (r *Router) handleResource(config *GraphQLConfig, fields graphql.Fields, re return nil, err } + // Create a simple map of header name to header value. + headerMap := map[string]string{} + for headerName := range respHeader { + headerMap[casing.LowerCamel(strings.ToLower(headerName))] = respHeader.Get(headerName) + } + paramMap := config.paramMappings[resource.path] if m, ok := result.(map[string]interface{}); ok { - // Save params for child requests to use. + // Save params for child requests to use. By putting this into the + // response object but not into the GraphQL schema it ensures that + // downstream resolvers can access it but it never gets sent to the + // client as part of a response. newParams := map[string]interface{}{} for k, v := range params { newParams[k] = v @@ -193,12 +202,6 @@ func (r *Router) handleResource(config *GraphQLConfig, fields graphql.Fields, re newParams[paramName] = m[fieldName] } m["__params"] = newParams - - // Set headers so they can be queried. - headerMap := map[string]string{} - for headerName := range respHeader { - headerMap[casing.LowerCamel(strings.ToLower(headerName))] = respHeader.Get(headerName) - } m["headers"] = headerMap } else if s, ok := result.([]interface{}); ok { // Since this is a list, we set params on each item. @@ -214,6 +217,10 @@ func (r *Router) handleResource(config *GraphQLConfig, fields graphql.Fields, re m["__params"] = newParams } } + result = map[string]interface{}{ + "edges": s, + "headers": headerMap, + } } return result, nil }, diff --git a/graphql_model.go b/graphql_model.go index 2113334c..e7fe6400 100644 --- a/graphql_model.go +++ b/graphql_model.go @@ -45,6 +45,25 @@ func getFields(typ reflect.Type) []reflect.StructField { return fields } +// 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 { + headerFields := graphql.Fields{} + for _, name := range headerNames { + headerFields[casing.LowerCamel(strings.ToLower(name))] = &graphql.Field{ + Type: graphql.String, + } + } + fields["headers"] = &graphql.Field{ + Type: graphql.NewObject(graphql.ObjectConfig{ + Name: casing.Camel(strings.Replace(name+" Headers", ".", " ", -1)), + Fields: headerFields, + }), + } + } +} + // generateGraphModel converts a Go type to GraphQL Schema. func (r *Router) generateGraphModel(config *GraphQLConfig, t reflect.Type, urlTemplate string, headerNames []string, ignoreParams map[string]bool) (graphql.Output, error) { if t == ipType { @@ -152,20 +171,7 @@ func (r *Router) generateGraphModel(config *GraphQLConfig, t reflect.Type, urlTe } } - if len(headerNames) > 0 { - headerFields := graphql.Fields{} - for _, name := range headerNames { - headerFields[casing.LowerCamel(strings.ToLower(name))] = &graphql.Field{ - Type: graphql.String, - } - } - fields["headers"] = &graphql.Field{ - Type: graphql.NewObject(graphql.ObjectConfig{ - Name: casing.Camel(strings.Replace(t.String()+" Headers", ".", " ", -1)), - Fields: headerFields, - }), - } - } + addHeaderFields(t.String(), fields, headerNames) if len(fields) == 0 { fields["_"] = &graphql.Field{ @@ -225,6 +231,34 @@ func (r *Router) generateGraphModel(config *GraphQLConfig, t reflect.Type, urlTe if err != nil { return nil, err } + + 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 + } + + fields := graphql.Fields{ + "edges": &graphql.Field{ + Type: graphql.NewList(items), + }, + } + + addHeaderFields(name, fields, headerNames) + + wrapper := graphql.NewObject(graphql.ObjectConfig{ + Name: name, + Fields: fields, + }) + + config.known[name] = wrapper + + return wrapper, nil + } + return graphql.NewList(items), nil case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: return graphql.Int, nil diff --git a/graphql_test.go b/graphql_test.go index 83900a14..f451fdf0 100644 --- a/graphql_test.go +++ b/graphql_test.go @@ -79,7 +79,7 @@ func TestGraphQL(t *testing.T) { app := newTestRouter() app.Resource("/categories").Get("get-categories", "doc", - NewResponse(http.StatusOK, "").Model([]CategorySummary{}), + NewResponse(http.StatusOK, "").Model([]CategorySummary{}).Headers("link"), ).Run(func(ctx Context, input struct { Limit int `query:"limit" default:"10"` }) { @@ -96,6 +96,7 @@ func TestGraphQL(t *testing.T) { if len(summaries) < input.Limit { input.Limit = len(summaries) } + ctx.Header().Set("Link", "; rel=\"first\"") ctx.WriteModel(http.StatusOK, summaries[:input.Limit]) }) @@ -186,22 +187,31 @@ func TestGraphQL(t *testing.T) { query := strings.Replace(strings.Replace(`{ categories(limit: 1) { - categoriesItem { - id - featured - products { - productsItem { - id - suggested_price - created - metadata{ - key - value - } - stores { - storesItem { + headers { + link + } + edges { + categoriesItem { + id + featured + products { + edges { + productsItem { id - url + suggested_price + created + metadata{ + key + value + } + stores { + edges { + storesItem { + id + url + } + } + } } } } @@ -218,41 +228,48 @@ func TestGraphQL(t *testing.T) { assert.YAMLEq(t, strings.Replace(` data: categories: - - categoriesItem: - id: video_games - featured: true - products: - - productsItem: - id: nintendo_switch - suggested_price: 349.99 - created: "2022-02-22T22:22:22Z" - metadata: null - stores: - - storesItem: - id: target - url: https://www.target.com/ - - productsItem: - id: playstation_ps5 - suggested_price: 499.99 - created: "2022-02-22T22:22:22Z" - metadata: null - stores: - - storesItem: - id: amazon - url: https://www.amazon.com/ - - productsItem: - id: xbox_series_x - suggested_price: 499.99 - created: "2022-02-22T22:22:22Z" - metadata: - - key: foo - value: bar - stores: - - storesItem: - id: amazon - url: https://www.amazon.com/ - - storesItem: - id: target - url: https://www.target.com/ + headers: + link: ; rel="first" + edges: + - categoriesItem: + id: video_games + featured: true + products: + edges: + - productsItem: + id: nintendo_switch + suggested_price: 349.99 + created: "2022-02-22T22:22:22Z" + metadata: null + stores: + edges: + - storesItem: + id: target + url: https://www.target.com/ + - productsItem: + id: playstation_ps5 + suggested_price: 499.99 + created: "2022-02-22T22:22:22Z" + metadata: null + stores: + edges: + - storesItem: + id: amazon + url: https://www.amazon.com/ + - productsItem: + id: xbox_series_x + suggested_price: 499.99 + created: "2022-02-22T22:22:22Z" + metadata: + - key: foo + value: bar + stores: + edges: + - storesItem: + id: amazon + url: https://www.amazon.com/ + - storesItem: + id: target + url: https://www.target.com/ `, "\t", " ", -1), w.Body.String()) } From a1d48c41e3216dc6b8d61aa468ae6517781e06dd Mon Sep 17 00:00:00 2001 From: "Daniel G. Taylor" Date: Sat, 26 Feb 2022 23:26:52 -0800 Subject: [PATCH 03/12] feat: always allow some low-level headers in responses --- context.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/context.go b/context.go index 0b059c86..850d758b 100644 --- a/context.go +++ b/context.go @@ -13,6 +13,19 @@ import ( "github.com/goccy/go-yaml" ) +// allowedHeaders is a list of built-in headers that are always allowed without +// explicitly being documented. Mostly they are low-level HTTP headers that +// control access or connection settings. +var allowedHeaders = map[string]bool{ + "access-control-allow-origin": true, + "access-control-allow-methods": true, + "access-control-allow-headers": true, + "access-control-max-age": true, + "connection": true, + "keep-alive": true, + "vary": true, +} + // ContextFromRequest returns a Huma context for a request, useful for // accessing high-level convenience functions from e.g. middleware. func ContextFromRequest(w http.ResponseWriter, r *http.Request) Context { @@ -101,6 +114,10 @@ func (c *hcontext) WriteHeader(status int) { // Check that all headers were allowed to be sent. for name := range c.Header() { + if allowedHeaders[strings.ToLower(name)] { + continue + } + found := false for _, h := range allowed { From 615058be1620304faa81236276669e6118afc969 Mon Sep 17 00:00:00 2001 From: "Daniel G. Taylor" Date: Sun, 27 Feb 2022 10:53:00 -0800 Subject: [PATCH 04/12] fix: better test coverage, fix loop bug --- graphql.go | 241 ++++++++++++++++++++++++----------------------- graphql_model.go | 20 ++-- graphql_test.go | 27 ++++-- 3 files changed, 150 insertions(+), 138 deletions(-) diff --git a/graphql.go b/graphql.go index 909cfe1a..b60248e4 100644 --- a/graphql.go +++ b/graphql.go @@ -85,146 +85,151 @@ func getModel(op *Operation) (reflect.Type, []string, error) { return nil, nil, fmt.Errorf("no model found for %s", op.id) } -func (r *Router) handleResource(config *GraphQLConfig, fields graphql.Fields, resource *Resource, ignoreParams map[string]bool) { - for _, op := range resource.operations { - if op.method != http.MethodGet { - continue - } - - model, headerNames, err := getModel(op) - if err != nil || model == nil { - // This is a GET but returns nothing??? - continue - } +func (r *Router) handleOperation(config *GraphQLConfig, fields graphql.Fields, resource *Resource, op *Operation, ignoreParams map[string]bool) { + model, headerNames, err := getModel(op) + if err != nil || model == nil { + // This is a GET but returns nothing??? + return + } - // `/things` -> `things` - // `/things/{thing-id}` -> `thingsItem(thingId)` - // `/things/{thing-id}/sub` -> `sub(thingId)` - parts := strings.Split(strings.Trim(resource.path, "/"), "/") - last := parts[len(parts)-1] - for i := len(parts) - 1; i >= 0; i-- { - if parts[i][0] == '{' { - if i > 0 { - last = parts[i-1] + "Item" - } - continue + // `/things` -> `things` + // `/things/{thing-id}` -> `thingsItem(thingId)` + // `/things/{thing-id}/sub` -> `sub(thingId)` + parts := strings.Split(strings.Trim(resource.path, "/"), "/") + last := parts[len(parts)-1] + for i := len(parts) - 1; i >= 0; i-- { + if parts[i][0] == '{' { + if i > 0 { + last = parts[i-1] + "Item" } - break + continue } + break + } - // Setup input arguments (i.e. OpenAPI operation params). - args := graphql.FieldConfigArgument{} - argsNameMap := map[string]string{} - for name, param := range op.params { - if ignoreParams[name] || param.Internal { - // This will be handled automatically. - continue - } - jsName := casing.LowerCamel(name) - typ, err := r.generateGraphModel(config, param.typ, "", nil, nil) - if err != nil { - panic(err) - } - argsNameMap[jsName] = name - args[jsName] = &graphql.ArgumentConfig{ - Type: typ, - Description: param.Description, - } + // Setup input arguments (i.e. OpenAPI operation params). + args := graphql.FieldConfigArgument{} + argsNameMap := map[string]string{} + for name, param := range op.params { + if ignoreParams[name] || param.Internal { + // This will be handled automatically. + continue } - - // Convert the Go model to GraphQL Schema. - out, err := r.generateGraphModel(config, model, resource.path, headerNames, ignoreParams) + jsName := casing.LowerCamel(name) + typ, err := r.generateGraphModel(config, param.typ, "", nil, nil) if err != nil { panic(err) } + argsNameMap[jsName] = name + args[jsName] = &graphql.ArgumentConfig{ + Type: typ, + Description: param.Description, + } + } + + // Convert the Go model to GraphQL Schema. + out, err := r.generateGraphModel(config, model, resource.path, headerNames, ignoreParams) + if err != nil { + panic(err) + } - fields[last] = &graphql.Field{ - Type: out, - Description: op.description, - Args: args, - Resolve: func(p graphql.ResolveParams) (interface{}, error) { - // Fetch and populate this resource from the underlying REST API. - headers := p.Context.Value(graphKeyHeaders).(http.Header).Clone() - path := resource.path - queryParams := map[string]interface{}{} - - // Handle pre-filled args, then passed args - params := map[string]interface{}{} - if p.Source != nil { - if m, ok := p.Source.(map[string]interface{}); ok { - if m["__params"] != nil { - params = m["__params"].(map[string]interface{}) - for k, v := range params { - path = strings.Replace(path, "{"+k+"}", fmt.Sprintf("%v", v), 1) - } + fields[last] = &graphql.Field{ + Type: out, + Description: op.description, + Args: args, + Resolve: func(p graphql.ResolveParams) (interface{}, error) { + // Fetch and populate this resource from the underlying REST API. + headers := p.Context.Value(graphKeyHeaders).(http.Header).Clone() + path := resource.path + queryParams := map[string]interface{}{} + + // Handle pre-filled args, then passed args + params := map[string]interface{}{} + if p.Source != nil { + if m, ok := p.Source.(map[string]interface{}); ok { + if m["__params"] != nil { + params = m["__params"].(map[string]interface{}) + for k, v := range params { + path = strings.Replace(path, "{"+k+"}", fmt.Sprintf("%v", v), 1) } } } + } - for arg := range p.Args { - // Passed args get saved for later use. - params[argsNameMap[arg]] = p.Args[arg] - - // Apply the arg to the request. - param := op.params[argsNameMap[arg]] - if param.In == inPath { - path = strings.Replace(path, "{"+argsNameMap[arg]+"}", fmt.Sprintf("%v", p.Args[arg]), 1) - } else if param.In == inQuery { - queryParams[argsNameMap[arg]] = p.Args[arg] - } else if param.In == inHeader { - headers.Set(argsNameMap[arg], fmt.Sprintf("%v", p.Args[arg])) - } + for arg := range p.Args { + // Passed args get saved for later use. + params[argsNameMap[arg]] = p.Args[arg] + + // Apply the arg to the request. + param := op.params[argsNameMap[arg]] + fmt.Println(arg, argsNameMap[arg], op.params, param, param.Name, param.In) + if param.In == inPath { + path = strings.Replace(path, "{"+argsNameMap[arg]+"}", fmt.Sprintf("%v", p.Args[arg]), 1) + } else if param.In == inQuery { + queryParams[argsNameMap[arg]] = p.Args[arg] + } else if param.In == inHeader { + headers.Set(argsNameMap[arg], fmt.Sprintf("%v", p.Args[arg])) } + } - result, respHeader, err := r.fetch(headers, path, queryParams) - if err != nil { - return nil, err - } + result, respHeader, err := r.fetch(headers, path, queryParams) + if err != nil { + return nil, err + } - // Create a simple map of header name to header value. - headerMap := map[string]string{} - for headerName := range respHeader { - headerMap[casing.LowerCamel(strings.ToLower(headerName))] = respHeader.Get(headerName) - } + // Create a simple map of header name to header value. + headerMap := map[string]string{} + for headerName := range respHeader { + headerMap[casing.LowerCamel(strings.ToLower(headerName))] = respHeader.Get(headerName) + } - paramMap := config.paramMappings[resource.path] + paramMap := config.paramMappings[resource.path] - if m, ok := result.(map[string]interface{}); ok { - // Save params for child requests to use. By putting this into the - // response object but not into the GraphQL schema it ensures that - // downstream resolvers can access it but it never gets sent to the - // client as part of a response. - newParams := map[string]interface{}{} - for k, v := range params { - newParams[k] = v - } - for paramName, fieldName := range paramMap { - newParams[paramName] = m[fieldName] - } - m["__params"] = newParams - m["headers"] = headerMap - } else if s, ok := result.([]interface{}); ok { - // Since this is a list, we set params on each item. - for _, item := range s { - if m, ok := item.(map[string]interface{}); ok { - newParams := map[string]interface{}{} - for k, v := range params { - newParams[k] = v - } - for paramName, fieldName := range paramMap { - newParams[paramName] = m[fieldName] - } - m["__params"] = newParams + if m, ok := result.(map[string]interface{}); ok { + // Save params for child requests to use. By putting this into the + // response object but not into the GraphQL schema it ensures that + // downstream resolvers can access it but it never gets sent to the + // client as part of a response. + newParams := map[string]interface{}{} + for k, v := range params { + newParams[k] = v + } + for paramName, fieldName := range paramMap { + newParams[paramName] = m[fieldName] + } + m["__params"] = newParams + m["headers"] = headerMap + } else if s, ok := result.([]interface{}); ok { + // Since this is a list, we set params on each item. + for _, item := range s { + if m, ok := item.(map[string]interface{}); ok { + newParams := map[string]interface{}{} + for k, v := range params { + newParams[k] = v } + for paramName, fieldName := range paramMap { + newParams[paramName] = m[fieldName] + } + m["__params"] = newParams } - result = map[string]interface{}{ - "edges": s, - "headers": headerMap, - } } - return result, nil - }, + result = map[string]interface{}{ + "edges": s, + "headers": headerMap, + } + } + return result, nil + }, + } +} + +func (r *Router) handleResource(config *GraphQLConfig, fields graphql.Fields, resource *Resource, ignoreParams map[string]bool) { + for _, op := range resource.operations { + if op.method != http.MethodGet { + continue } + + r.handleOperation(config, fields, resource, op, ignoreParams) } } diff --git a/graphql_model.go b/graphql_model.go index e7fe6400..51c820e6 100644 --- a/graphql_model.go +++ b/graphql_model.go @@ -2,8 +2,6 @@ package huma import ( "fmt" - "net" - "net/url" "reflect" "strings" "time" @@ -12,11 +10,6 @@ import ( "github.com/graphql-go/graphql" ) -var ( - ipType = reflect.TypeOf(net.IP{}) - uriType = reflect.TypeOf(url.URL{}) -) - // getFields performs a breadth-first search for all fields including embedded // ones. It may return multiple fields with the same name, the first of which // represents the outer-most declaration. @@ -66,18 +59,12 @@ func addHeaderFields(name string, fields graphql.Fields, headerNames []string) { // generateGraphModel converts a Go type to GraphQL Schema. func (r *Router) generateGraphModel(config *GraphQLConfig, t reflect.Type, urlTemplate string, headerNames []string, ignoreParams map[string]bool) (graphql.Output, error) { - if t == ipType { - return graphql.String, nil - } - switch t.Kind() { case reflect.Struct: // Handle special cases. switch t { case timeType: return graphql.DateTime, nil - case uriType: - return graphql.String, nil } if config.known[t.String()] != nil { @@ -101,10 +88,10 @@ func (r *Router) generateGraphModel(config *GraphQLConfig, t reflect.Type, urlTe } out, err := r.generateGraphModel(config, f.Type, "", nil, ignoreParams) - if err != nil { return nil, err } + if name != "" { fields[name] = &graphql.Field{ Name: name, @@ -174,6 +161,11 @@ func (r *Router) generateGraphModel(config *GraphQLConfig, t reflect.Type, urlTe addHeaderFields(t.String(), fields, headerNames) if len(fields) == 0 { + // JSON supports empty object (e.g. for future expansion) but GraphQL + // does not, so here we add a dummy value that can be used in the query + // and will always return `null`. The presense of this field being + // null vs the containing object being `null` lets you know if the JSON + // empty object was present or not. fields["_"] = &graphql.Field{ Type: graphql.Boolean, } diff --git a/graphql_test.go b/graphql_test.go index f451fdf0..7143057d 100644 --- a/graphql_test.go +++ b/graphql_test.go @@ -37,8 +37,9 @@ type ProductSummary struct { type Product struct { ProductSummary SuggestedPrice float32 `json:"suggested_price"` - Created time.Time `json:"created" doc:"When this product was created"` + Created *time.Time `json:"created,omitempty" doc:"When this product was created"` Metadata map[string]string `json:"metadata,omitempty" doc:"Additional information about the product"` + Empty *struct{} `json:"empty"` stores map[string]*Store `json:"-"` } @@ -58,9 +59,9 @@ func TestGraphQL(t *testing.T) { amazon := &Store{StoreSummary: StoreSummary{ID: "amazon"}, URL: "https://www.amazon.com/"} target := &Store{StoreSummary: StoreSummary{ID: "target"}, URL: "https://www.target.com/"} - xsx := &Product{ProductSummary: ProductSummary{ID: "xbox_series_x"}, SuggestedPrice: 499.99, Created: now, Metadata: map[string]string{"foo": "bar"}, stores: map[string]*Store{"amazon": amazon, "target": target}} - ps5 := &Product{ProductSummary: ProductSummary{ID: "playstation_ps5"}, SuggestedPrice: 499.99, Created: now, stores: map[string]*Store{"amazon": amazon}} - ns := &Product{ProductSummary: ProductSummary{ID: "nintendo_switch"}, SuggestedPrice: 349.99, Created: now, stores: map[string]*Store{"target": target}} + xsx := &Product{ProductSummary: ProductSummary{ID: "xbox_series_x"}, SuggestedPrice: 499.99, Created: &now, Metadata: map[string]string{"foo": "bar"}, stores: map[string]*Store{"amazon": amazon, "target": target}, Empty: &struct{}{}} + ps5 := &Product{ProductSummary: ProductSummary{ID: "playstation_ps5"}, SuggestedPrice: 499.99, Created: &now, stores: map[string]*Store{"amazon": amazon}} + ns := &Product{ProductSummary: ProductSummary{ID: "nintendo_switch"}, SuggestedPrice: 349.99, stores: map[string]*Store{"target": target}} videoGames := &Category{ CategorySummary: CategorySummary{ID: "video_games"}, @@ -78,7 +79,8 @@ func TestGraphQL(t *testing.T) { app := newTestRouter() - app.Resource("/categories").Get("get-categories", "doc", + categoriesResource := app.Resource("/categories") + categoriesResource.Get("get-categories", "doc", NewResponse(http.StatusOK, "").Model([]CategorySummary{}).Headers("link"), ).Run(func(ctx Context, input struct { Limit int `query:"limit" default:"10"` @@ -100,6 +102,12 @@ func TestGraphQL(t *testing.T) { ctx.WriteModel(http.StatusOK, summaries[:input.Limit]) }) + categoriesResource.Delete("delete-category", "doc", + NewResponse(http.StatusNoContent, ""), + ).Run(func(ctx Context) { + ctx.WriteHeader(http.StatusNoContent) + }) + app.Resource("/categories/{category-id}").Get("get-category", "doc", NewResponse(http.StatusOK, "").Model(&Category{}), NewResponse(http.StatusNotFound, "").Model(&ErrorModel{}), @@ -204,6 +212,9 @@ func TestGraphQL(t *testing.T) { key value } + empty { + _ + } stores { edges { storesItem { @@ -239,8 +250,9 @@ data: - productsItem: id: nintendo_switch suggested_price: 349.99 - created: "2022-02-22T22:22:22Z" + created: null metadata: null + empty: null stores: edges: - storesItem: @@ -251,6 +263,7 @@ data: suggested_price: 499.99 created: "2022-02-22T22:22:22Z" metadata: null + empty: null stores: edges: - storesItem: @@ -263,6 +276,8 @@ data: metadata: - key: foo value: bar + empty: + _: null stores: edges: - storesItem: From 3a0d6a12d28c17420a9d2a183b9ab591b35b7eba Mon Sep 17 00:00:00 2001 From: "Daniel G. Taylor" Date: Sun, 27 Feb 2022 12:47:51 -0800 Subject: [PATCH 05/12] fix: sub-resource collection bug --- graphql.go | 8 +++++--- graphql_test.go | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/graphql.go b/graphql.go index b60248e4..515fc414 100644 --- a/graphql.go +++ b/graphql.go @@ -45,11 +45,13 @@ type GraphQLConfig struct { // allResources recursively finds all resource and sub-resources and adds them // to the `result` slice. -func allResources(result []*Resource, r *Resource) { +func allResources(r *Resource) []*Resource { + result := []*Resource{} for _, sub := range r.subResources { result = append(result, sub) - allResources(result, sub) + result = append(result, allResources(sub)...) } + return result } // fetch from a Huma router. Returns the parsed JSON. @@ -245,7 +247,7 @@ func (r *Router) EnableGraphQL(config *GraphQLConfig) { resources := []*Resource{} for _, resource := range r.resources { resources = append(resources, resource) - allResources(resources, resource) + resources = append(resources, allResources(resource)...) } sort.Slice(resources, func(i, j int) bool { return len(resources[i].path) < len(resources[j].path) diff --git a/graphql_test.go b/graphql_test.go index 7143057d..1f7bd574 100644 --- a/graphql_test.go +++ b/graphql_test.go @@ -108,7 +108,7 @@ func TestGraphQL(t *testing.T) { ctx.WriteHeader(http.StatusNoContent) }) - app.Resource("/categories/{category-id}").Get("get-category", "doc", + categoriesResource.SubResource("/{category-id}").Get("get-category", "doc", NewResponse(http.StatusOK, "").Model(&Category{}), NewResponse(http.StatusNotFound, "").Model(&ErrorModel{}), ).Run(func(ctx Context, input struct { From 5c902b0527258b61da72d6005bf0cd9307ce55ce Mon Sep 17 00:00:00 2001 From: "Daniel G. Taylor" Date: Sun, 27 Feb 2022 13:19:02 -0800 Subject: [PATCH 06/12] docs: GraphQL README docs updates --- README.md | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 243378cc..f6837af2 100644 --- a/README.md +++ b/README.md @@ -843,7 +843,7 @@ app.Resource("/notes/{note-id}").Get("get-note", "docs", }) ``` -You would map the `/notes` response to the `/notes/{note-id}` request with a tag on the response struct's field: +You would map the `/notes` response to the `/notes/{note-id}` request with a `graphParam` tag on the response struct's field that tells Huma that the `note-id` parameter in URLs can be loaded directly from the `id` field of the response object. ```go type NoteSummary struct { @@ -859,6 +859,29 @@ See the `graphql_test.go` file for a full-fledged example. > :whale: Note that because Huma knows nothing about your database, there is no way to make efficient queries to only select the fields that were requested. This GraphQL layer works by making normal HTTP requests to your service as needed to fulfill the query. Even with that caveat it can greatly simplify and speed up frontend requests. +### 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: + +``` +{ + "edges": [... your responses here...], + "headers": { + "headerName": "headerValue" + } +} +``` + +### Custom GraphQL Path + +You can set a custom path for the GraphQL endpoint: + +```go +app.EnableGraphQL(&huma.GraphQLConfig{ + Path: "/graphql", +}) +``` + ### Enabling the GraphiQL UI You can turn on a UI for writing and making queries with schema documentation via the GraphQL config: @@ -869,7 +892,7 @@ app.EnableGraphQL(&huma.GraphQLConfig{ }) ``` -It is [recommended](https://graphql.org/learn/serving-over-http/#graphiql) to turn GraphiQL off in production. Instead a tool like [graphqurl](https://github.com/hasura/graphqurl) can be useful for using GraphiQL in production on the client side, and it supports custom headers for e.g. auth. +It is [recommended](https://graphql.org/learn/serving-over-http/#graphiql) to turn GraphiQL off in production. Instead a tool like [graphqurl](https://github.com/hasura/graphqurl) can be useful for using GraphiQL in production on the client side, and it supports custom headers for e.g. auth. Don't forget to enable CORS via e.g. [`rs/cors`](https://github.com/rs/cors) so browsers allow access. ## CLI Runtime Arguments & Configuration From d8c8e0b8518a4247ab7a9d034b6c05c1a62414d8 Mon Sep 17 00:00:00 2001 From: "Daniel G. Taylor" Date: Sun, 27 Feb 2022 20:07:08 -0800 Subject: [PATCH 07/12] fix: docs updates / fixes, test for []byte --- README.md | 4 +++- graphql_model.go | 10 ++++++++-- graphql_test.go | 6 +++++- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f6837af2..57e1963c 100644 --- a/README.md +++ b/README.md @@ -853,7 +853,7 @@ type NoteSummary struct { Whenever a list of items is returned, you can access the detailed item via the name+"Item", e.g. `notesItem` would return the `get-note` response. -Then you can make requests against the service like `http://localhost:8888/graphql?query={notes{id%20notesItem{contents}}}`. +Then you can make requests against the service like `http://localhost:8888/graphql?query={notes{edges{id%20notesItem{contents}}}}`. See the `graphql_test.go` file for a full-fledged example. @@ -872,6 +872,8 @@ HTTP responses may be lists, such as the `list-notes` example operation above. S } ``` +This data structure can be considered experimental and may change in the future based on feedback. + ### Custom GraphQL Path You can set a custom path for the GraphQL endpoint: diff --git a/graphql_model.go b/graphql_model.go index 51c820e6..dad5e6bb 100644 --- a/graphql_model.go +++ b/graphql_model.go @@ -57,7 +57,9 @@ func addHeaderFields(name string, fields graphql.Fields, headerNames []string) { } } -// generateGraphModel converts a Go type to GraphQL Schema. +// 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) { switch t.Kind() { case reflect.Struct: @@ -132,17 +134,21 @@ func (r *Router) generateGraphModel(config *GraphQLConfig, t reflect.Type, urlTe } } + // Store the parameter mappings for later use in resolver functions. config.paramMappings[urlTemplate] = paramMap for k := range paramMap { ignoreParams[k] = true } if urlTemplate != "" { + // The presence of a template means this is a resource. Try and find + // all child resources. for _, resource := range config.resources { if len(resource.path) > len(urlTemplate) { // This could be a child resource. Let's find the longest prefix match // among all the resources and if that value matches the current - // resources's URL template then this is a direct child. + // resources's URL template then this is a direct child, even if + // it spans multiple URL path components or arguments. var best *Resource for _, sub := range config.resources { if len(resource.path) > len(sub.path) && strings.HasPrefix(resource.path, sub.path) { diff --git a/graphql_test.go b/graphql_test.go index 1f7bd574..cddda795 100644 --- a/graphql_test.go +++ b/graphql_test.go @@ -21,7 +21,8 @@ type CategorySummary struct { type Category struct { CategorySummary - Featured bool `json:"featured" doc:"Display as featured in the app"` + Featured bool `json:"featured" doc:"Display as featured in the app"` + Code []byte `json:"code" doc:"Category code"` products map[string]*Product `json:"-"` } @@ -66,6 +67,7 @@ func TestGraphQL(t *testing.T) { videoGames := &Category{ CategorySummary: CategorySummary{ID: "video_games"}, Featured: true, + Code: []byte{'h', 'i'}, products: map[string]*Product{ "xbox_series_x": xsx, "playstation_ps5": ps5, @@ -202,6 +204,7 @@ func TestGraphQL(t *testing.T) { categoriesItem { id featured + code products { edges { productsItem { @@ -245,6 +248,7 @@ data: - categoriesItem: id: video_games featured: true + code: aGk= products: edges: - productsItem: From b4a81c0a46309752c702a22749e7dc8051abf16f Mon Sep 17 00:00:00 2001 From: "Daniel G. Taylor" Date: Sun, 27 Feb 2022 23:11:45 -0800 Subject: [PATCH 08/12] feat: resolve graphql fetches concurrently --- graphql.go | 101 +++++++++++++++++++++++++++++++---------------------- 1 file changed, 60 insertions(+), 41 deletions(-) diff --git a/graphql.go b/graphql.go index 515fc414..59b37eaa 100644 --- a/graphql.go +++ b/graphql.go @@ -174,53 +174,72 @@ func (r *Router) handleOperation(config *GraphQLConfig, fields graphql.Fields, r } } - result, respHeader, err := r.fetch(headers, path, queryParams) - if err != nil { - return nil, err - } + // Fire off the request but don't wait for the response. Instead, we + // return a "thunk" which is a function to be resolved later (like a js + // Promise) which GraphQL resolves *after* visiting all fields in + // breadth-first order. This ensures we kick off all the requests in + // parallel but then wait for all the results until processing deeper + // into the query. + // See also https://github.com/graphql-go/graphql/pull/388. + done := make(chan bool) + var result interface{} + var respHeader http.Header + go func() { + result, respHeader, err = r.fetch(headers, path, queryParams) + done <- true + }() + + return func() (interface{}, error) { + // Wait for request goroutine to complete. Since it's done async we + // have to handle the errors here, not in the goroutine above. + <-done + if err != nil { + return nil, err + } - // Create a simple map of header name to header value. - headerMap := map[string]string{} - for headerName := range respHeader { - headerMap[casing.LowerCamel(strings.ToLower(headerName))] = respHeader.Get(headerName) - } + // Create a simple map of header name to header value. + headerMap := map[string]string{} + for headerName := range respHeader { + headerMap[casing.LowerCamel(strings.ToLower(headerName))] = respHeader.Get(headerName) + } - paramMap := config.paramMappings[resource.path] + paramMap := config.paramMappings[resource.path] - if m, ok := result.(map[string]interface{}); ok { - // Save params for child requests to use. By putting this into the - // response object but not into the GraphQL schema it ensures that - // downstream resolvers can access it but it never gets sent to the - // client as part of a response. - newParams := map[string]interface{}{} - for k, v := range params { - newParams[k] = v - } - for paramName, fieldName := range paramMap { - newParams[paramName] = m[fieldName] - } - m["__params"] = newParams - m["headers"] = headerMap - } else if s, ok := result.([]interface{}); ok { - // Since this is a list, we set params on each item. - for _, item := range s { - if m, ok := item.(map[string]interface{}); ok { - newParams := map[string]interface{}{} - for k, v := range params { - newParams[k] = v - } - for paramName, fieldName := range paramMap { - newParams[paramName] = m[fieldName] + if m, ok := result.(map[string]interface{}); ok { + // Save params for child requests to use. By putting this into the + // response object but not into the GraphQL schema it ensures that + // downstream resolvers can access it but it never gets sent to the + // client as part of a response. + newParams := map[string]interface{}{} + for k, v := range params { + newParams[k] = v + } + for paramName, fieldName := range paramMap { + newParams[paramName] = m[fieldName] + } + m["__params"] = newParams + m["headers"] = headerMap + } else if s, ok := result.([]interface{}); ok { + // Since this is a list, we set params on each item. + for _, item := range s { + if m, ok := item.(map[string]interface{}); ok { + newParams := map[string]interface{}{} + for k, v := range params { + newParams[k] = v + } + for paramName, fieldName := range paramMap { + newParams[paramName] = m[fieldName] + } + m["__params"] = newParams } - m["__params"] = newParams + } + result = map[string]interface{}{ + "edges": s, + "headers": headerMap, } } - result = map[string]interface{}{ - "edges": s, - "headers": headerMap, - } - } - return result, nil + return result, nil + }, nil }, } } From 4e87c096122f71742636e89744fcc6c412b3a6df Mon Sep 17 00:00:00 2001 From: "Daniel G. Taylor" Date: Tue, 1 Mar 2022 20:37:46 -0800 Subject: [PATCH 09/12] feat: graphql cost analysis and complexity limits --- README.md | 53 +++++++++++++++++++++++++++++ go.mod | 1 + go.sum | 3 ++ graphql.go | 87 ++++++++++++++++++++++++++++++++++++++++++++---- graphql_model.go | 5 +-- graphql_test.go | 4 ++- 6 files changed, 143 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 57e1963c..b6ae7319 100644 --- a/README.md +++ b/README.md @@ -896,6 +896,59 @@ app.EnableGraphQL(&huma.GraphQLConfig{ It is [recommended](https://graphql.org/learn/serving-over-http/#graphiql) to turn GraphiQL off in production. Instead a tool like [graphqurl](https://github.com/hasura/graphqurl) can be useful for using GraphiQL in production on the client side, and it supports custom headers for e.g. auth. Don't forget to enable CORS via e.g. [`rs/cors`](https://github.com/rs/cors) so browsers allow access. +### GraphQL Query Complexity Limits + +You can limit the maximum query complexity your server allows: + +```go +app.EnableGraphQL(&huma.GraphQLConfig{ + ComplexityLimit: 250, +}) +``` + +Complexity is a rough measure of the request load against your service and is calculated as the following: + +| Field Type | Complexity | +| -------------------------------- | ---------------------------------: | +| Enum | 0 | +| Scalar (e.g. int, float, string) | 0 | +| Plain array / object | 0 | +| Resource object | 1 | +| Array of resources | count + (childComplexity \* count) | + +`childComplexity` is the total complexity of any child selectors and the `count` is determined by passed in parameters like `first`, `last`, `count`, `limit`, `records`, or `pageSize` with a built-in default multiplier of `10`. + +If a single resource is a child of a list, then the resource's complexity is also multiplied by the number of resources. This means nested queries that make list calls get very expensive fast. For example: + +``` +{ + categories(first: 10) { + edges { + catgoriesItem { + products(first: 10) { + edges { + productsItem { + id + price + } + } + } + } + } + } +} +``` + +Because you are fetching up to 10 categories, and for each of those fetching a `categoriesItem` object and up to 10 products within each category, then a `productsItem` for each product, this results in: + +``` +Calculation: +(((1 producstItem * 10 products) + 10 products) + 1 categoriesItem) * 10 categories + 10 categories + +Result: +220 complexity +``` + ## CLI Runtime Arguments & Configuration The CLI can be configured in multiple ways. In order of decreasing precedence: diff --git a/go.mod b/go.mod index 0fe48268..35bcaace 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( 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/koron-go/gqlcost v0.2.2 github.com/magiconair/properties v1.8.2 // indirect github.com/mattn/go-isatty v0.0.12 github.com/mitchellh/mapstructure v1.3.3 // indirect diff --git a/go.sum b/go.sum index 1537939e..3bff6208 100644 --- a/go.sum +++ b/go.sum @@ -95,6 +95,7 @@ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGa github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/graphql-go/graphql v0.7.9/go.mod h1:k6yrAYQaSP59DC5UVxbgxESlmVyojThKdORUqGDGmrI= github.com/graphql-go/graphql v0.8.0 h1:JHRQMeQjofwqVvGwYnr8JnPTY0AxgVy1HpHSGPLdH0I= github.com/graphql-go/graphql v0.8.0/go.mod h1:nKiHzRM0qopJEwCITUuIsxk9PlVlwIiiI8pnJEhordQ= github.com/graphql-go/handler v0.2.3 h1:CANh8WPnl5M9uA25c2GBhPqJhE53Fg0Iue/fRNla71E= @@ -135,6 +136,8 @@ github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7V github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/koron-go/gqlcost v0.2.2 h1:f5Avjia6Vv2I0FDBiB/TSF3nrqdtKI8xaNfizT0lW5w= +github.com/koron-go/gqlcost v0.2.2/go.mod h1:8ZAmWla8nXCH0lBTxMZ+gbvgHhCCvTX3V4pEkC3obQA= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= diff --git a/graphql.go b/graphql.go index 59b37eaa..907a7a98 100644 --- a/graphql.go +++ b/graphql.go @@ -13,6 +13,7 @@ import ( "github.com/danielgtaylor/casing" "github.com/graphql-go/graphql" "github.com/graphql-go/handler" + "github.com/koron-go/gqlcost" ) type graphContextKey string @@ -26,6 +27,11 @@ type GraphQLConfig struct { // GraphiQL sets whether the UI is available at the path. Defaults to off. GraphiQL bool + // ComplexityLimit sets the maximum allowed complexity, which is calculated + // as 1 for each field and 2 + (n * child) for each array with n children + // created from sub-resource requests. + ComplexityLimit int + // 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. @@ -41,6 +47,10 @@ type GraphQLConfig struct { // `/items/{item-id}/prices`. These mappings are configured by putting a // tag `graphParam` on your go struct fields. paramMappings map[string]map[string]string + + // 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 } // allResources recursively finds all resource and sub-resources and adds them @@ -87,7 +97,56 @@ func getModel(op *Operation) (reflect.Type, []string, error) { return nil, nil, fmt.Errorf("no model found for %s", op.id) } -func (r *Router) handleOperation(config *GraphQLConfig, fields graphql.Fields, resource *Resource, op *Operation, ignoreParams map[string]bool) { +// caluclateComplexity will populate the cost map whenever a resource request +// is made for a field. If the request returns a list and has a count-limiting +// argument, then that is used as a multiplier for downstream values. +func calculateComplexity(config *GraphQLConfig, parentName string, model reflect.Type, out graphql.Output, fieldName string) { + if config.costMap[parentName].Fields == nil { + config.costMap[parentName] = gqlcost.TypeCost{ + Fields: gqlcost.FieldsCost{}, + } + } + + // All resources have a cost associated with fetching them. Always set + // `useMultipliers` as that controls whether or not to apply parent + // multipliers to the current field complexity value. + cost := gqlcost.Cost{ + Complexity: 1, + UseMultipliers: true, + } + if model.Kind() == reflect.Slice && strings.HasSuffix(out.Name(), "Collection") { + // This is an array and we need to multiply by the number of items requested. + cost.MultiplierFunc = func(m map[string]interface{}) int { + // Try to get the max number of items requested from various well-known + // argument names. + result := 0 + found := false + for _, arg := range []string{"first", "last", "limit", "count", "pageSize", "records"} { + if _, ok := m[arg]; ok { + v := reflect.ValueOf(m[arg]) + switch v.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + result += int(v.Int()) + found = true + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + result += int(v.Uint()) + found = true + } + } + } + + if found { + return result + } + + // No idea how many items will get returned, so we default to 10. + return 10 + } + } + config.costMap[parentName].Fields[fieldName] = cost +} + +func (r *Router) handleOperation(config *GraphQLConfig, parentName string, fields graphql.Fields, resource *Resource, op *Operation, ignoreParams map[string]bool) { model, headerNames, err := getModel(op) if err != nil || model == nil { // This is a GET but returns nothing??? @@ -122,10 +181,15 @@ func (r *Router) handleOperation(config *GraphQLConfig, fields graphql.Fields, r if err != nil { panic(err) } + var def interface{} + if param.Schema != nil { + def = param.Schema.Default + } argsNameMap[jsName] = name args[jsName] = &graphql.ArgumentConfig{ - Type: typ, - Description: param.Description, + Type: typ, + Description: param.Description, + DefaultValue: def, } } @@ -135,6 +199,8 @@ func (r *Router) handleOperation(config *GraphQLConfig, fields graphql.Fields, r panic(err) } + calculateComplexity(config, parentName, model, out, last) + fields[last] = &graphql.Field{ Type: out, Description: op.description, @@ -164,7 +230,6 @@ func (r *Router) handleOperation(config *GraphQLConfig, fields graphql.Fields, r // Apply the arg to the request. param := op.params[argsNameMap[arg]] - fmt.Println(arg, argsNameMap[arg], op.params, param, param.Name, param.In) if param.In == inPath { path = strings.Replace(path, "{"+argsNameMap[arg]+"}", fmt.Sprintf("%v", p.Args[arg]), 1) } else if param.In == inQuery { @@ -244,13 +309,13 @@ func (r *Router) handleOperation(config *GraphQLConfig, fields graphql.Fields, r } } -func (r *Router) handleResource(config *GraphQLConfig, fields graphql.Fields, resource *Resource, ignoreParams map[string]bool) { +func (r *Router) handleResource(config *GraphQLConfig, parentName string, fields graphql.Fields, resource *Resource, ignoreParams map[string]bool) { for _, op := range resource.operations { if op.method != http.MethodGet { continue } - r.handleOperation(config, fields, resource, op, ignoreParams) + r.handleOperation(config, parentName, fields, resource, op, ignoreParams) } } @@ -278,9 +343,10 @@ func (r *Router) EnableGraphQL(config *GraphQLConfig) { config.known = map[string]graphql.Output{} config.resources = resources config.paramMappings = map[string]map[string]string{} + config.costMap = gqlcost.CostMap{} for _, resource := range resources { - r.handleResource(config, fields, resource, map[string]bool{}) + r.handleResource(config, "Query", fields, resource, map[string]bool{}) } root := graphql.ObjectConfig{Name: "Query", Fields: fields} @@ -290,6 +356,13 @@ func (r *Router) EnableGraphQL(config *GraphQLConfig) { panic(err) } + if config.ComplexityLimit > 0 { + gqlcost.AddCostAnalysisRule(gqlcost.AnalysisOptions{ + MaximumCost: config.ComplexityLimit, + CostMap: config.costMap, + }) + } + h := handler.New(&handler.Config{ Schema: &schema, Pretty: true, diff --git a/graphql_model.go b/graphql_model.go index dad5e6bb..9ceccbb6 100644 --- a/graphql_model.go +++ b/graphql_model.go @@ -73,6 +73,7 @@ func (r *Router) generateGraphModel(config *GraphQLConfig, t reflect.Type, urlTe return config.known[t.String()], nil } + objectName := casing.Camel(strings.Replace(t.String(), ".", " ", -1)) fields := graphql.Fields{} paramMap := map[string]string{} @@ -158,7 +159,7 @@ func (r *Router) generateGraphModel(config *GraphQLConfig, t reflect.Type, urlTe } } if best != nil && best.path == urlTemplate { - r.handleResource(config, fields, resource, ignoreParams) + r.handleResource(config, objectName, fields, resource, ignoreParams) } } } @@ -178,7 +179,7 @@ func (r *Router) generateGraphModel(config *GraphQLConfig, t reflect.Type, urlTe } out := graphql.NewObject(graphql.ObjectConfig{ - Name: casing.Camel(strings.Replace(t.String(), ".", " ", -1)), + Name: objectName, Fields: fields, }) config.known[t.String()] = out diff --git a/graphql_test.go b/graphql_test.go index cddda795..7cb7a41a 100644 --- a/graphql_test.go +++ b/graphql_test.go @@ -193,7 +193,9 @@ func TestGraphQL(t *testing.T) { ctx.WriteModel(http.StatusOK, categories[input.CategoryID].products[input.ProductID].stores[input.StoreID]) }) - app.EnableGraphQL(nil) + app.EnableGraphQL(&GraphQLConfig{ + ComplexityLimit: 250, + }) query := strings.Replace(strings.Replace(`{ categories(limit: 1) { From 31a4f4b51028f846a4e4d051049e2deff654dcae Mon Sep 17 00:00:00 2001 From: "Daniel G. Taylor" Date: Tue, 1 Mar 2022 21:30:41 -0800 Subject: [PATCH 10/12] fix: make path param arguments required --- graphql.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/graphql.go b/graphql.go index 907a7a98..8c9aa22b 100644 --- a/graphql.go +++ b/graphql.go @@ -181,6 +181,9 @@ func (r *Router) handleOperation(config *GraphQLConfig, parentName string, field if err != nil { panic(err) } + if param.In == inPath { + typ = graphql.NewNonNull(typ) + } var def interface{} if param.Schema != nil { def = param.Schema.Default From 75cb6c52291a9d1aa5180923550d1a27a850bb96 Mon Sep 17 00:00:00 2001 From: "Daniel G. Taylor" Date: Thu, 3 Mar 2022 21:58:31 -0800 Subject: [PATCH 11/12] 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 b6ae7319..3b4a23cf 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 35bcaace..067d6d70 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 3bff6208..ac96e8e1 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 8c9aa22b..dadb7afc 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 9ceccbb6..5d5e50a9 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 00000000..11b671fb --- /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 7cb7a41a..8aeca9b3 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 From 33ddff3ce87cd887c5e712fb968df835506e7dd3 Mon Sep 17 00:00:00 2001 From: "Daniel G. Taylor" Date: Fri, 4 Mar 2022 10:20:53 -0800 Subject: [PATCH 12/12] fix: race and limit per-request concurrency --- graphql.go | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/graphql.go b/graphql.go index dadb7afc..1c923230 100644 --- a/graphql.go +++ b/graphql.go @@ -20,6 +20,7 @@ import ( type graphContextKey string var graphKeyHeaders graphContextKey = "headers" +var graphKeySem graphContextKey = "sem" type GraphQLConfig struct { // Path where the GraphQL endpoint is available. Defaults to `/graphql`. @@ -252,6 +253,11 @@ func (r *Router) handleOperation(config *GraphQLConfig, parentName string, field } } + // Use a per-request semaphore to limit the number of concurrent + // goroutines used to fetch data to satisfy that request. + sem := p.Context.Value(graphKeySem).(chan int) + sem <- 1 + // Fire off the request but don't wait for the response. Instead, we // return a "thunk" which is a function to be resolved later (like a js // Promise) which GraphQL resolves *after* visiting all fields in @@ -259,12 +265,14 @@ func (r *Router) handleOperation(config *GraphQLConfig, parentName string, field // parallel but then wait for all the results until processing deeper // into the query. // See also https://github.com/graphql-go/graphql/pull/388. - done := make(chan bool) + done := make(chan bool, 1) var result interface{} var respHeader http.Header + var err error go func() { - result, respHeader, err = r.fetch(headers, path, queryParams) + result, respHeader, err = r.fetch(headers.Clone(), path, queryParams) done <- true + <-sem }() return func() (interface{}, error) { @@ -392,7 +400,9 @@ func (r *Router) EnableGraphQL(config *GraphQLConfig) { r.mux.HandleFunc(config.Path, func(w http.ResponseWriter, r *http.Request) { // Save the headers for future requests as they can contain important // information. - r = r.WithContext(context.WithValue(r.Context(), graphKeyHeaders, r.Header)) + c := context.WithValue(r.Context(), graphKeyHeaders, r.Header) + c = context.WithValue(c, graphKeySem, make(chan int, 10)) + r = r.WithContext(c) h.ServeHTTP(w, r) }) }