mcpField
G
graphql-api-design

graphql-api-design

github.com/seb1n开发232116 次安装11 周前更新
代码仓库

关于 graphql-api-design

graphql-api-design 是帮助 AI Agent 根据规格、模式或自然语言描述设计 GraphQL API 的技能,面向 API 开发者。它可生成模式定义与解析器实现,并涵盖分页、性能优化和安全控制。

主要功能

  1. 01建模对象、输入类型与枚举
  2. 02设计查询、变更和订阅
  3. 03生成 Relay 游标分页结构
  4. 04使用 DataLoader 缓解 N+1
  5. 05加入深度与复杂度限制
  6. 06支持模式联邦设计

适用场景

  1. 01根据领域描述生成 GraphQL 模式
  2. 02为 API 查询和变更实现解析器
  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 Agent 根据规范、模式或自然语言描述设计完整的 GraphQL API。Agent 会生成类型定义、查询、变更、订阅、输入类型、枚举和解析器实现,并应用性能模式,包括使用 DataLoader 防止 N+1 问题、通过 Relay connection 规范实现基于游标的分页、限制查询深度,以及面向微服务架构的模式联邦。

工作流

  1. 将领域建模为类型: 分析应用领域,并定义 GraphQL 对象类型、输入类型、枚举、接口和联合类型。每种类型都应表示一个实际实体,其字段应符合数据使用者的实际需求。谨慎使用非空(!)注解——确实可能缺失的字段应设为可空。优先使用具体的标量类型(例如 DateTime、URL),而非原始 String,以便模式自我说明。

  2. 设计查询和变更: 为读取操作定义 Query 字段,为写入操作定义 Mutation 字段。查询应使用名词命名(user、posts),变更则应使用动词命名(createPost、updateUser)。每个变更都应接受一个输入类型参数,并返回一个负载类型,其中包含已修改的对象以及面向用户的错误。这种模式可让变更保持一致且易于扩展。

  3. 使用连接实现分页: 对于任何可能返回大量条目的列表字段,都应使用包含 edges、node、cursor 和 pageInfo 的 Relay connection 规范。与基于偏移量的分页不同,这种方式提供的基于游标的分页在插入和删除数据时仍保持稳定。应为每种实体定义可复用的连接类型,而不是直接返回原始数组。

  4. 使用 DataLoader 编写解析器: 实现使用 DataLoader 在单个请求内批处理并缓存数据库查询的解析器。如果不使用 DataLoader,获取 50 篇文章及其作者的查询会分别执行 50 次作者查询(即 N+1 问题)。DataLoader 会将这些查询合并为一次批量查询。每个请求都应新建一个 DataLoader 实例,以避免用户之间的数据泄露。

  5. 为实时数据添加订阅: 为客户端需要实时响应的事件定义 Subscription 字段(例如新消息、状态变更)。使用发布/订阅后端(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、Schema Stitching
  • 测试: GraphQL Playground、Apollo Studio、graphql-test(jest)、Insomnia

用法

向 Agent 提供数据实体、实体间关系以及所需操作的描述。Agent 会生成完整的 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。在每个请求的上下文工厂中创建 DataLoader 实例,避免在不同用户或请求之间泄漏缓存数据。
  • 限制查询深度和复杂度,以防止拒绝服务攻击。将最大深度设为 10-15,并为字段分配复杂度成本,尤其是连接和嵌套关系。
  • 单实体查询使用可空返回类型(post(id: ID!): Post 在未找到时返回 null),列表查询使用非空数组(tags: [Tag!]! 始终返回数组,也可能为空)。
  • 通过模式演进进行版本管理,而不是使用 URL 版本控制。 可自由添加新字段(不构成破坏性变更),使用 @deprecated(reason: "Use newField instead") 弃用旧字段,并在客户端完成迁移后将其移除。
  • 所有变更参数都使用输入类型,而不是传递单独的标量参数。这样以后添加可选字段时就不会破坏现有客户端。

边缘情况

  • 循环引用: User -> Posts -> Author -> Posts 之类的类型会创建循环模式。这在 GraphQL 中是有效的,但需要限制深度以防止无限查询。DataLoader 可以防止无限解析循环。
  • 空值传播: 如果非空字段解析器抛出错误,空值会向上传播到最近的可空父级。应谨慎设计可空边界,以免单个字段错误导致整个响应变为空。
  • 空连接: 对于空结果集,返回 { edges: [], pageInfo: { hasNextPage: false, hasPreviousPage: false }, totalCount: 0 },而不是 null。
  • 游标稳定性: 游标应保持不透明,并在插入操作前后稳定。使用行 ID 作为游标值(经 base64 编码)是稳定的;使用偏移量则不稳定,插入或删除项目时会出错。
  • 文件上传: GraphQL 原生不支持文件上传。可使用多部分请求规范(graphql-upload),或通过单独的 REST 端点处理上传,并将生成的 URL 传递给变更。
  • 订阅连接中断: 客户端可能会丢失 WebSocket 连接。设计订阅时,应确保客户端可以在重新连接后通过重新查询恢复状态,而不是仅依赖订阅流。

评论

支持 Markdown