Repository: danielgtaylor/huma
Pull request #27 (2022-02-27 07:32:31)

Add Read-Only GraphQL Support
This PR enables the use of an optional read-only GraphQL endpoint and introduces a `graphParam` struct field tag to facilitate field to parameter mappings for nested resources. See the README and test for more info & an example.


Patch:
diff --git a/README.md b/README.md
index 87cc80d..3b4a23c 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,166 @@ 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().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...
+})
+
+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 `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 {
+	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{edges{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.
+
+### 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 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"
+	}
+}
+```
+
+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
+
+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:
+
+```go
+app.EnableGraphQL(&huma.GraphQLConfig{
+	GraphiQL: true,
+})
+```
+
+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/context.go b/context.go
index 0b059c8..850d758 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 {
diff --git a/go.mod b/go.mod
index fc06c5a..067d6d7 100644
--- a/go.mod
+++ b/go.mod
@@ -5,12 +5,16 @@ go 1.13
 require (
 	github.com/Jeffail/gabs/v2 v2.6.0
 	github.com/andybalholm/brotli v1.0.0
-	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
+	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
 	github.com/mitchellh/mapstructure v1.3.3 // indirect
@@ -22,11 +26,13 @@ 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/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 424c0b4..ac96e8e 100644
--- a/go.sum
+++ b/go.sum
@@ -39,15 +39,17 @@ 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=
 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=
@@ -93,6 +95,11 @@ 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=
+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=
@@ -119,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=
@@ -129,6 +135,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=
@@ -223,10 +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=
@@ -394,9 +404,13 @@ 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=
 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
new file mode 100644
index 0000000..1c92323
--- /dev/null
+++ b/graphql.go
@@ -0,0 +1,408 @@
+package huma
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"reflect"
+	"sort"
+	"strings"
+
+	"github.com/danielgtaylor/casing"
+	"github.com/fatih/structs"
+	"github.com/graphql-go/graphql"
+	"github.com/graphql-go/handler"
+	"github.com/koron-go/gqlcost"
+)
+
+type graphContextKey string
+
+var graphKeyHeaders graphContextKey = "headers"
+var graphKeySem graphContextKey = "sem"
+
+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
+
+	// 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
+
+	// 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.
+	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
+
+	// 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
+// to the `result` slice.
+func allResources(r *Resource) []*Resource {
+	result := []*Resource{}
+	for _, sub := range r.subResources {
+		result = append(result, sub)
+		result = append(result, allResources(sub)...)
+	}
+	return result
+}
+
+// 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)
+}
+
+// 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???
+		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
+		}
+		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, nil)
+		if err != nil {
+			panic(err)
+		}
+		if param.In == inPath {
+			typ = graphql.NewNonNull(typ)
+		}
+		var def interface{}
+		if param.Schema != nil {
+			def = param.Schema.Default
+		}
+		argsNameMap[jsName] = name
+		args[jsName] = &graphql.ArgumentConfig{
+			Type:         typ,
+			Description:  param.Description,
+			DefaultValue: def,
+		}
+	}
+
+	// Convert the Go model to GraphQL Schema.
+	out, err := r.generateGraphModel(config, model, resource.path, headerNames, ignoreParams, nil)
+	if err != nil {
+		panic(err)
+	}
+
+	calculateComplexity(config, parentName, model, out, last)
+
+	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]))
+				}
+			}
+
+			// 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
+			// 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, 1)
+			var result interface{}
+			var respHeader http.Header
+			var err error
+			go func() {
+				result, respHeader, err = r.fetch(headers.Clone(), path, queryParams)
+				done <- true
+				<-sem
+			}()
+
+			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)
+				}
+
+				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
+						}
+					}
+					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
+		},
+	}
+}
+
+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, parentName, fields, resource, op, ignoreParams)
+	}
+}
+
+// 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)
+		resources = append(resources, allResources(resource)...)
+	}
+	sort.Slice(resources, func(i, j int) bool {
+		return len(resources[i].path) < len(resources[j].path)
+	})
+
+	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{})
+	}
+
+	root := graphql.ObjectConfig{Name: "Query", Fields: fields}
+	schemaConfig := graphql.SchemaConfig{Query: graphql.NewObject(root)}
+	schema, err := graphql.NewSchema(schemaConfig)
+	if err != nil {
+		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,
+		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.
+		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)
+	})
+}
diff --git a/graphql_model.go b/graphql_model.go
new file mode 100644
index 0000000..5d5e50a
--- /dev/null
+++ b/graphql_model.go
@@ -0,0 +1,304 @@
+package huma
+
+import (
+	"fmt"
+	"reflect"
+	"strings"
+	"time"
+
+	"github.com/danielgtaylor/casing"
+	"github.com/graphql-go/graphql"
+)
+
+// 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
+}
+
+// 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 && fields["headers"] == nil {
+		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. 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, listItems graphql.Output) (graphql.Output, error) {
+	switch t.Kind() {
+	case reflect.Struct:
+		// Handle special cases.
+		switch t {
+		case timeType:
+			return graphql.DateTime, 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
+		}
+
+		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
+				}
+
+				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
+				}
+
+				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
+							}
+							entries := []interface{}{}
+							m := reflect.ValueOf(p.Source.(map[string]interface{})[name])
+							for _, k := range m.MapKeys() {
+								entries = append(entries, map[string]interface{}{
+									"key":   k.Interface(),
+									"value": m.MapIndex(k).Interface(),
+								})
+							}
+							return entries, nil
+						}
+					}
+				}
+			}
+		}
+
+		// 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, 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) {
+							if best == nil || len(best.path) < len(sub.path) {
+								best = sub
+							}
+						}
+					}
+					if best != nil && best.path == urlTemplate {
+						r.handleResource(config, objectName, fields, resource, ignoreParams)
+					}
+				}
+			}
+		}
+
+		addHeaderFields(objectName, 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,
+			}
+		}
+
+		out := graphql.NewObject(graphql.ObjectConfig{
+			Name:   objectName,
+			Fields: fields,
+		})
+		config.known[objectName] = 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, listItems)
+		if err != nil {
+			return nil, err
+		}
+		valueModel, err := r.generateGraphModel(config, t.Elem(), "", nil, ignoreParams, listItems)
+		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, headerNames, ignoreParams, nil)
+		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.
+			paginator, err := r.generateGraphModel(config, reflect.TypeOf(config.Paginator), "", headerNames, ignoreParams, items)
+			if err != nil {
+				return nil, err
+			}
+
+			if config.known[paginator.Name()] != nil {
+				return config.known[paginator.Name()], nil
+			}
+
+			config.known[paginator.Name()] = paginator
+			return paginator, 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
+	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, listItems)
+	}
+
+	return nil, fmt.Errorf("unsupported type %s from %s", t.Kind(), t)
+}
diff --git a/graphql_paginator.go b/graphql_paginator.go
new file mode 100644
index 0000000..11b671f
--- /dev/null
+++ b/graphql_paginator.go
@@ -0,0 +1,77 @@
+package huma
+
+import (
+	"net/url"
+	"strings"
+
+	link "github.com/tent/http-link-go"
+)
+
+// GraphQLPaginator defines how to to turn list responses from the HTTP API to
+// GraphQL response objects.
+type GraphQLPaginator interface {
+	// Load the paginated response from the given headers and body. After this
+	// call completes, your struct instance should be ready to send back to
+	// the client.
+	Load(headers map[string]string, body []interface{}) error
+}
+
+// GraphQLHeaders is a placeholder to be used in `GraphQLPaginator` struct
+// implementations which gets replaced with a struct of response headers.
+type GraphQLHeaders map[string]string
+
+// GraphQLItems is a placeholder to be used in `GraphQLPaginator` struct
+// implementations which gets replaced with a list of the response items model.
+type GraphQLItems []interface{}
+
+// GraphQLPaginationParams provides params for link relationships so that
+// new GraphQL queries to get e.g. the next page of items are easy to construct.
+type GraphQLPaginationParams struct {
+	First map[string]string `json:"first" doc:"First page link relationship"`
+	Next  map[string]string `json:"next" doc:"Next page link relationship"`
+	Prev  map[string]string `json:"prev" doc:"Previous page link relationship"`
+	Last  map[string]string `json:"last" doc:"Last page link relationship"`
+}
+
+// GraphQLDefaultPaginator provides a default generic paginator implementation
+// that makes no assumptions about pagination parameter names, headers, etc.
+// It enables clients to access the response items (edges) as well as any
+// response headers. If a link relation header is found in the response, then
+// link relationships are parsed and turned into easy-to-use parameters for
+// subsequent requests.
+type GraphQLDefaultPaginator struct {
+	Headers GraphQLHeaders          `json:"headers"`
+	Links   GraphQLPaginationParams `json:"links" doc:"Pagination link parameters"`
+	Edges   GraphQLItems            `json:"edges"`
+}
+
+// Load the paginated response and parse link relationships if available.
+func (g *GraphQLDefaultPaginator) Load(headers map[string]string, body []interface{}) error {
+	g.Headers = headers
+	if parsed, err := link.Parse(headers["link"]); err == nil && len(parsed) > 0 {
+		for _, item := range parsed {
+			parsed, err := url.Parse(item.URI)
+			if err != nil {
+				continue
+			}
+			params := map[string]string{}
+			query := parsed.Query()
+			for k := range query {
+				params[k] = query.Get(k)
+			}
+
+			switch strings.ToLower(item.Rel) {
+			case "first":
+				g.Links.First = params
+			case "next":
+				g.Links.Next = params
+			case "prev":
+				g.Links.Prev = params
+			case "last":
+				g.Links.Last = params
+			}
+		}
+	}
+	g.Edges = body
+	return nil
+}
diff --git a/openapi.go b/openapi.go
index 58db74a..6e7157b 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 9c66762..bae143c 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
 	}
 


