GraphQL API - 解答例

本解答例では、gqlgenを使用した完全なGraphQL APIの実装を示します。ブログシステムを例に、Query、Mutation、Subscription、認証・認可、N+1問題の解決、テストまでを網羅します。

---

プロジェクト構成

blog-api/
├── graph/
│   ├── schema.graphqls          # GraphQLスキーマ定義
│   ├── schema.resolvers.go      # リゾルバー実装
│   ├── generated.go             # 自動生成コード
│   └── model/                   # モデル定義
├── dataloader/
│   └── dataloader.go            # Dataloader実装
├── middleware/
│   └── auth.go                  # 認証ミドルウェア
├── database/
│   └── db.go                    # データベース接続
├── gqlgen.yml                   # gqlgen設定
├── go.mod
├── go.sum
└── server.go                    # エントリーポイント

---

1. GraphQLスキーマ定義

schema.graphqls

# ===== スカラー型の定義 =====
scalar DateTime
scalar Upload

# ===== ユーザー型 =====
type User {
  id: ID!
  name: String!
  email: String!
  bio: String
  avatarUrl: String
  posts(first: Int, after: String): PostConnection!
  comments: [Comment!]!
  createdAt: DateTime!
  updatedAt: DateTime!
}

# ===== 投稿型 =====
type Post {
  id: ID!
  title: String!
  content: String!
  published: Boolean!
  author: User!
  comments(first: Int, after: String): CommentConnection!
  tags: [Tag!]!
  viewCount: Int!
  likeCount: Int!
  createdAt: DateTime!
  updatedAt: DateTime!
}

# ===== コメント型 =====
type Comment {
  id: ID!
  content: String!
  author: User!
  post: Post!
  createdAt: DateTime!
  updatedAt: DateTime!
}

# ===== タグ型 =====
type Tag {
  id: ID!
  name: String!
  posts(first: Int, after: String): PostConnection!
}

# ===== ページネーション(Relay Connection仕様) =====
type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type PostEdge {
  node: Post!
  cursor: String!
}

