关于 graphql-api-design
graphql-api-design 是帮助 AI Agent 根据规格、模式或自然语言描述设计 GraphQL API 的技能,面向 API 开发者。它可生成模式定义与解析器实现,并涵盖分页、性能优化和安全控制。
主要功能
- 01建模对象、输入类型与枚举
- 02设计查询、变更和订阅
- 03生成 Relay 游标分页结构
- 04使用 DataLoader 缓解 N+1
- 05加入深度与复杂度限制
- 06支持模式联邦设计
适用场景
- 01根据领域描述生成 GraphQL 模式
- 02为 API 查询和变更实现解析器
- 03为列表接口设计游标分页
- 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 规范实现基于游标的分页、限制查询深度,以及面向微服务架构的模式联邦。
工作流
-
将领域建模为类型: 分析应用领域,并定义 GraphQL 对象类型、输入类型、枚举、接口和联合类型。每种类型都应表示一个实际实体,其字段应符合数据使用者的实际需求。谨慎使用非空(
!)注解——确实可能缺失的字段应设为可空。优先使用具体的标量类型(例如DateTime、URL),而非原始String,以便模式自我说明。 -
设计查询和变更: 为读取操作定义 Query 字段,为写入操作定义 Mutation 字段。查询应使用名词命名(
user、posts),变更则应使用动词命名(createPost、updateUser)。每个变更都应接受一个输入类型参数,并返回一个负载类型,其中包含已修改的对象以及面向用户的错误。这种模式可让变更保持一致且易于扩展。 -
使用连接实现分页: 对于任何可能返回大量条目的列表字段,都应使用包含
edges、node、cursor和pageInfo的 Relay connection 规范。与基于偏移量的分页不同,这种方式提供的基于游标的分页在插入和删除数据时仍保持稳定。应为每种实体定义可复用的连接类型,而不是直接返回原始数组。 -
使用 DataLoader 编写解析器: 实现使用 DataLoader 在单个请求内批处理并缓存数据库查询的解析器。如果不使用 DataLoader,获取 50 篇文章及其作者的查询会分别执行 50 次作者查询(即 N+1 问题)。DataLoader 会将这些查询合并为一次批量查询。每个请求都应新建一个 DataLoader 实例,以避免用户之间的数据泄露。
-
为实时数据添加订阅: 为客户端需要实时响应的事件定义 Subscription 字段(例如新消息、状态变更)。使用发布/订阅后端(Redis、Kafka,或用于开发的内存后端)发布事件。订阅负载应保持精简——如果需要,客户端可以利用订阅触发器重新获取完整数据。
-
保护并优化模式: 添加查询深度限制(最多 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 连接。设计订阅时,应确保客户端可以在重新连接后通过重新查询恢复状态,而不是仅依赖订阅流。