Test patch:
diff --git a/graphql_test.go b/graphql_test.go
new file mode 100644
index 0000000..8aeca9b
--- /dev/null
+++ b/graphql_test.go
@@ -0,0 +1,307 @@
+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"`
+	Code     []byte `json:"code" doc:"Category code"`
+
+	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,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:"-"`
+}
+
+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}, 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"},
+		Featured:        true,
+		Code:            []byte{'h', 'i'},
+		products: map[string]*Product{
+			"xbox_series_x":   xsx,
+			"playstation_ps5": ps5,
+			"nintendo_switch": ns,
+		},
+	}
+
+	categories := map[string]*Category{
+		"video_games": videoGames,
+	}
+
+	app := newTestRouter()
+
+	categoriesResource := app.Resource("/categories")
+	categoriesResource.Get("get-categories", "doc",
+		NewResponse(http.StatusOK, "").Model([]CategorySummary{}).Headers("link"),
+	).Run(func(ctx Context, input struct {
+		Cursor string `query:"cursor"`
+		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.Header().Set("Link", "</categories?cursor=abc123>; rel=\"next\"")
+		ctx.WriteModel(http.StatusOK, summaries[:input.Limit])
+	})
+
+	categoriesResource.Delete("delete-category", "doc",
+		NewResponse(http.StatusNoContent, ""),
+	).Run(func(ctx Context) {
+		ctx.WriteHeader(http.StatusNoContent)
+	})
+
+	categoriesResource.SubResource("/{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(&GraphQLConfig{
+		ComplexityLimit: 250,
+	})
+
+	query := strings.Replace(strings.Replace(`{
+		categories(limit: 1) {
+			headers {
+				link
+			}
+			links {
+				next {
+					key
+					value
+				}
+			}
+			edges {
+				categoriesItem {
+					id
+					featured
+					code
+					products {
+						edges {
+							productsItem {
+								id
+								suggested_price
+								created
+								metadata{
+									key
+									value
+								}
+								empty {
+									_
+								}
+								stores {
+									edges {
+										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:
+		headers:
+			link: </categories?cursor=abc123>; rel="next"
+		links:
+			next:
+				- key: cursor
+				  value: abc123
+		edges:
+			- categoriesItem:
+					id: video_games
+					featured: true
+					code: aGk=
+					products:
+						edges:
+							- productsItem:
+									id: nintendo_switch
+									suggested_price: 349.99
+									created: null
+									metadata: null
+									empty: 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
+									empty: 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
+									empty:
+										_: null
+									stores:
+										edges:
+											- storesItem:
+													id: amazon
+													url: https://www.amazon.com/
+											- storesItem:
+													id: target
+													url: https://www.target.com/
+`, "\t", "  ", -1), w.Body.String())
+}