From 969bd825d71bdc6985d4209f86529680f324325a Mon Sep 17 00:00:00 2001 From: Samuel Berthe Date: Fri, 1 May 2026 03:47:31 +0200 Subject: [PATCH] feat(golang-graphql): write full skill body with references and evals (#32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- EVALUATIONS.md | 3 +- README.md | 2 +- skills/golang-graphql/SKILL.md | 253 +++++++++++++++- skills/golang-graphql/evals/evals.json | 168 +++++++++++ skills/golang-graphql/references/gqlgen.md | 274 ++++++++++++++++++ .../golang-graphql/references/graphql-go.md | 255 ++++++++++++++++ skills/golang-graphql/references/testing.md | 180 ++++++++++++ 7 files changed, 1132 insertions(+), 3 deletions(-) create mode 100644 skills/golang-graphql/evals/evals.json create mode 100644 skills/golang-graphql/references/gqlgen.md create mode 100644 skills/golang-graphql/references/graphql-go.md create mode 100644 skills/golang-graphql/references/testing.md diff --git a/EVALUATIONS.md b/EVALUATIONS.md index cee961f..bd0a088 100644 --- a/EVALUATIONS.md +++ b/EVALUATIONS.md @@ -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 @@
Full breakdown (60 assertions) -**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 | | ---- | ------------------------------------------------------------------------------------------------------- | ------------------------------ | ------------------------------ | diff --git a/README.md b/README.md index 1061d90..1937aff 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/skills/golang-graphql/SKILL.md b/skills/golang-graphql/SKILL.md index 739b900..fc36308 100644 --- a/skills/golang-graphql/SKILL.md +++ b/skills/golang-graphql/SKILL.md @@ -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..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. diff --git a/skills/golang-graphql/evals/evals.json b/skills/golang-graphql/evals/evals.json new file mode 100644 index 0000000..67f0339 --- /dev/null +++ b/skills/golang-graphql/evals/evals.json @@ -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..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..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..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": [] + } + ] +} diff --git a/skills/golang-graphql/references/gqlgen.md b/skills/golang-graphql/references/gqlgen.md new file mode 100644 index 0000000..1772736 --- /dev/null +++ b/skills/golang-graphql/references/gqlgen.md @@ -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..model` — override which Go type backs a GraphQL type +- `fields..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)) +``` diff --git a/skills/golang-graphql/references/graphql-go.md b/skills/golang-graphql/references/graphql-go.md new file mode 100644 index 0000000..a98bfab --- /dev/null +++ b/skills/golang-graphql/references/graphql-go.md @@ -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 | diff --git a/skills/golang-graphql/references/testing.md b/skills/golang-graphql/references/testing.md new file mode 100644 index 0000000..b4a3ed8 --- /dev/null +++ b/skills/golang-graphql/references/testing.md @@ -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.