272 lines
7.3 KiB
Markdown
272 lines
7.3 KiB
Markdown
# gqlgen Reference
|
||
|
||
gqlgen is a schema-first, code-generation library. Write SDL, run `go generate`, fill in resolver bodies.
|
||
|
||
## Project Setup
|
||
|
||
```bash
|
||
# Bootstrap a new project
|
||
go run github.com/99designs/gqlgen init
|
||
|
||
# Pin the tool in go.mod for reproducible generation (Go 1.24+)
|
||
go get -tool github.com/99designs/gqlgen@latest
|
||
```
|
||
|
||
For Go <1.24 modules, use the legacy `tools.go` blank-import workaround instead.
|
||
|
||
```bash
|
||
# Regenerate after every schema change
|
||
go tool gqlgen generate
|
||
```
|
||
|
||
Never hand-edit generated files (`generated.go`, `models_gen.go`) — `generate` overwrites them.
|
||
|
||
## gqlgen.yml
|
||
|
||
```yaml
|
||
schema:
|
||
- graph/schema/*.graphql
|
||
|
||
exec:
|
||
filename: graph/generated.go
|
||
package: graph
|
||
|
||
model:
|
||
filename: graph/model/models_gen.go
|
||
package: model
|
||
|
||
resolver:
|
||
layout: follow-schema # one resolvers file per schema file
|
||
dir: graph
|
||
package: graph
|
||
filename_template: "{name}.resolvers.go"
|
||
|
||
autobind:
|
||
- github.com/me/app/internal/domain # reuse existing structs
|
||
|
||
models:
|
||
# ID: graphql.IntID # legacy only — use opaque string IDs for new schemas
|
||
User:
|
||
model: github.com/me/app/internal/domain.User
|
||
fields:
|
||
posts:
|
||
resolver: true # force a custom resolver (required for DataLoader fields)
|
||
|
||
omit_slice_element_pointers: true
|
||
struct_fields_always_pointers: false
|
||
resolvers_always_return_pointers: true
|
||
```
|
||
|
||
Key knobs:
|
||
|
||
- `autobind` — maps Go structs to GraphQL types; fields must match by name (case-insensitive)
|
||
- `models.<T>.model` — override which Go type backs a GraphQL type
|
||
- `fields.<f>.resolver: true` — force a custom resolver instead of struct field access; required for any field that should batch via DataLoader
|
||
- `struct_fields_always_pointers` / `resolvers_always_return_pointers` — controls `*T` vs `T` in generated signatures; match your domain model conventions
|
||
|
||
## Resolver Structure
|
||
|
||
The generated `Config` holds a `Resolvers` field of the generated interface. You implement it:
|
||
|
||
```go
|
||
// graph/resolver.go — you own this file, not generated
|
||
type Resolver struct {
|
||
db *sql.DB
|
||
userService *service.UserService
|
||
loaders *dataloaders.Loaders // injected per-request
|
||
}
|
||
```
|
||
|
||
Per-type resolvers implement the generated interface split by GraphQL type:
|
||
|
||
```go
|
||
type queryResolver struct{ *Resolver }
|
||
type mutationResolver struct{ *Resolver }
|
||
type userResolver struct{ *Resolver }
|
||
|
||
func (r *queryResolver) User(ctx context.Context, id string) (*model.User, error) { ... }
|
||
func (r *userResolver) Posts(ctx context.Context, obj *model.User) ([]*model.Post, error) { ... }
|
||
```
|
||
|
||
`obj` is the parent object — the entry point for walking the graph.
|
||
|
||
## DataLoaders (gqlgen)
|
||
|
||
Use `github.com/vikstrous/dataloadgen` (generics, fast) or `github.com/graph-gophers/dataloader`:
|
||
|
||
```go
|
||
// Inject per-request via middleware
|
||
func Middleware(db *sql.DB, next http.Handler) http.Handler {
|
||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
loaders := &Loaders{
|
||
PostsByUserID: dataloadgen.NewLoader(func(ctx context.Context, ids []string) ([][]*domain.Post, []error) {
|
||
return batchPostsByUserID(ctx, db, ids) // returns one []Post per user ID
|
||
}, dataloadgen.WithWait(1*time.Millisecond)),
|
||
}
|
||
ctx := context.WithValue(r.Context(), loadersKey, loaders)
|
||
next.ServeHTTP(w, r.WithContext(ctx))
|
||
})
|
||
}
|
||
|
||
// Resolver uses the loader — never the DB directly
|
||
func (r *userResolver) Posts(ctx context.Context, obj *model.User) ([]*model.Post, error) {
|
||
return loaders.For(ctx).PostsByUserID.Load(ctx, obj.ID)
|
||
}
|
||
```
|
||
|
||
Set `wait` to 1–2ms — allows multiple concurrent resolvers to register keys before the batch fires.
|
||
|
||
## Authentication Directives
|
||
|
||
```graphql
|
||
directive @hasRole(role: Role!) on FIELD_DEFINITION
|
||
|
||
type Query {
|
||
adminStats: Stats! @hasRole(role: ADMIN)
|
||
}
|
||
```
|
||
|
||
```go
|
||
// Implement the directive function
|
||
func HasRole(ctx context.Context, obj any, next graphql.Resolver, role model.Role) (any, error) {
|
||
user := auth.UserFromContext(ctx)
|
||
if user == nil || user.Role != role {
|
||
return nil, &gqlerror.Error{
|
||
Message: "access denied",
|
||
Extensions: map[string]any{"code": "FORBIDDEN"},
|
||
}
|
||
}
|
||
return next(ctx)
|
||
}
|
||
|
||
// Register at server bootstrap
|
||
c := generated.Config{
|
||
Resolvers: &graph.Resolver{...},
|
||
Directives: generated.DirectiveRoot{
|
||
HasRole: HasRole,
|
||
},
|
||
}
|
||
```
|
||
|
||
## Middleware Hooks
|
||
|
||
```go
|
||
srv.AroundOperations(func(ctx context.Context, next graphql.OperationHandler) graphql.ResponseHandler {
|
||
// log operation name, add trace span
|
||
return next(ctx)
|
||
})
|
||
srv.AroundFields(func(ctx context.Context, next graphql.Resolver) (any, error) {
|
||
// per-field tracing, timing
|
||
return next(ctx)
|
||
})
|
||
```
|
||
|
||
## Error Presenter
|
||
|
||
```go
|
||
srv.SetErrorPresenter(func(ctx context.Context, err error) *gqlerror.Error {
|
||
var gqlErr *gqlerror.Error
|
||
if errors.As(err, &gqlErr) {
|
||
return gqlErr
|
||
}
|
||
log.Ctx(ctx).Error("resolver error", "err", err)
|
||
return gqlerror.Errorf("internal server error")
|
||
})
|
||
|
||
srv.SetRecoverFunc(func(ctx context.Context, err any) error {
|
||
log.Ctx(ctx).Error("panic in resolver", "err", err)
|
||
return fmt.Errorf("internal server error")
|
||
})
|
||
```
|
||
|
||
## Subscriptions
|
||
|
||
```go
|
||
srv.AddTransport(transport.Websocket{
|
||
KeepAlivePingInterval: 10 * time.Second,
|
||
Upgrader: websocket.Upgrader{
|
||
// Restrict to your own origin in production; true here is dev-only.
|
||
CheckOrigin: func(r *http.Request) bool {
|
||
return r.Header.Get("Origin") == "https://app.example.com"
|
||
},
|
||
},
|
||
InitFunc: func(ctx context.Context, initPayload transport.InitPayload) (context.Context, *transport.InitPayload, error) {
|
||
// auth at connection time
|
||
token := initPayload.Authorization()
|
||
user, err := validateToken(token)
|
||
if err != nil {
|
||
return ctx, nil, err
|
||
}
|
||
return context.WithValue(ctx, userKey, user), &initPayload, nil
|
||
},
|
||
})
|
||
```
|
||
|
||
gqlgen supports both `graphql-ws` (legacy) and `graphql-transport-ws` (current) subprotocols.
|
||
|
||
## File Uploads
|
||
|
||
```go
|
||
srv.AddTransport(transport.MultipartForm{
|
||
MaxUploadSize: 10 << 20, // 10 MB total
|
||
MaxMemory: 5 << 20, // 5 MB in memory; rest spills to disk
|
||
})
|
||
```
|
||
|
||
Schema:
|
||
|
||
```graphql
|
||
scalar Upload
|
||
|
||
type Mutation {
|
||
uploadAvatar(file: Upload!): User!
|
||
}
|
||
```
|
||
|
||
Resolver receives `graphql.Upload{File io.Reader, Filename string, Size int64, ContentType string}`.
|
||
|
||
## Apollo Federation v2
|
||
|
||
`gqlgen.yml`:
|
||
|
||
```yaml
|
||
federation:
|
||
filename: graph/federation.go
|
||
version: 2
|
||
```
|
||
|
||
Schema:
|
||
|
||
```graphql
|
||
extend schema
|
||
@link(
|
||
url: "https://specs.apollo.dev/federation/v2.3"
|
||
import: ["@key", "@shareable", "@external"]
|
||
)
|
||
|
||
type User @key(fields: "id") {
|
||
id: ID!
|
||
name: String!
|
||
}
|
||
```
|
||
|
||
Implement `FindUserByID` in the generated entity resolver. Works with Apollo Router and Cosmo.
|
||
|
||
## Production Handler Setup
|
||
|
||
```go
|
||
srv := handler.New(es)
|
||
srv.AddTransport(transport.Options{})
|
||
srv.AddTransport(transport.GET{})
|
||
srv.AddTransport(transport.POST{})
|
||
srv.AddTransport(transport.MultipartForm{MaxUploadSize: 10 << 20, MaxMemory: 5 << 20})
|
||
srv.AddTransport(transport.Websocket{KeepAlivePingInterval: 10 * time.Second})
|
||
|
||
srv.SetQueryCache(lru.New[*ast.QueryDocument](1000))
|
||
if os.Getenv("ENV") != "production" {
|
||
srv.Use(extension.Introspection{})
|
||
}
|
||
srv.Use(extension.AutomaticPersistedQuery{Cache: lru.New[string](100)})
|
||
srv.Use(extension.FixedComplexityLimit(200))
|
||
```
|