type CommentConnection {
  edges: [CommentEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type CommentEdge {
  node: Comment!
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

# ===== Query =====
type Query {
  # ユーザー関連
  me: User
  user(id: ID!): User
  users(first: Int, after: String): [User!]!

  # 投稿関連
  post(id: ID!): Post
  posts(
    first: Int
    after: String
    published: Boolean
    tag: String
    search: String
  ): PostConnection!

  # タグ関連
  tag(id: ID!): Tag
  tags: [Tag!]!
}

# ===== Mutation =====
type Mutation {
  # ユーザー認証
  signup(input: SignupInput!): AuthPayload!
  login(input: LoginInput!): AuthPayload!

  # ユーザー管理
  updateUser(input: UpdateUserInput!): User!
  uploadAvatar(file: Upload!): User!

  # 投稿管理
  createPost(input: CreatePostInput!): Post!
  updatePost(id: ID!, input: UpdatePostInput!): Post!
  deletePost(id: ID!): Boolean!
  publishPost(id: ID!): Post!

  # コメント管理
  createComment(input: CreateCommentInput!): Comment!
  updateComment(id: ID!, input: UpdateCommentInput!): Comment!
  deleteComment(id: ID!): Boolean!

  # いいね機能
  likePost(postId: ID!): Post!
  unlikePost(postId: ID!): Post!
}

# ===== Subscription =====
type Subscription {
  postCreated: Post!
  commentAdded(postId: ID!): Comment!
}

# ===== Input型 =====
input SignupInput {
  name: String!
  email: String!
  password: String!
}

input LoginInput {
  email: String!
  password: String!
}

input UpdateUserInput {
  name: String
  bio: String
}

input CreatePostInput {
  title: String!
  content: String!
  tags: [String!]
  published: Boolean
}

input UpdatePostInput {
  title: String
  content: String
  tags: [String!]
  published: Boolean
}

input CreateCommentInput {
  postId: ID!
  content: String!
}

input UpdateCommentInput {
  content: String!
}

# ===== 認証レスポンス =====
type AuthPayload {
  token: String!
  user: User!
}

# ===== ディレクティブ =====
directive @requireAuth on FIELD_DEFINITION
directive @hasRole(role: String!) on FIELD_DEFINITION

---

2. gqlgen設定

gqlgen.yml

# スキーマファイルの場所
schema:
  - graph/schema.graphqls

# 生成されるコードの出力先
exec:
  filename: graph/generated.go
  package: graph

# モデルの出力先
model:
  filename: graph/model/models_gen.go
  package: model

# リゾルバーの出力先
resolver:
  layout: follow-schema
  dir: graph
  package: graph
  filename_template: "{name}.resolvers.go"

# スカラー型のマッピング
models:
  ID:
    model:
      - github.com/99designs/gqlgen/graphql.ID
  Int:
    model:
      - github.com/99designs/gqlgen/graphql.Int
  DateTime:
    model:
      - time.Time
  Upload:
    model:
      - github.com/99designs/gqlgen/graphql.Upload

# 既存のモデル型を使用する場合
  User:
    model:
      - blog-api/database.User
  Post:
    model:
      - blog-api/database.Post
  Comment:
    model:
      - blog-api/database.Comment

# 自動生成をスキップするリゾルバー
skip_runtime:
  - Post.author
  - Comment.author
  - User.posts

---

3. リゾルバー実装

graph/schema.resolvers.go

package graph

import (
	"blog-api/database"
	"blog-api/dataloader"
	"blog-api/graph/model"
	"context"
	"errors"
	"fmt"
	"strconv"
	"time"

	"golang.org/x/crypto/bcrypt"
)

// Resolver はルートリゾルバー
type Resolver struct {
	DB           *database.DB
	DataLoader   *dataloader.Loaders
	Subscribers  map[string]chan *model.Post
}

// ===== Query Resolvers =====

func (r *queryResolver) Me(ctx context.Context) (*database.User, error) {
	// コンテキストから現在のユーザーを取得
	userID := ctx.Value("userID")
	if userID == nil {
		return nil, errors.New("not authenticated")
	}

	user, err := r.DB.GetUserByID(ctx, userID.(string))
	if err != nil {
		return nil, err
	}

	return user, nil
}

func (r *queryResolver) User(ctx context.Context, id string) (*database.User, error) {
	return r.DB.GetUserByID(ctx, id)
}

func (r *queryResolver) Users(ctx context.Context, first *int, after *string) ([]*database.User, error) {
	limit := 10
	if first != nil {
		limit = *first
	}

	offset := 0
	if after != nil {
		offset, _ = strconv.Atoi(*after)
	}

	return r.DB.GetUsers(ctx, limit, offset)
}

func (r *queryResolver) Post(ctx context.Context, id string) (*database.Post, error) {
	// 閲覧数をインクリメント
	if err := r.DB.IncrementViewCount(ctx, id); err != nil {
		// エラーはログに記録するが、クエリは継続
		fmt.Printf("failed to increment view count: %v\n", err)
	}

	return r.DB.GetPostByID(ctx, id)
}

func (r *queryResolver) Posts(
	ctx context.Context,
	first *int,
	after *string,
	published *bool,
	tag *string,
	search *string,
) (*model.PostConnection, error) {
	limit := 10
	if first != nil {
		limit = *first
	}

	offset := 0
	if after != nil {
		offset, _ = strconv.Atoi(*after)
	}

	// フィルター条件を構築
	filter := database.PostFilter{
		Published: published,
		Tag:       tag,
		Search:    search,
	}

	posts, total, err := r.DB.GetPosts(ctx, limit, offset, filter)
	if err != nil {
		return nil, err
	}

	// Relay Connection形式に変換
	edges := make([]*model.PostEdge, len(posts))
	for i, post := range posts {
		edges[i] = &model.PostEdge{
			Node:   post,
			Cursor: strconv.Itoa(offset + i + 1),
		}
	}

	hasNextPage := offset+len(posts) < total
	var endCursor *string
	if len(edges) > 0 {
		c := edges[len(edges)-1].Cursor
		endCursor = &c
	}

	return &model.PostConnection{
		Edges: edges,
		PageInfo: &model.PageInfo{
			HasNextPage:     hasNextPage,
			HasPreviousPage: offset > 0,
			StartCursor:     nil,
			EndCursor:       endCursor,
		},
		TotalCount: total,
	}, nil
}

func (r *queryResolver) Tag(ctx context.Context, id string) (*database.Tag, error) {
	return r.DB.GetTagByID(ctx, id)
}

func (r *queryResolver) Tags(ctx context.Context) ([]*database.Tag, error) {
	return r.DB.GetAllTags(ctx)
}

// ===== Mutation Resolvers =====

func (r *mutationResolver) Signup(ctx context.Context, input model.SignupInput) (*model.AuthPayload, error) {
	// パスワードをハッシュ化
	hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
	if err != nil {
		return nil, err
	}

	// ユーザー作成
	user := &database.User{
		Name:     input.Name,
		Email:    input.Email,
		Password: string(hashedPassword),
	}

	if err := r.DB.CreateUser(ctx, user); err != nil {
		return nil, err
	}

	// JWTトークン生成
	token, err := generateToken(user.ID)
	if err != nil {
		return nil, err
	}

	return &model.AuthPayload{
		Token: token,
		User:  user,
	}, nil
}

func (r *mutationResolver) Login(ctx context.Context, input model.LoginInput) (*model.AuthPayload, error) {
	user, err := r.DB.GetUserByEmail(ctx, input.Email)
	if err != nil {
		return nil, errors.New("invalid email or password")
	}

	// パスワード検証
	if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(input.Password)); err != nil {
		return nil, errors.New("invalid email or password")
	}

	// JWTトークン生成
	token, err := generateToken(user.ID)
	if err != nil {
		return nil, err
	}

	return &model.AuthPayload{
		Token: token,
		User:  user,
	}, nil
}

func (r *mutationResolver) UpdateUser(ctx context.Context, input model.UpdateUserInput) (*database.User, error) {
	userID := ctx.Value("userID")
	if userID == nil {
		return nil, errors.New("not authenticated")
	}

	user, err := r.DB.GetUserByID(ctx, userID.(string))
	if err != nil {
		return nil, err
	}

	// 更新
	if input.Name != nil {
		user.Name = *input.Name
	}
	if input.Bio != nil {
		user.Bio = input.Bio
	}

	if err := r.DB.UpdateUser(ctx, user); err != nil {
		return nil, err
	}

	return user, nil
}

func (r *mutationResolver) CreatePost(ctx context.Context, input model.CreatePostInput) (*database.Post, error) {
	userID := ctx.Value("userID")
	if userID == nil {
		return nil, errors.New("not authenticated")
	}

	published := false
	if input.Published != nil {
		published = *input.Published
	}

	post := &database.Post{
		Title:     input.Title,
		Content:   input.Content,
		AuthorID:  userID.(string),
		Published: published,
		CreatedAt: time.Now(),
		UpdatedAt: time.Now(),
	}

	if err := r.DB.CreatePost(ctx, post); err != nil {
		return nil, err
	}

	// タグを関連付け
	if input.Tags != nil {
		for _, tagName := range input.Tags {
			tag, err := r.DB.GetOrCreateTag(ctx, tagName)
			if err != nil {
				return nil, err
			}
			if err := r.DB.AddTagToPost(ctx, post.ID, tag.ID); err != nil {
				return nil, err
			}
		}
	}

	// Subscriptionに通知
	if published {
		r.notifyPostCreated(post)
	}

	return post, nil
}

func (r *mutationResolver) UpdatePost(ctx context.Context, id string, input model.UpdatePostInput) (*database.Post, error) {
	userID := ctx.Value("userID")
	if userID == nil {
		return nil, errors.New("not authenticated")
	}

	post, err := r.DB.GetPostByID(ctx, id)
	if err != nil {
		return nil, err
	}

	// 権限チェック:投稿者本人のみ更新可能
	if post.AuthorID != userID.(string) {
		return nil, errors.New("permission denied")
	}

	// 更新
	if input.Title != nil {
		post.Title = *input.Title
	}
	if input.Content != nil {
		post.Content = *input.Content
	}
	if input.Published != nil {
		post.Published = *input.Published
	}

	post.UpdatedAt = time.Now()

	if err := r.DB.UpdatePost(ctx, post); err != nil {
		return nil, err
	}

	return post, nil
}

func (r *mutationResolver) DeletePost(ctx context.Context, id string) (bool, error) {
	userID := ctx.Value("userID")
	if userID == nil {
		return false, errors.New("not authenticated")
	}

	post, err := r.DB.GetPostByID(ctx, id)
	if err != nil {
		return false, err
	}

	// 権限チェック
	if post.AuthorID != userID.(string) {
		return false, errors.New("permission denied")
	}

	if err := r.DB.DeletePost(ctx, id); err != nil {
		return false, err
	}

	return true, nil
}

func (r *mutationResolver) CreateComment(ctx context.Context, input model.CreateCommentInput) (*database.Comment, error) {
	userID := ctx.Value("userID")
	if userID == nil {
		return nil, errors.New("not authenticated")
	}

	comment := &database.Comment{
		PostID:    input.PostID,
		AuthorID:  userID.(string),
		Content:   input.Content,
		CreatedAt: time.Now(),
		UpdatedAt: time.Now(),
	}

	if err := r.DB.CreateComment(ctx, comment); err != nil {
		return nil, err
	}

	return comment, nil
}

func (r *mutationResolver) LikePost(ctx context.Context, postID string) (*database.Post, error) {
	userID := ctx.Value("userID")
	if userID == nil {
		return nil, errors.New("not authenticated")
	}

	if err := r.DB.AddLike(ctx, postID, userID.(string)); err != nil {
		return nil, err
	}

	return r.DB.GetPostByID(ctx, postID)
}

// ===== Field Resolvers(N+1問題を回避) =====

func (r *postResolver) Author(ctx context.Context, obj *database.Post) (*database.User, error) {
	// Dataloaderを使用してバッチ処理
	return r.DataLoader.UserLoader.Load(ctx, obj.AuthorID)
}

func (r *postResolver) Comments(ctx context.Context, obj *database.Post, first *int, after *string) (*model.CommentConnection, error) {
	limit := 10
	if first != nil {
		limit = *first
	}

	comments, err := r.DB.GetCommentsByPostID(ctx, obj.ID, limit)
	if err != nil {
		return nil, err
	}

	edges := make([]*model.CommentEdge, len(comments))
	for i, comment := range comments {
		edges[i] = &model.CommentEdge{
			Node:   comment,
			Cursor: strconv.Itoa(i),
		}
	}

	return &model.CommentConnection{
		Edges: edges,
		PageInfo: &model.PageInfo{
			HasNextPage:     false,
			HasPreviousPage: false,
		},
		TotalCount: len(comments),
	}, nil
}

func (r *postResolver) Tags(ctx context.Context, obj *database.Post) ([]*database.Tag, error) {
	return r.DB.GetTagsByPostID(ctx, obj.ID)
}

func (r *commentResolver) Author(ctx context.Context, obj *database.Comment) (*database.User, error) {
	return r.DataLoader.UserLoader.Load(ctx, obj.AuthorID)
}

func (r *userResolver) Posts(ctx context.Context, obj *database.User, first *int, after *string) (*model.PostConnection, error) {
	limit := 10
	if first != nil {
		limit = *first
	}

	posts, total, err := r.DB.GetPostsByAuthorID(ctx, obj.ID, limit)
	if err != nil {
		return nil, err
	}

	edges := make([]*model.PostEdge, len(posts))
	for i, post := range posts {
		edges[i] = &model.PostEdge{
			Node:   post,
			Cursor: strconv.Itoa(i),
		}
	}

	return &model.PostConnection{
		Edges: edges,
		PageInfo: &model.PageInfo{
			HasNextPage:     false,
			HasPreviousPage: false,
		},
		TotalCount: total,
	}, nil
}

// ===== Subscription Resolvers =====

func (r *subscriptionResolver) PostCreated(ctx context.Context) (<-chan *database.Post, error) {
	posts := make(chan *database.Post, 1)

	// サブスクライバーを登録
	id := fmt.Sprintf("%d", time.Now().UnixNano())
	r.Subscribers[id] = posts

	// コンテキストがキャンセルされたらクリーンアップ
	go func() {
		<-ctx.Done()
		delete(r.Subscribers, id)
		close(posts)
	}()

	return posts, nil
}

func (r *Resolver) notifyPostCreated(post *database.Post) {
	for _, ch := range r.Subscribers {
		select {
		case ch <- post:
		default:
			// チャンネルがブロックされている場合はスキップ
		}
	}
}

// ===== ヘルパー関数 =====

func generateToken(userID string) (string, error) {
	// 実際の実装ではJWTライブラリを使用
	// ここでは簡略化のため固定値を返す
	return "jwt_token_" + userID, nil
}

// ===== Resolver Type Assertions =====

func (r *Resolver) Query() QueryResolver       { return &queryResolver{r} }
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }
func (r *Resolver) Post() PostResolver         { return &postResolver{r} }
func (r *Resolver) Comment() CommentResolver   { return &commentResolver{r} }
func (r *Resolver) User() UserResolver         { return &userResolver{r} }
func (r *Resolver) Subscription() SubscriptionResolver {
	return &subscriptionResolver{r}
}

type queryResolver struct{ *Resolver }
type mutationResolver struct{ *Resolver }
type postResolver struct{ *Resolver }
type commentResolver struct{ *Resolver }
type userResolver struct{ *Resolver }
type subscriptionResolver struct{ *Resolver }

---

4. Dataloader実装(N+1問題の解決)

dataloader/dataloader.go

package dataloader

import (
	"blog-api/database"
	"context"
	"time"

	"github.com/vikstrous/dataloadgen"
)

type Loaders struct {
	UserLoader    *dataloadgen.Loader[string, *database.User]
	PostLoader    *dataloadgen.Loader[string, *database.Post]
	CommentLoader *dataloadgen.Loader[string, *database.Comment]
}

func NewLoaders(db *database.DB) *Loaders {
	return &Loaders{
		UserLoader: dataloadgen.NewLoader(
			func(ctx context.Context, keys []string) ([]*database.User, []error) {
				users, err := db.GetUsersByIDs(ctx, keys)
				if err != nil {
					errors := make([]error, len(keys))
					for i := range errors {
						errors[i] = err
					}
					return nil, errors
				}

				// キーの順序に合わせて結果を並べ替え
				userMap := make(map[string]*database.User)
				for _, user := range users {
					userMap[user.ID] = user
				}

				result := make([]*database.User, len(keys))
				errors := make([]error, len(keys))
				for i, key := range keys {
					result[i] = userMap[key]
				}

				return result, errors
			},
			dataloadgen.WithWait(time.Millisecond),
			dataloadgen.WithBatchCapacity(100),
		),

		PostLoader: dataloadgen.NewLoader(
			func(ctx context.Context, keys []string) ([]*database.Post, []error) {
				posts, err := db.GetPostsByIDs(ctx, keys)
				if err != nil {
					errors := make([]error, len(keys))
					for i := range errors {
						errors[i] = err
					}
					return nil, errors
				}

				postMap := make(map[string]*database.Post)
				for _, post := range posts {
					postMap[post.ID] = post
				}

				result := make([]*database.Post, len(keys))
				errors := make([]error, len(keys))
				for i, key := range keys {
					result[i] = postMap[key]
				}

				return result, errors
			},
			dataloadgen.WithWait(time.Millisecond),
			dataloadgen.WithBatchCapacity(100),
		),
	}
}

// ミドルウェア:リクエストごとにDataloaderを初期化
func Middleware(db *database.DB) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			loaders := NewLoaders(db)
			ctx := context.WithValue(r.Context(), "dataloaders", loaders)
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

---

5. 認証ミドルウェア

middleware/auth.go

package middleware

import (
	"context"
	"net/http"
	"strings"

	"github.com/golang-jwt/jwt/v5"
)

var jwtSecret = []byte("your-secret-key") // 実際は環境変数から取得

func AuthMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Authorizationヘッダーからトークンを取得
		authHeader := r.Header.Get("Authorization")
		if authHeader == "" {
			// トークンがない場合は認証なしで続行
			next.ServeHTTP(w, r)
			return
		}

		// "Bearer <token>" 形式からトークンを抽出
		tokenString := strings.Replace(authHeader, "Bearer ", "", 1)

		// トークンを検証
		token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
			return jwtSecret, nil
		})

		if err != nil || !token.Valid {
			http.Error(w, "Invalid token", http.StatusUnauthorized)
			return
		}

		// クレームからユーザーIDを取得
		claims, ok := token.Claims.(jwt.MapClaims)
		if !ok {
			http.Error(w, "Invalid token claims", http.StatusUnauthorized)
			return
		}

		userID := claims["user_id"].(string)

		// コンテキストにユーザーIDを設定
		ctx := context.WithValue(r.Context(), "userID", userID)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

