[teamai] Push 87 resource(s) from XingfenD

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