mcpField
G
graphql-api-design

graphql-api-design

github.com/seb1n開発232116 回インストール11 週間前に更新
リポジトリ

graphql-api-design について

graphql-api-design は、仕様・スキーマ・自然言語の説明から GraphQL API を設計する AI エージェント向けスキルです。API 開発者を対象に、スキーマ定義とリゾルバー実装を生成し、ページネーションや性能・セキュリティ対策も扱います。

主な機能

  1. 01オブジェクト・入力型・列挙型の設計
  2. 02クエリ・ミューテーション・サブスクリプションの設計
  3. 03Relay カーソルページネーション構造の生成
  4. 04DataLoader による N+1 問題の緩和
  5. 05深度と複雑度の制限
  6. 06スキーマフェデレーションの設計

活用シーン

  1. 01ドメインの説明から GraphQL スキーマを生成
  2. 02API のクエリやミューテーション用リゾルバーを実装
  3. 03リストフィールドのカーソルページネーションを設計
  4. 04関連データ取得を最適化し、過剰なクエリを制限

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/seb1n/awesome-ai-agent-skills --skill graphql-api-design

インストール後、Claude Code で/graphql-api-designを入力して読み込まれたか確認します。

詳細

GraphQL API の設計

このスキルを使うと、AI エージェントが仕様、スキーマ、または自然言語による説明から、完全な GraphQL API を設計できます。エージェントは型定義、クエリ、ミューテーション、サブスクリプション、入力型、列挙型、およびリゾルバーの実装を生成します。N+1 問題を防ぐ DataLoader、Relay の Connection 仕様によるカーソルベースのページネーション、クエリ深度の制限、マイクロサービスアーキテクチャ向けのスキーマフェデレーションなど、パフォーマンス向上のパターンも適用します。

ワークフロー

  1. ドメインを型としてモデル化します: アプリケーションのドメインを分析し、GraphQL のオブジェクト型、入力型、列挙型、インターフェース、ユニオンを定義します。各型は実際のエンティティを表し、データの利用者が実際に必要とするフィールドを含める必要があります。非 null(!)の注釈は慎重に使い、実際に値が存在しない場合があるフィールドは nullable にします。スキーマを自己記述的にするため、生の String よりも具体的なスカラー型(例:DateTime、URL)を優先します。

  2. クエリとミューテーションを設計します: 読み取り操作には Query フィールドを、書き込み操作には Mutation フィールドを定義します。クエリには名詞ベースの名前(user、posts)を使い、ミューテーションには動詞ベースの名前(createPost、updateUser)を使います。各ミューテーションは単一の入力型引数を受け取り、変更されたオブジェクトとユーザー向けのエラーを含むペイロード型を返すようにします。このパターンにより、ミューテーションの一貫性と拡張性が保たれます。

  3. Connection を使ってページネーションを実装します: 多数の項目を返す可能性があるリストフィールドには、edges、node、cursor、pageInfo を含む Relay Connection 仕様を使います。これにより、オフセットベースのページネーションとは異なり、挿入や削除があっても安定したカーソルベースのページネーションが可能になります。生の配列を返すのではなく、エンティティごとに再利用可能な Connection 型を定義します。

  4. DataLoader を使ってリゾルバーを記述します: 1 回のリクエスト内でデータベースの検索をまとめてキャッシュする DataLoader を使うリゾルバーを実装します。DataLoader がない場合、50 件の投稿とその作成者を取得するクエリでは、作成者を検索するクエリが 50 回個別に実行されます(N+1 問題)。DataLoader はこれらを 1 回のバッチクエリにまとめます。ユーザー間でデータが漏洩しないよう、リクエストごとに新しい DataLoader インスタンスを作成します。

  5. リアルタイムデータ用のサブスクリプションを追加します: クライアントがリアルタイムで反応する必要のあるイベント(例:新しいメッセージ、ステータスの変更)に対して Subscription フィールドを定義します。イベントの配信には、Pub/Sub バックエンド(Redis、Kafka、または開発用のインメモリ)を使います。サブスクリプションのペイロードは最小限に抑えます。必要に応じて、クライアントはサブスクリプションをトリガーとして全データを再取得できます。

  6. スキーマを保護し、最適化します: 悪意のあるクエリを防ぐため、クエリ深度の制限(最大 10~15 レベル)とクエリ複雑度の分析を追加します。リゾルバーでフィールド単位の認可を実装します。本番環境では、帯域幅を削減し、任意のクエリ実行を防ぐために、永続化クエリを使います。API が複数のサービスにまたがる場合は、スキーマフェデレーションを検討します。

対応テクノロジー

  • サーバー: Apollo Server、GraphQL Yoga、Mercurius(Fastify)、Strawberry(Python)、graphql-java
  • スキーマツール: SDL ファースト(typeDefs)、コードファースト(TypeGraphQL、Nexus、Pothos)
  • パフォーマンス: DataLoader、@defer/@stream ディレクティブ、永続化クエリ、自動永続化クエリ(APQ)
  • フェデレーション: Apollo Federation、GraphQL Mesh、スキーマスティッチング
  • テスト: GraphQL Playground、Apollo Studio、graphql-test(jest)、Insomnia