---

6. サーバーのエントリーポイント

server.go

package main

import (
	"blog-api/database"
	"blog-api/dataloader"
	"blog-api/graph"
	"blog-api/middleware"
	"log"
	"net/http"
	"os"

	"github.com/99designs/gqlgen/graphql/handler"
	"github.com/99designs/gqlgen/graphql/handler/extension"
	"github.com/99designs/gqlgen/graphql/handler/transport"
	"github.com/99designs/gqlgen/graphql/playground"
	"github.com/gorilla/websocket"
	"github.com/rs/cors"
)

const defaultPort = "8080"

func main() {
	port := os.Getenv("PORT")
	if port == "" {
		port = defaultPort
	}

	// データベース接続
	db, err := database.NewDB("postgres://user:pass@localhost/blog")
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	// リゾルバーの初期化
	resolver := &graph.Resolver{
		DB:          db,
		Subscribers: make(map[string]chan *database.Post),
	}

	// GraphQLサーバーの設定
	srv := handler.New(graph.NewExecutableSchema(graph.Config{
		Resolvers: resolver,
	}))

	// WebSocketサポート(Subscription用)
	srv.AddTransport(transport.Websocket{
		Upgrader: websocket.Upgrader{
			CheckOrigin: func(r *http.Request) bool {
				return true
			},
		},
	})
	srv.AddTransport(transport.POST{})
	srv.AddTransport(transport.Options{})

	// 拡張機能の追加
	srv.Use(extension.Introspection{}) // イントロスペクション有効化
	srv.Use(extension.FixedComplexityLimit(1000)) // クエリ複雑度制限
	srv.Use(extension.DepthLimit(5)) // クエリ深度制限

	// 本番環境ではイントロスペクションを無効化
	if os.Getenv("ENV") == "production" {
		srv.Use(extension.DisableIntrospection())
	}

	// ミドルウェアの設定
	handler := middleware.AuthMiddleware(srv)
	handler = dataloader.Middleware(db)(handler)

	// CORS設定
	c := cors.New(cors.Options{
		AllowedOrigins:   []string{"http://localhost:3000"},
		AllowCredentials: true,
		AllowedHeaders:   []string{"Authorization", "Content-Type"},
	})
	handler = c.Handler(handler)

	// ルーティング
	http.Handle("/", playground.Handler("GraphQL Playground", "/query"))
	http.Handle("/query", handler)

	log.Printf("Server running at http://localhost:%s/", port)
	log.Printf("GraphQL Playground: http://localhost:%s/", port)
	log.Fatal(http.ListenAndServe(":"+port, nil))
}

