mirror of
https://github.com/samber/cc-skills-golang.git
synced 2026-09-11 19:46:44 +03:00
feat(golang-graphql): write full skill body with references and evals (#32)
* feat(golang-graphql): write full skill body with references and evals
Replaces the stub with a complete skill covering gqlgen (schema-first
codegen) and graph-gophers/graphql-go (schema-first reflection).
Key topics: Library choice table, N+1 DataLoader (per-request), schema
design with nullability rules, mutation envelope pattern, subscription
goroutine discipline, production safety (introspection gating, complexity
caps), error sanitization via ErrorPresenter, file uploads, Federation v2.
Reference files added:
- references/gqlgen.md — codegen workflow, gqlgen.yml, DataLoaders,
directives, subscriptions, file uploads, Federation v2
- references/graphql-go.md — reflection model, type mapping (int32!),
nullable pointers, DataLoaders, OTel tracing
- references/testing.md — gqlgen client harness, gqltesting patterns
Evals: 8 adversarial scenarios, 37 assertions.
Results: with=100%, without=84%, delta=+16pp (uplift driven by eval 1
N+1 DataLoader pattern: 6/6 with skill vs 1/6 without).
Most evals (2–4, 6–8) are common knowledge — future iterations should
target DataLoader wait timing, fields.resolver:true in gqlgen.yml,
Federation @key wiring, and WS subprotocol selection.
* fix(golang-graphql): address PR review comments and add adversarial evals
- SKILL.md: move pubsub.Subscribe() before goroutine in subscription example
- gqlgen.md: remove IntID override (legacy-only), fix DataLoader batch return
type to [][]*domain.Post, add CheckOrigin allow-list example, gate
Introspection behind ENV check
- graphql-go.md: fix OTel import to trace/otel + otel.DefaultTracer(), add
db field to RootResolver struct, fix UnmarshalGraphQL signature to any
- evals: add 5 new adversarial evals (9-13); eval 12 (OTel tracer import)
shows 4/4 → 0/4 uplift (model hallucinates trace/oteltracer without skill)
- README: mark golang-graphql ✅ with token counts (76/2935/7766)
- EVALUATIONS.md: update to 59 total assertions, 100% with / 83% without
This commit is contained in:
+2
-1
@@ -50,6 +50,7 @@
|
||||
| `golang-samber-lo` | v1.0.0 | 86 | 97% | 57% | +40pp | 1.70× | |
|
||||
| `golang-uber-fx` | v1.0.0 | 21 | 100% | **95%** | +5pp | 1.05× | **Low delta, high without** |
|
||||
| `golang-uber-dig` | v1.0.0 | 20 | 100% | **90%** | +10pp | 1.11× | **Low delta, high without** |
|
||||
| `golang-graphql` | v0.0.2 | 59 | 100% | **83%** | +17pp | 1.20× | **Low delta, high without** |
|
||||
| `golang-samber-do` | v1.0.0 | 53 | 100% | 19% | +81pp | 5.26× | |
|
||||
| **Total (38 skills)** | | **3242** | **98%** | **55%** | **+43pp** | **1.79×** | |
|
||||
|
||||
@@ -4511,7 +4512,7 @@
|
||||
<details>
|
||||
<summary>Full breakdown (60 assertions)</summary>
|
||||
|
||||
**Model:** Claude Sonnet 4.6 | **Runs:** 12 evals × 2 configs = 24 subagents | **Grading:** LLM-as-judge
|
||||
**Model:** Claude Sonnet 4.6 | **Runs:** 12 evals × 2 configs = 24 subagents | **Grading:** Human-as-judge
|
||||
|
||||
| # | Assertion | With | Without |
|
||||
| ---- | ------------------------------------------------------------------------------------------------------- | ------------------------------ | ------------------------------ |
|
||||
|
||||
@@ -179,7 +179,7 @@ These skills are designed as **atomic, cross-referencing units**. A skill may re
|
||||
| Skill | Flags | Error rate gap | Description (tok) | SKILL.md (tok) | Directory (tok) |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| ❌ `golang-google-wire` | | — | 0 | 0 | 0 |
|
||||
| ❌ `golang-graphql` | | — | 0 | 0 | 0 |
|
||||
| ✅ `golang-graphql` | | -16% | 76 | 2,935 | 7,766 |
|
||||
| ✅ `golang-grpc` | ⚡ | -41% | 69 | 2,149 | 4,965 |
|
||||
| ❌ `golang-spf13-cobra` | | — | 0 | 0 | 0 |
|
||||
| ❌ `golang-spf13-viper` | | — | 0 | 0 | 0 |
|
||||
|
||||
@@ -18,4 +18,255 @@ metadata:
|
||||
allowed-tools: Read Edit Write Glob Grep Bash(go:*) Bash(golangci-lint:*) Bash(git:*) Agent WebFetch mcp__context7__resolve-library-id mcp__context7__query-docs Bash(curl:*)
|
||||
---
|
||||
|
||||
(Content will be added in a future iteration)
|
||||
**Persona:** You are a Go GraphQL engineer. You design schemas deliberately, batch database access to prevent N+1, and treat query complexity limits as non-optional in production.
|
||||
|
||||
**Modes:**
|
||||
|
||||
- **Build mode** — generating new schemas, resolvers, or server setup: follow the skill's sequential instructions; launch a background agent to grep for existing resolver patterns and naming conventions before generating new code.
|
||||
- **Review mode** — auditing a GraphQL codebase or PR: use a sub-agent to scan for N+1 resolver patterns, missing complexity caps, global DataLoaders, and introspection enabled in production, in parallel with reading the business logic.
|
||||
|
||||
> **Community default.** A company skill that explicitly supersedes `samber/cc-skills-golang@golang-graphql` skill takes precedence.
|
||||
|
||||
# Go GraphQL Best Practices
|
||||
|
||||
Both major libraries are schema-first: write SDL (`.graphql` files), bind Go resolvers. Choose based on project size and team preferences.
|
||||
|
||||
This skill is not exhaustive. Refer to each library's official documentation and code examples for current API signatures. Context7 can help as a discoverability platform.
|
||||
|
||||
## Library Choice
|
||||
|
||||
| Library | Approach | Type safety | Build step | Best for |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `github.com/99designs/gqlgen` | Codegen | Compile-time | `go generate` | Large schemas, federation, strict types |
|
||||
| `github.com/graph-gophers/graphql-go` | Reflection | Parse-time | None | Simple schemas, fast iteration |
|
||||
| `github.com/graphql-go/graphql` | Code-first | Runtime | None | **Avoid** — verbose, no SDL |
|
||||
|
||||
Pick **gqlgen** when: Apollo Federation is required, schema is large (100+ types), or the team wants generated stubs and zero reflection overhead.
|
||||
|
||||
Pick **graph-gophers** when: schema is small/medium, the build pipeline should stay simple, or a dynamic schema is needed.
|
||||
|
||||
For deep-dive on each library, see [gqlgen reference](./references/gqlgen.md) and [graphql-go reference](./references/graphql-go.md).
|
||||
|
||||
## Schema Design
|
||||
|
||||
```graphql
|
||||
# ✓ Good — explicit nullability; ID scalar for opaque identifiers
|
||||
type User {
|
||||
id: ID!
|
||||
email: String! # non-null: the server can always return this
|
||||
bio: String # nullable: may be unset
|
||||
posts(first: Int = 10, after: String): PostConnection!
|
||||
}
|
||||
|
||||
# ✗ Bad — Int ID leaks implementation details, breaks client caching
|
||||
type Post {
|
||||
id: Int!
|
||||
}
|
||||
```
|
||||
|
||||
**Nullability rule:** mark a field `!` only when the server can _always_ return a value. A resolver error on a non-null field nulls the parent object, causing cascade failures; nullable fields only null the field itself.
|
||||
|
||||
**Pagination:** use Relay cursor connections (`Connection`/`Edge`/`PageInfo`) for list fields. Avoid offset pagination on large datasets — cursors are stable under concurrent writes.
|
||||
|
||||
**Mutations:** wrap results in an envelope type so clients receive business errors alongside partial results without polluting the GraphQL `errors` array:
|
||||
|
||||
```graphql
|
||||
type CreateUserPayload {
|
||||
user: User
|
||||
errors: [UserError!]!
|
||||
}
|
||||
```
|
||||
|
||||
## Resolver Patterns
|
||||
|
||||
Keep resolvers thin — they translate GraphQL inputs to domain calls and domain responses to GraphQL outputs.
|
||||
|
||||
```go
|
||||
// ✓ Good — resolver delegates to service layer
|
||||
func (r *mutationResolver) CreateUser(ctx context.Context, input model.CreateUserInput) (*model.CreateUserPayload, error) {
|
||||
user, err := r.userService.Create(ctx, input.Email, input.Name)
|
||||
if err != nil {
|
||||
return nil, formatError(err)
|
||||
}
|
||||
return &model.CreateUserPayload{User: toGQLUser(user)}, nil
|
||||
}
|
||||
|
||||
// ✗ Bad — SQL in resolver, no separation of concerns
|
||||
func (r *queryResolver) User(ctx context.Context, id string) (*model.User, error) {
|
||||
row := r.db.QueryRowContext(ctx, "SELECT * FROM users WHERE id = $1", id)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Use per-type resolver structs (`userResolver`, `postResolver`) rather than one monolithic resolver for all fields.
|
||||
|
||||
## N+1 Prevention (DataLoaders)
|
||||
|
||||
Each `User.posts` resolver fires a SQL query per user without batching — O(n) DB calls for n users. DataLoaders solve this by coalescing per-field loads into a single batch query.
|
||||
|
||||
**Critical rule: DataLoaders MUST be created per-request in HTTP middleware, never globally.** A global DataLoader caches across requests — stale data, potential cross-user data leakage.
|
||||
|
||||
```go
|
||||
// ✓ Good — per-request DataLoader in middleware
|
||||
func DataLoaderMiddleware(db *sql.DB, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
loaders := &Loaders{
|
||||
PostsByUserID: newPostsByUserIDLoader(r.Context(), db),
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), loadersKey, loaders)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// ✗ Bad — global DataLoader shared across all requests
|
||||
var globalLoader = newPostsByUserIDLoader(context.Background(), db)
|
||||
```
|
||||
|
||||
In gqlgen, mark batched fields with `resolver: true` in `gqlgen.yml` to force a dedicated resolver method. See [gqlgen reference](./references/gqlgen.md) for full DataLoader wiring.
|
||||
|
||||
## Authentication and Authorization
|
||||
|
||||
Two-layer model:
|
||||
|
||||
1. **HTTP middleware** — extract and validate tokens, stash identity in `context.Context`.
|
||||
2. **Schema directives** (gqlgen) or **resolver checks** (graphql-go) — enforce per-field authorization.
|
||||
|
||||
```go
|
||||
// HTTP middleware layer (both libraries)
|
||||
func AuthMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.Header.Get("Authorization")
|
||||
user, err := validateToken(token)
|
||||
if err != nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), userKey, user)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
In gqlgen, use `@hasRole` schema directives for field-level authorization — authorization policy lives in the schema, not scattered across resolvers. See [gqlgen reference](./references/gqlgen.md).
|
||||
|
||||
## Error Handling
|
||||
|
||||
Never return raw internal errors — they leak SQL messages, stack traces, or service internals to clients.
|
||||
|
||||
```go
|
||||
// gqlgen — custom ErrorPresenter strips internal details
|
||||
srv.SetErrorPresenter(func(ctx context.Context, err error) *gqlerror.Error {
|
||||
var gqlErr *gqlerror.Error
|
||||
if errors.As(err, &gqlErr) {
|
||||
return gqlErr // already formatted
|
||||
}
|
||||
// log internal err here
|
||||
return gqlerror.Errorf("internal error") // safe client message
|
||||
})
|
||||
|
||||
// Add extension codes for client-side error handling
|
||||
return nil, &gqlerror.Error{
|
||||
Message: "user not found",
|
||||
Extensions: map[string]any{"code": "NOT_FOUND"},
|
||||
}
|
||||
```
|
||||
|
||||
For graph-gophers, implement the `ResolverError` interface to attach `Extensions()`. See [graphql-go reference](./references/graphql-go.md).
|
||||
|
||||
Use `graphql.AddError(ctx, err)` in gqlgen for non-fatal field errors where the resolver can still return partial data.
|
||||
|
||||
For error wrapping patterns, see the `samber/cc-skills-golang@golang-error-handling` skill.
|
||||
|
||||
## Subscriptions
|
||||
|
||||
Subscriptions use long-lived WebSocket connections. The critical discipline: **always respect context cancellation** — a leaked goroutine per disconnected client exhausts resources silently.
|
||||
|
||||
```go
|
||||
// ✓ Good — closes channel when client disconnects
|
||||
func (r *subscriptionResolver) MessageAdded(ctx context.Context, room string) (<-chan *model.Message, error) {
|
||||
ch := make(chan *model.Message, 1)
|
||||
sub := r.pubsub.Subscribe(room) // subscribe once before the goroutine
|
||||
go func() {
|
||||
defer close(ch) // always close; signals iteration to stop
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return // client disconnected
|
||||
case msg := <-sub:
|
||||
ch <- msg
|
||||
}
|
||||
}
|
||||
}()
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// ✗ Bad — goroutine leaks forever when client disconnects
|
||||
func (r *subscriptionResolver) MessageAdded(ctx context.Context, room string) (<-chan *model.Message, error) {
|
||||
ch := make(chan *model.Message, 1)
|
||||
go func() {
|
||||
for msg := range r.pubsub.Subscribe(room) {
|
||||
ch <- msg // blocks forever after client gone
|
||||
}
|
||||
}()
|
||||
return ch, nil
|
||||
}
|
||||
```
|
||||
|
||||
## Performance and Safety
|
||||
|
||||
Production GraphQL servers require explicit limits. Without them, a single deeply nested query exhausts CPU and memory.
|
||||
|
||||
```go
|
||||
// gqlgen — wire these into every production handler
|
||||
srv := handler.NewDefaultServer(es)
|
||||
srv.Use(extension.FixedComplexityLimit(200)) // max cost per query
|
||||
|
||||
// Gate introspection — only in non-production environments
|
||||
if os.Getenv("ENV") != "production" {
|
||||
srv.Use(extension.Introspection{})
|
||||
}
|
||||
```
|
||||
|
||||
For graph-gophers: `graphql.MaxDepth(10)` and `graphql.MaxParallelism(10)` options at `ParseSchema` time.
|
||||
|
||||
**Query allow-listing:** in production, consider persisted queries (gqlgen APQ extension) to reject arbitrary query strings.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
| Mistake | Why it matters | Fix |
|
||||
| --- | --- | --- |
|
||||
| N+1 queries in child resolvers | One SQL per parent row → O(n) DB calls | Use per-request DataLoader |
|
||||
| Global DataLoader | Cross-request cache — stale data, data leaks | Create DataLoader in request middleware |
|
||||
| Editing `models_gen.go` directly | Next `go generate` wipes hand edits | Use `autobind` or `models.<T>.model` in `gqlgen.yml` |
|
||||
| Forgetting `go generate` after schema change | Resolver interface mismatch at compile time | Re-run `go run github.com/99designs/gqlgen generate` |
|
||||
| `int` field in graph-gophers resolver | Library requires `int32` for `Int` scalar | Use `int32` (or `float64` for `Float`) |
|
||||
| Introspection enabled in production | Exposes full schema to attackers | Gate with `ENV` check |
|
||||
| No complexity cap | Deeply nested query → CPU/memory DoS | `extension.FixedComplexityLimit(N)` |
|
||||
| Leaking DB errors from resolvers | Exposes SQL internals to clients | Wrap in `ErrorPresenter` / `ResolverError` |
|
||||
| Subscription goroutine leak | Client disconnect → goroutine runs forever | `defer close(ch)` + `select ctx.Done()` |
|
||||
| Nullable field for always-required data | Clients must null-check everywhere | Mark `!` in schema; return error from resolver |
|
||||
|
||||
## Deep Dives
|
||||
|
||||
- **[gqlgen reference](./references/gqlgen.md)** — codegen workflow, `gqlgen.yml`, DataLoaders, Federation v2, directives
|
||||
- **[graphql-go reference](./references/graphql-go.md)** — reflection resolver model, type mapping, tracing
|
||||
- **[Testing](./references/testing.md)** — gqlgen client harness, gqltesting, httptest patterns
|
||||
|
||||
## Cross-References
|
||||
|
||||
- → See `samber/cc-skills-golang@golang-context` skill for context propagation in resolvers and subscriptions
|
||||
- → See `samber/cc-skills-golang@golang-error-handling` skill for error wrapping and sentinel patterns
|
||||
- → See `samber/cc-skills-golang@golang-testing` skill for table-driven and integration test patterns
|
||||
- → See `samber/cc-skills-golang@golang-observability` skill for tracing and metrics in resolvers
|
||||
- → See `samber/cc-skills-golang@golang-security` skill for input validation and injection prevention
|
||||
- → See `samber/cc-skills-golang@golang-database` skill for N+1 query patterns and DataLoader database batching
|
||||
|
||||
## References
|
||||
|
||||
- [gqlgen](https://github.com/99designs/gqlgen)
|
||||
- [graph-gophers/graphql-go](https://github.com/graph-gophers/graphql-go)
|
||||
- [Relay cursor connections spec](https://relay.dev/graphql/connections.htm)
|
||||
|
||||
If you encounter a bug or unexpected behavior in gqlgen, open an issue at https://github.com/99designs/gqlgen/issues.
|
||||
|
||||
If you encounter a bug or unexpected behavior in graph-gophers/graphql-go, open an issue at https://github.com/graph-gophers/graphql-go/issues.
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
{
|
||||
"skill_name": "golang-graphql",
|
||||
"evals": [
|
||||
{
|
||||
"id": 1,
|
||||
"prompt": "I have a gqlgen project with a User type and a Post type. Users have many posts. Write the Go resolver for User.posts. We fetch posts from a PostgreSQL database. The project is set up with a standard gqlgen layout.",
|
||||
"expected_output": "A resolver that uses a per-request DataLoader (not direct DB calls) to batch-fetch posts by user IDs. Must NOT query the database directly inside the resolver. Must NOT use a global DataLoader. Should use context to access the per-request loader.",
|
||||
"assertions": [
|
||||
"Uses a DataLoader or batch loader to fetch posts, not a direct db.Query/QueryContext call inside the resolver method",
|
||||
"Accesses the DataLoader from context (not a package-level or global variable)",
|
||||
"The resolver function signature uses obj *model.User as a parameter to access the parent user's ID",
|
||||
"Does not query the database directly inside the Posts resolver body",
|
||||
"Mentions that the DataLoader must be injected per-request via HTTP middleware",
|
||||
"DataLoader middleware creates a new loader instance per request, not a shared global"
|
||||
],
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"prompt": "We have a graph-gophers/graphql-go project. I need to add a resolver that returns the total comment count for a post. The field is declared as `commentCount: Int!` in the SDL. Write the Go resolver method.",
|
||||
"expected_output": "Resolver method using int32 (not int) as the return type for the Int! scalar field. Must use int32, since graph-gophers requires this specific type.",
|
||||
"assertions": [
|
||||
"Returns int32 (not int, int64, or uint) for the Int! scalar field",
|
||||
"Method signature matches the SDL field name (case-insensitive: CommentCount or commentCount)",
|
||||
"Does not return plain Go int — which causes a type mismatch at parse time with graph-gophers"
|
||||
],
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"prompt": "Set up a production-ready gqlgen HTTP handler. The app will be deployed publicly. We need to make sure it's safe to expose.",
|
||||
"expected_output": "Handler setup that gates introspection (disabled or ENV-checked in production) and adds a query complexity limit. Must not leave introspection unconditionally enabled.",
|
||||
"assertions": [
|
||||
"Introspection is gated — either disabled in production or guarded by an environment variable check",
|
||||
"A complexity limit is set using extension.FixedComplexityLimit or equivalent",
|
||||
"Does NOT call srv.Use(extension.Introspection{}) unconditionally without an env guard",
|
||||
"Uses handler.New or handler.NewDefaultServer from github.com/99designs/gqlgen/handler",
|
||||
"Mentions MaxDepth or complexity limiting as a protection against deeply nested queries"
|
||||
],
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"prompt": "Implement a messageAdded subscription resolver in gqlgen. Messages are published via an in-memory pub/sub system. The resolver should stream new messages to subscribers in a given room.",
|
||||
"expected_output": "Subscription resolver that closes the channel on context cancellation (defer close(ch) + ctx.Done() in a select). Must handle client disconnect to avoid goroutine leaks.",
|
||||
"assertions": [
|
||||
"Uses defer close(ch) to close the output channel when done",
|
||||
"Uses a select statement with ctx.Done() to detect client disconnection",
|
||||
"Returns a receive-only channel (<-chan *model.Message or similar)",
|
||||
"Does not use a plain for-range loop without ctx.Done() check — this would cause a goroutine leak on disconnect",
|
||||
"The goroutine terminates when ctx is cancelled"
|
||||
],
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"prompt": "I'm using gqlgen and want to customize the User type to reuse my existing domain.User struct instead of having gqlgen generate a new one. The domain struct has an Email field. How do I configure this?",
|
||||
"expected_output": "Uses autobind or models.<T>.model in gqlgen.yml to map the GraphQL User type to domain.User. Must NOT instruct editing models_gen.go directly.",
|
||||
"assertions": [
|
||||
"Uses gqlgen.yml configuration (autobind or models.<T>.model) to bind the existing struct",
|
||||
"Does NOT suggest editing models_gen.go or generated.go directly",
|
||||
"Shows the correct gqlgen.yml syntax for either autobind or models.<T>.model",
|
||||
"Mentions that generated files are overwritten on next go generate"
|
||||
],
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"prompt": "In a graph-gophers/graphql-go resolver, I need to return a user profile that includes a nullable bio field. The SDL has: bio: String. Write the Go struct field or return type for bio.",
|
||||
"expected_output": "Uses *string (pointer to string) for the nullable String field. Non-pointer string would imply non-null in graph-gophers.",
|
||||
"assertions": [
|
||||
"Uses *string (pointer) for the nullable bio field, not plain string",
|
||||
"Explains that non-pointer = non-null and pointer = nullable in graph-gophers type mapping",
|
||||
"Does not use sql.NullString or other DB-specific nullable types for the GraphQL resolver layer"
|
||||
],
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"prompt": "My gqlgen resolver calls a database function that can return sql.ErrNoRows or other database errors. Write the resolver for GetUser(id: ID!): User! that handles these cases correctly for clients.",
|
||||
"expected_output": "Resolver wraps or transforms errors before returning them. Uses ErrorPresenter or gqlerror.Error with extensions code. Must NOT return raw sql.ErrNoRows to the client.",
|
||||
"assertions": [
|
||||
"Does NOT return raw sql.ErrNoRows or other internal errors directly to the client",
|
||||
"Translates sql.ErrNoRows to a GraphQL error with a meaningful message or extension code (e.g. NOT_FOUND)",
|
||||
"Uses gqlerror.Error or gqlerror.Errorf to format the client error",
|
||||
"Mentions the ErrorPresenter as the right place to centralize error sanitization",
|
||||
"Wraps internal errors so they don't expose SQL messages to API consumers"
|
||||
],
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"prompt": "Design a GraphQL mutation for creating a user where the email is always required but the bio is optional. The mutation should handle validation errors gracefully without returning HTTP errors.",
|
||||
"expected_output": "Uses a mutation envelope payload type with a user field and an errors field. Email is String! (non-null) in input; bio is String (nullable). Errors are returned in the payload, not as GraphQL top-level errors.",
|
||||
"assertions": [
|
||||
"Input type has email: String! (non-null) for required email",
|
||||
"Input type has bio: String (nullable, no !) for optional bio",
|
||||
"Mutation returns a payload envelope type with both user and errors fields",
|
||||
"Validation errors are returned inside the payload errors field, not as GraphQL top-level errors",
|
||||
"The payload errors field uses a non-null list type like [UserError!]! or similar",
|
||||
"Does NOT use HTTP 400/422 errors for validation — uses the GraphQL payload pattern"
|
||||
],
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"prompt": "Write a gqlgen subscription resolver for order status updates. Use an in-memory pub/sub broker. The resolver should subscribe to a topic named after the order ID and stream status events.",
|
||||
"expected_output": "Resolver subscribes to the pub/sub topic ONCE before launching the goroutine, not inside the goroutine loop. The channel is closed when the context is cancelled.",
|
||||
"assertions": [
|
||||
"Calls the pub/sub Subscribe (or equivalent) method BEFORE the go func() call, not inside the goroutine",
|
||||
"Does NOT call Subscribe() inside the for-select loop body (which would create a new subscription per iteration)",
|
||||
"Uses defer close(ch) to signal iteration end",
|
||||
"Uses select with ctx.Done() to handle client disconnect",
|
||||
"The subscription/topic handle is captured in a variable before the goroutine starts"
|
||||
],
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"prompt": "In a graph-gophers/graphql-go project, add a search query: `search(query: String!, first: Int, after: ID): [User!]!`. The first and after arguments are optional pagination parameters. Write the Go args struct for this resolver.",
|
||||
"expected_output": "Uses *int32 (not *int or int32) for the nullable Int argument 'first', and graphql.ID (or *graphql.ID) for the after argument. Nullable args use pointer types.",
|
||||
"assertions": [
|
||||
"Uses *int32 (pointer to int32) for the optional 'first: Int' argument — NOT *int or int32",
|
||||
"Uses graphql.ID or *graphql.ID for the 'after: ID' argument",
|
||||
"Optional arguments are represented as pointers to allow nil (absent) values",
|
||||
"Does NOT use plain int or *int for an Int field in graph-gophers"
|
||||
],
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"prompt": "Write a dataloadgen batch function for a gqlgen project that fetches all posts for a list of user IDs. Each user can have zero or more posts.",
|
||||
"expected_output": "Batch function returns [][]*domain.Post (a slice of post slices, one per user ID), not []*domain.Post. The outer slice length must match the input IDs length.",
|
||||
"assertions": [
|
||||
"Batch function return type is [][]*domain.Post (2D slice) or equivalent — NOT []*domain.Post",
|
||||
"The outer slice has exactly one element per input user ID (same length as the ids parameter)",
|
||||
"Handles users with zero posts by including an empty slice (not nil or missing entry)",
|
||||
"Does NOT return a flat []*domain.Post that collapses all posts into one slice"
|
||||
],
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"prompt": "Add OpenTelemetry tracing to a graph-gophers/graphql-go server. Show the import statement and the ParseSchema call with the tracer configured.",
|
||||
"expected_output": "Uses github.com/graph-gophers/graphql-go/trace/otel import and otel.DefaultTracer() — not an external otelgraphql package. Passed as graphql.Tracer() option.",
|
||||
"assertions": [
|
||||
"Imports github.com/graph-gophers/graphql-go/trace/otel (the bundled tracer package)",
|
||||
"Uses otel.DefaultTracer() to construct the tracer — NOT otelgraphql.DefaultTracer() or similar hallucinated package",
|
||||
"Passes the tracer as graphql.Tracer(otel.DefaultTracer()) option to MustParseSchema or ParseSchema",
|
||||
"Does NOT import an external third-party otelgraphql package that does not exist in graph-gophers"
|
||||
],
|
||||
"files": []
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"prompt": "We're building a federated GraphQL system with gqlgen. The User service owns the User type and must be resolvable by its id field from other services. What configuration and code changes are needed?",
|
||||
"expected_output": "Configures federation.version: 2 in gqlgen.yml, adds @key(fields: 'id') to the User schema type, and implements a FindUserByID entity resolver. Mentions Apollo Router or Cosmo as the gateway.",
|
||||
"assertions": [
|
||||
"Adds federation block with version: 2 to gqlgen.yml",
|
||||
"Adds @key(fields: \"id\") directive to the User type in the SDL schema",
|
||||
"Implements or mentions the FindUserByID entity resolver (generated by gqlgen's federation support)",
|
||||
"Imports or links the Apollo Federation v2 spec URL in the schema extend block",
|
||||
"Does NOT describe a manual federation approach without gqlgen.yml config"
|
||||
],
|
||||
"files": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
# 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 tools.go so go mod keeps it
|
||||
```
|
||||
|
||||
```go
|
||||
//go:build tools
|
||||
// +build tools
|
||||
|
||||
package tools
|
||||
|
||||
import _ "github.com/99designs/gqlgen"
|
||||
```
|
||||
|
||||
```bash
|
||||
# Regenerate after every schema change
|
||||
go run github.com/99designs/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))
|
||||
```
|
||||
@@ -0,0 +1,255 @@
|
||||
# graph-gophers/graphql-go Reference
|
||||
|
||||
Schema-first, reflection-based — no codegen. Write SDL, bind Go resolver structs. Parse-time validation gives a fail-fast contract.
|
||||
|
||||
## Setup
|
||||
|
||||
```go
|
||||
import (
|
||||
"github.com/graph-gophers/graphql-go"
|
||||
"github.com/graph-gophers/graphql-go/relay"
|
||||
"github.com/graph-gophers/graphql-go/trace/otel"
|
||||
)
|
||||
|
||||
schema := graphql.MustParseSchema(sdlString, &RootResolver{},
|
||||
graphql.MaxDepth(10),
|
||||
graphql.MaxParallelism(10),
|
||||
graphql.UseFieldResolvers(), // expose exported struct fields without explicit methods
|
||||
graphql.Tracer(otel.DefaultTracer()),
|
||||
)
|
||||
|
||||
http.Handle("/graphql", &relay.Handler{Schema: schema})
|
||||
```
|
||||
|
||||
`MustParseSchema` panics on invalid SDL or resolver mismatch — catch it at startup, not at request time.
|
||||
|
||||
## Resolver Structure
|
||||
|
||||
One exported method per schema field; name match is case-insensitive:
|
||||
|
||||
```go
|
||||
type RootResolver struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
type QueryResolver struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func (r *RootResolver) Query() *QueryResolver { return &QueryResolver{db: r.db} }
|
||||
|
||||
// Args struct for field arguments
|
||||
func (r *QueryResolver) User(ctx context.Context, args struct{ ID graphql.ID }) (*UserResolver, error) {
|
||||
user, err := r.db.GetUser(ctx, string(args.ID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &UserResolver{user: user}, nil
|
||||
}
|
||||
```
|
||||
|
||||
Return resolver wrapper structs, not domain models directly — keeps GraphQL projection separate from persistence.
|
||||
|
||||
## Type Mapping
|
||||
|
||||
| GraphQL type | Go type | Notes |
|
||||
| --- | --- | --- |
|
||||
| `ID` | `graphql.ID` | string alias |
|
||||
| `Int` | `int32` | **NOT `int`** — mismatch is a parse-time error |
|
||||
| `Float` | `float64` | |
|
||||
| `String` | `string` | |
|
||||
| `Boolean` | `bool` | |
|
||||
| `[T]` | `[]*T` or `[]T` | |
|
||||
| Nullable `T` | `*T` | pointer = nullable |
|
||||
| Non-null `T!` | `T` | non-pointer |
|
||||
| Custom scalar | implement `UnmarshalGraphQL(input any) error` + `MarshalJSON() ([]byte, error)` | |
|
||||
| Enum | typed string alias | |
|
||||
| Input | exported struct with field tags optional | |
|
||||
| Interface/Union | Go interface returned; `ToConcreteType() (*T, bool)` discriminators | |
|
||||
|
||||
Common mistake: using `int` for an `Int!` field — the parser rejects it with a type mismatch error.
|
||||
|
||||
## Nullable vs Non-null Arguments
|
||||
|
||||
```go
|
||||
// ✓ Good — pointer arg = nullable in schema
|
||||
func (r *QueryResolver) Users(ctx context.Context, args struct {
|
||||
Role *string // nullable: Role in SDL
|
||||
Limit int32 // non-null: Limit! in SDL
|
||||
}) ([]*UserResolver, error) { ... }
|
||||
```
|
||||
|
||||
Forgetting `*` on a nullable argument causes unmarshal failure when clients send `null`.
|
||||
|
||||
## Custom Scalar
|
||||
|
||||
```go
|
||||
type DateTime struct{ time.Time }
|
||||
|
||||
func (d *DateTime) UnmarshalGraphQL(input any) error {
|
||||
s, ok := input.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("DateTime must be a string")
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.Time = t
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d DateTime) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(d.Time.Format(time.RFC3339))
|
||||
}
|
||||
```
|
||||
|
||||
## Interfaces and Unions
|
||||
|
||||
```graphql
|
||||
interface Node { id: ID! }
|
||||
union SearchResult = User | Post
|
||||
```
|
||||
|
||||
```go
|
||||
// Interface — implement ToUser, ToPost discriminators
|
||||
type SearchResultResolver struct{ result any }
|
||||
|
||||
func (r *SearchResultResolver) ToUser() (*UserResolver, bool) {
|
||||
u, ok := r.result.(*domain.User)
|
||||
return &UserResolver{u}, ok
|
||||
}
|
||||
|
||||
func (r *SearchResultResolver) ToPost() (*PostResolver, bool) {
|
||||
p, ok := r.result.(*domain.Post)
|
||||
return &PostResolver{p}, ok
|
||||
}
|
||||
```
|
||||
|
||||
## DataLoaders
|
||||
|
||||
Use `github.com/graph-gophers/dataloader` per-request:
|
||||
|
||||
```go
|
||||
func DataLoaderMiddleware(db *sql.DB, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
loader := dataloader.NewBatchedLoader(func(ctx context.Context, keys dataloader.Keys) []*dataloader.Result {
|
||||
ids := make([]string, len(keys))
|
||||
for i, k := range keys {
|
||||
ids[i] = k.String()
|
||||
}
|
||||
posts, err := batchPostsByUserID(ctx, db, ids)
|
||||
// map results back to keys order ...
|
||||
return results
|
||||
})
|
||||
ctx := context.WithValue(r.Context(), postsLoaderKey, loader)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// In resolver
|
||||
func (r *UserResolver) Posts(ctx context.Context) ([]*PostResolver, error) {
|
||||
thunk := ctx.Value(postsLoaderKey).(*dataloader.Loader).Load(ctx, dataloader.StringKey(r.user.ID))
|
||||
result, err := thunk()
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Implement `ResolverError` to attach structured extensions:
|
||||
|
||||
```go
|
||||
type ResolverError interface {
|
||||
error
|
||||
Extensions() map[string]any
|
||||
}
|
||||
|
||||
type AppError struct {
|
||||
msg string
|
||||
code string
|
||||
}
|
||||
|
||||
func (e *AppError) Error() string { return e.msg }
|
||||
func (e *AppError) Extensions() map[string]any {
|
||||
return map[string]any{"code": e.code}
|
||||
}
|
||||
|
||||
// Usage in resolver
|
||||
return nil, &AppError{msg: "user not found", code: "NOT_FOUND"}
|
||||
```
|
||||
|
||||
Panics in resolvers are caught automatically and converted to GraphQL errors.
|
||||
|
||||
## OpenTelemetry Tracing
|
||||
|
||||
```go
|
||||
import "github.com/graph-gophers/graphql-go/trace/otel"
|
||||
|
||||
schema := graphql.MustParseSchema(sdl, &RootResolver{},
|
||||
graphql.Tracer(otel.DefaultTracer()),
|
||||
)
|
||||
```
|
||||
|
||||
Emits spans per request, validation, and field resolution with operation name and field path.
|
||||
|
||||
## Subscriptions
|
||||
|
||||
```go
|
||||
func (r *SubscriptionResolver) MessageAdded(ctx context.Context, args struct{ Room string }) <-chan *MessageResolver {
|
||||
ch := make(chan *MessageResolver, 1)
|
||||
go func() {
|
||||
defer close(ch)
|
||||
sub := r.pubsub.Subscribe(args.Room)
|
||||
defer sub.Unsubscribe()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case msg := <-sub.Chan():
|
||||
ch <- &MessageResolver{msg: msg}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return ch
|
||||
}
|
||||
```
|
||||
|
||||
WebSocket transport is not bundled — pair with `gorilla/websocket` or use the relay handler with a WebSocket-aware mux.
|
||||
|
||||
## Disabling Introspection
|
||||
|
||||
```go
|
||||
schema := graphql.MustParseSchema(sdl, &RootResolver{},
|
||||
graphql.DisableIntrospection(),
|
||||
)
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Use `gqltesting.RunTests`:
|
||||
|
||||
```go
|
||||
func TestUser(t *testing.T) {
|
||||
gqltesting.RunTests(t, []*gqltesting.Test{
|
||||
{
|
||||
Schema: schema,
|
||||
Query: `{ user(id: "1") { name email } }`,
|
||||
ExpectedResult: `{ "user": { "name": "Alice", "email": "alice@example.com" } }`,
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
For HTTP-level tests, drive `relay.Handler` with `httptest.NewRecorder()`.
|
||||
|
||||
## graph-gophers vs gqlgen Summary
|
||||
|
||||
| Concern | graph-gophers | gqlgen |
|
||||
| --- | --- | --- |
|
||||
| Type safety | Parse-time reflection | Compile-time codegen |
|
||||
| Build complexity | None | `go generate` step |
|
||||
| Performance | Slower (reflection) | Faster (static dispatch) |
|
||||
| Federation | Manual | First-class (v2) |
|
||||
| File uploads | Manual | Built-in MultipartForm |
|
||||
| Best for | Small/medium schemas | Large schemas, strict teams |
|
||||
@@ -0,0 +1,180 @@
|
||||
# Testing GraphQL in Go
|
||||
|
||||
## gqlgen — Client Harness
|
||||
|
||||
The `github.com/99designs/gqlgen/client` package drives the full stack (directives, middleware, resolvers) via an `http.Handler`:
|
||||
|
||||
```go
|
||||
func TestCreateUser(t *testing.T) {
|
||||
// Build the full handler with real dependencies (use a test DB)
|
||||
srv := handler.NewDefaultServer(graph.NewExecutableSchema(graph.Config{
|
||||
Resolvers: &graph.Resolver{
|
||||
DB: testDB,
|
||||
},
|
||||
}))
|
||||
|
||||
c := client.New(srv)
|
||||
|
||||
var resp struct {
|
||||
CreateUser struct {
|
||||
User struct {
|
||||
ID string
|
||||
Email string
|
||||
}
|
||||
Errors []struct{ Message string }
|
||||
}
|
||||
}
|
||||
|
||||
c.MustPost(`
|
||||
mutation CreateUser($email: String!, $name: String!) {
|
||||
createUser(input: {email: $email, name: $name}) {
|
||||
user { id email }
|
||||
errors { message }
|
||||
}
|
||||
}
|
||||
`, &resp,
|
||||
client.Var("email", "alice@example.com"),
|
||||
client.Var("name", "Alice"),
|
||||
client.AddHeader("Authorization", "Bearer test-token"),
|
||||
)
|
||||
|
||||
require.Empty(t, resp.CreateUser.Errors)
|
||||
require.Equal(t, "alice@example.com", resp.CreateUser.User.Email)
|
||||
}
|
||||
```
|
||||
|
||||
For unit testing individual resolvers, call resolver methods directly with a constructed `Resolver` and a real `context.Context` — no HTTP overhead.
|
||||
|
||||
## gqlgen — Testing with DataLoaders
|
||||
|
||||
Wrap the test server with the DataLoader middleware so resolver tests exercise the full batching path:
|
||||
|
||||
```go
|
||||
srv := handler.NewDefaultServer(es)
|
||||
h := dataloaders.Middleware(testDB, srv)
|
||||
|
||||
c := client.New(h)
|
||||
```
|
||||
|
||||
## gqlgen — Testing Subscriptions
|
||||
|
||||
Use `client.Subscription` to test subscription resolvers:
|
||||
|
||||
```go
|
||||
sub := c.Subscription(`subscription { messageAdded(room: "general") { content } }`)
|
||||
defer sub.Close()
|
||||
|
||||
// Trigger an event
|
||||
publishMessage("general", "hello")
|
||||
|
||||
var event struct{ MessageAdded struct{ Content string } }
|
||||
err := sub.Next(&event)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "hello", event.MessageAdded.Content)
|
||||
```
|
||||
|
||||
## graph-gophers — gqltesting
|
||||
|
||||
```go
|
||||
func TestUser(t *testing.T) {
|
||||
gqltesting.RunTests(t, []*gqltesting.Test{
|
||||
{
|
||||
Schema: schema,
|
||||
Query: `{ user(id: "1") { name email } }`,
|
||||
ExpectedResult: `{"user":{"name":"Alice","email":"alice@example.com"}}`,
|
||||
},
|
||||
{
|
||||
Schema: schema,
|
||||
Query: `{ user(id: "999") { name } }`,
|
||||
ExpectedErrors: []*gqlerrors.QueryError{
|
||||
{Message: "user not found", Extensions: map[string]any{"code": "NOT_FOUND"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
For HTTP-level tests:
|
||||
|
||||
```go
|
||||
func TestRelayHandler(t *testing.T) {
|
||||
body := `{"query":"{ user(id: \"1\") { name } }"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/graphql", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
relay.Handler{Schema: schema}.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Contains(t, w.Body.String(), `"Alice"`)
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Error Handling
|
||||
|
||||
Verify error extensions reach the client:
|
||||
|
||||
```go
|
||||
var resp struct {
|
||||
Errors []struct {
|
||||
Message string
|
||||
Extensions struct{ Code string }
|
||||
}
|
||||
}
|
||||
c.Post(`{ user(id: "999") { name } }`, &resp)
|
||||
require.Equal(t, "NOT_FOUND", resp.Errors[0].Extensions.Code)
|
||||
```
|
||||
|
||||
## Testing Auth Directives (gqlgen)
|
||||
|
||||
Test the directive function directly:
|
||||
|
||||
```go
|
||||
func TestHasRoleDirective(t *testing.T) {
|
||||
ctx := context.WithValue(context.Background(), userKey, &domain.User{Role: "USER"})
|
||||
_, err := HasRole(ctx, nil, func(ctx context.Context) (any, error) {
|
||||
return "ok", nil
|
||||
}, model.RoleAdmin)
|
||||
require.Error(t, err)
|
||||
|
||||
var gqlErr *gqlerror.Error
|
||||
require.True(t, errors.As(err, &gqlErr))
|
||||
require.Equal(t, "FORBIDDEN", gqlErr.Extensions["code"])
|
||||
}
|
||||
```
|
||||
|
||||
## Table-Driven Tests
|
||||
|
||||
```go
|
||||
func TestUserQueries(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
query string
|
||||
vars map[string]any
|
||||
wantCode string
|
||||
wantName string
|
||||
}{
|
||||
{"existing user", `query($id:ID!){user(id:$id){name}}`, map[string]any{"id": "1"}, "", "Alice"},
|
||||
{"missing user", `query($id:ID!){user(id:$id){name}}`, map[string]any{"id": "999"}, "NOT_FOUND", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var resp struct {
|
||||
User *struct{ Name string }
|
||||
Errors []struct {
|
||||
Extensions struct{ Code string }
|
||||
}
|
||||
}
|
||||
c.Post(tt.query, &resp, client.Var("id", tt.vars["id"]))
|
||||
if tt.wantCode != "" {
|
||||
require.Equal(t, tt.wantCode, resp.Errors[0].Extensions.Code)
|
||||
} else {
|
||||
require.Equal(t, tt.wantName, resp.User.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For testing patterns across the codebase, see the `samber/cc-skills-golang@golang-testing` skill.
|
||||
Reference in New Issue
Block a user