使用方法

データエンティティ、その関係、および必要な操作についての説明をエージェントに提供します。エージェントは完全な SDL スキーマ、リゾルバーの実装、DataLoader の設定を生成します。SDL ファーストとコードファーストのどちらの出力にするか、また対象とするサーバーフレームワークを指定してください。

例

例 1:リゾルバーを備えたブログプラットフォームのスキーマ
# schema.graphql — Complete blog platform schema

scalar DateTime

enum PostStatus {
  DRAFT
  PUBLISHED
  ARCHIVED
}

type User {
  id: ID!
  username: String!
  email: String!
  bio: String
  avatarUrl: String
  posts(first: Int, after: String): PostConnection!
  createdAt: DateTime!
}

type Post {
  id: ID!
  title: String!
  slug: String!
  content: String!
  excerpt: String
  status: PostStatus!
  author: User!
  tags: [Tag!]!
  comments(first: Int, after: String): CommentConnection!
  publishedAt: DateTime
  createdAt: DateTime!
  updatedAt: DateTime!
}

type Comment {
  id: ID!
  body: String!
  author: User!
  post: Post!
  createdAt: DateTime!
}

type Tag {
  id: ID!
  name: String!
  slug: String!
  posts(first: Int, after: String): PostConnection!
}

# Relay connection types for cursor-based pagination
type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

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

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

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

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

# Queries
type Query {
  post(id: ID, slug: String): Post
  posts(
    first: Int = 10
    after: String
    status: PostStatus
    tagSlug: String
  ): PostConnection!
  user(id: ID!): User
  me: User
  tags: [Tag!]!
}

# Mutations with input types and payload types
input CreatePostInput {
  title: String!
  content: String!
  tagIds: [ID!]
  status: PostStatus = DRAFT
}

type CreatePostPayload {
  post: Post
  errors: [MutationError!]!
}

input UpdatePostInput {
  title: String
  content: String
  status: PostStatus
  tagIds: [ID!]
}

type UpdatePostPayload {
  post: Post
  errors: [MutationError!]!
}

type MutationError {
  field: String
  message: String!
}

type Mutation {
  createPost(input: CreatePostInput!): CreatePostPayload!
  updatePost(id: ID!, input: UpdatePostInput!): UpdatePostPayload!
  deletePost(id: ID!): Boolean!
  addComment(postId: ID!, body: String!): Comment!
}

# Subscriptions
type Subscription {
  commentAdded(postId: ID!): Comment!
  postPublished: Post!
}
// resolvers.js — Resolvers with DataLoader for N+1 prevention
const DataLoader = require("dataloader");

// Create loaders per request (called from context factory)
function createLoaders(db) {
  return {
    userLoader: new DataLoader(async (userIds) => {
      const users = await db.users.findByIds(userIds);
      const userMap = new Map(users.map((u) => [u.id, u]));
      return userIds.map((id) => userMap.get(id) || null);
    }),
    postLoader: new DataLoader(async (postIds) => {
      const posts = await db.posts.findByIds(postIds);
      const postMap = new Map(posts.map((p) => [p.id, p]));
      return postIds.map((id) => postMap.get(id) || null);
    }),
  };
}

const resolvers = {
  Query: {
    post: (_, { id, slug }, { db }) => {
      if (id) return db.posts.findById(id);
      if (slug) return db.posts.findBySlug(slug);
      return null;
    },
    posts: async (_, { first = 10, after, status, tagSlug }, { db }) => {
      const cursor = after ? decodeCursor(after) : null;
      const { rows, totalCount } = await db.posts.findPaginated({
        limit: first + 1,
        cursor,
        status,
        tagSlug,
      });
      const hasNextPage = rows.length > first;
      const edges = rows.slice(0, first).map((post) => ({
        cursor: encodeCursor(post.id),
        node: post,
      }));
      return {
        edges,
        totalCount,
        pageInfo: {
          hasNextPage,
          hasPreviousPage: !!after,
          startCursor: edges[0]?.cursor || null,
          endCursor: edges[edges.length - 1]?.cursor || null,
        },
      };
    },
    me: (_, __, { currentUser }) => currentUser,
  },
  Post: {
    author: (post, _, { loaders }) => loaders.userLoader.load(post.authorId),
    tags: (post, _, { db }) => db.tags.findByPostId(post.id),
  },
  Comment: {
    author: (comment, _, { loaders }) => loaders.userLoader.load(comment.authorId),
  },
  Mutation: {
    createPost: async (_, { input }, { currentUser, db }) => {
      if (!currentUser) return { post: null, errors: [{ message: "Not authenticated" }] };
      if (!input.title.trim()) {
        return { post: null, errors: [{ field: "title", message: "Title cannot be empty" }] };
      }
      const post = await db.posts.create({ ...input, authorId: currentUser.id });
      return { post, errors: [] };
    },
  },
};