---

7. テストコード

graph/resolver_test.go

package graph_test

import (
	"blog-api/database"
	"blog-api/graph"
	"blog-api/graph/model"
	"context"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestSignup(t *testing.T) {
	db := database.NewTestDB(t)
	defer db.Close()

	resolver := &graph.Resolver{DB: db}
	mutationResolver := resolver.Mutation()

	input := model.SignupInput{
		Name:     "Test User",
		Email:    "test@example.com",
		Password: "password123",
	}

	result, err := mutationResolver.Signup(context.Background(), input)

	require.NoError(t, err)
	assert.NotEmpty(t, result.Token)
	assert.Equal(t, "Test User", result.User.Name)
	assert.Equal(t, "test@example.com", result.User.Email)
}

func TestCreatePost(t *testing.T) {
	db := database.NewTestDB(t)
	defer db.Close()

	// テストユーザーを作成
	user := &database.User{
		ID:    "user1",
		Name:  "Test User",
		Email: "test@example.com",
	}
	db.CreateUser(context.Background(), user)

	resolver := &graph.Resolver{DB: db}
	mutationResolver := resolver.Mutation()

	// 認証コンテキストを設定
	ctx := context.WithValue(context.Background(), "userID", user.ID)

	published := true
	input := model.CreatePostInput{
		Title:     "Test Post",
		Content:   "This is a test post",
		Published: &published,
	}

	post, err := mutationResolver.CreatePost(ctx, input)

	require.NoError(t, err)
	assert.Equal(t, "Test Post", post.Title)
	assert.Equal(t, user.ID, post.AuthorID)
	assert.True(t, post.Published)
}

func TestDataloaderBatching(t *testing.T) {
	db := database.NewTestDB(t)
	defer db.Close()

	// テストデータの準備
	users := []*database.User{
		{ID: "1", Name: "User 1"},
		{ID: "2", Name: "User 2"},
	}
	for _, u := range users {
		db.CreateUser(context.Background(), u)
	}

	posts := []*database.Post{
		{ID: "1", Title: "Post 1", AuthorID: "1"},
		{ID: "2", Title: "Post 2", AuthorID: "2"},
	}
	for _, p := range posts {
		db.CreatePost(context.Background(), p)
	}

	// Dataloaderを使用したクエリ
	loaders := dataloader.NewLoaders(db)
	ctx := context.Background()

	// 複数の投稿の著者を同時に取得
	author1, _ := loaders.UserLoader.Load(ctx, "1")
	author2, _ := loaders.UserLoader.Load(ctx, "2")

	assert.Equal(t, "User 1", author1.Name)
	assert.Equal(t, "User 2", author2.Name)

	// Dataloaderが適切にバッチ処理していることを確認
	// (実際の実装ではモックDBでクエリ回数を計測)
}

---

まとめ

この解答例では、以下を実装しました:

  • 完全なGraphQLスキーマ: Query、Mutation、Subscription、ページネーション
  • gqlgen設定: スキーマファースト開発の設定
  • リゾルバー実装: 認証、認可、CRUD操作
  • Dataloader: N+1問題の解決
  • 認証ミドルウェア: JWT認証の実装
  • サーバー設定: WebSocket、CORS、セキュリティ設定
  • テストコード: ユニットテストの例

これらを組み合わせることで、実務レベルのGraphQL APIを構築できます。