function encodeCursor(id) { return Buffer.from(`cursor:${id}`).toString("base64"); }
function decodeCursor(cursor) { return Buffer.from(cursor, "base64").toString().replace("cursor:", ""); }
例 2:カーソルベースのページネーションの実装
// pagination.js — Reusable cursor-based pagination for any entity

/**
 * Generic paginated query builder for SQL databases.
 * Works with any table that has an auto-incrementing or sortable ID.
 */
async function paginatedQuery(db, { table, first = 10, after, where = {} }) {
  const limit = Math.min(first, 100); // Cap at 100 per page
  const conditions = [];
  const params = [];

  // Apply cursor (decode to original ID)
  if (after) {
    const cursorId = Buffer.from(after, "base64").toString().split(":")[1];
    conditions.push(`id < $${params.length + 1}`);
    params.push(cursorId);
  }

  // Apply additional filters
  for (const [key, value] of Object.entries(where)) {
    if (value !== undefined) {
      conditions.push(`${key} = $${params.length + 1}`);
      params.push(value);
    }
  }

  const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";

  // Fetch one extra row to determine hasNextPage
  const query = `SELECT * FROM ${table} ${whereClause} ORDER BY id DESC LIMIT ${limit + 1}`;
  const rows = await db.query(query, params);

  // Count total matching rows
  const countQuery = `SELECT COUNT(*) as total FROM ${table} ${whereClause}`;
  const [{ total: totalCount }] = await db.query(countQuery, params);

  const hasNextPage = rows.length > limit;
  const nodes = rows.slice(0, limit);

  const edges = nodes.map((node) => ({
    cursor: Buffer.from(`cursor:${node.id}`).toString("base64"),
    node,
  }));

  return {
    edges,
    totalCount,
    pageInfo: {
      hasNextPage,
      hasPreviousPage: !!after,
      startCursor: edges[0]?.cursor || null,
      endCursor: edges[edges.length - 1]?.cursor || null,
    },
  };
}

// Usage in resolver
const resolvers = {
  Query: {
    posts: (_, args, { db }) =>
      paginatedQuery(db, {
        table: "posts",
        first: args.first,
        after: args.after,
        where: { status: args.status },
      }),
  },
};

ベストプラクティス

  • ミューテーションの一貫性を保つため、常に単一の input 引数を使用し、結果とユーザー向けエラーのリストの両方を含むペイロード型を返します。これにより、クライアントコードの予測可能性が高まります。
  • すべてのリレーションシップリゾルバーで DataLoader を使用して N+1 問題を解決します。 ユーザーやリクエスト間でキャッシュデータが漏えいしないよう、リクエストごとに(コンテキストファクトリーで)DataLoader インスタンスを作成します。
  • サービス拒否攻撃を防ぐため、クエリの深さと複雑さを制限します。 最大深度を 10~15 に設定し、フィールド(特にコネクションとネストされたリレーションシップ)に複雑さのコストを割り当てます。
  • 単一エンティティのクエリには nullable な戻り値の型(post(id: ID!): Post は見つからない場合に null を返します)を使用し、リストクエリには non-nullable な配列(tags: [Tag!]! は空の場合も含め、常に配列を返します)を使用します。
  • URL のバージョニングではなく、スキーマの進化によってバージョン管理します。 新しいフィールドは自由に追加し(非破壊的)、古いフィールドは @deprecated(reason: "Use newField instead") で非推奨にして、クライアントの移行後に削除します。
  • 個別のスカラー引数を渡すのではなく、すべてのミューテーション引数に入力型を使用します。 これにより、既存のクライアントを壊すことなく、後からオプションのフィールドを簡単に追加できます。

エッジケース

  • 循環参照: User -> Posts -> Author -> Posts のような型は循環スキーマを作成します。これは GraphQL では有効ですが、無限クエリを防ぐために深度制限が必要です。DataLoader によって無限の解決ループを防止できます。
  • null の伝播: non-nullable フィールドのリゾルバーがエラーをスローすると、null は最も近い nullable な親まで伝播します。1 つのフィールドのエラーによってレスポンス全体が null にならないよう、nullable の境界を慎重に設計します。
  • 空のコネクション: 結果セットが空の場合は null ではなく、{ edges: [], pageInfo: { hasNextPage: false, hasPreviousPage: false }, totalCount: 0 } を返します。
  • カーソルの安定性: カーソルは不透明で、挿入後も安定している必要があります。行 ID をカーソル値として使用する方法(base64 エンコード)は安定していますが、オフセットを使用する方法は安定せず、項目の挿入や削除で機能しなくなります。
  • ファイルのアップロード: GraphQL はファイルのアップロードをネイティブにはサポートしていません。マルチパートリクエスト仕様(graphql-upload)を使用するか、別の REST エンドポイントでアップロードを処理し、結果の URL をミューテーションに渡します。
  • サブスクリプション接続の切断: クライアントの WebSocket 接続が失われることがあります。サブスクリプションストリームだけに頼らず、再接続時に再クエリして状態を復元できるよう、サブスクリプションを設計します。

コメント