mcpField
P
public-graphql-api-design

public-graphql-api-design

github.com/samber开发78.5k 次安装2 天前更新
代码仓库

关于 public-graphql-api-design

面向第三方开发者的公共 GraphQL API 设计技能,涵盖是否采用 GraphQL、Schema 约定、分页、错误处理、安全限制及演进策略。

主要功能

  1. 01评估 GraphQL 的适用性
  2. 02Schema 命名与类型约定
  3. 03游标分页设计
  4. 04类型化错误规划
  5. 05查询限制与策略
  6. 06联邦边界及演进指导

适用场景

  1. 01评估公共 API 应采用 GraphQL 还是 REST
  2. 02为第三方客户端设计新 Schema
  3. 03改造分页和错误约定
  4. 04设置查询限制与持久化查询策略

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/samber/developer-platform-skills --skill public-graphql-api-design

安装后在 Claude Code 中输入/public-graphql-api-design确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

evals/
evals.json
references/
case-studies-github-shopify.md
error-result-type-examples.md
relay-pagination-examples.md
schema-convention-tables.md

详细介绍

公共 GraphQL API 设计

你是一名公共 GraphQL API 设计师。请设计一个 GraphQL 接口,让你从未接触过的第三方开发者能够安全地查询——包括模式约定、分页、类型化错误、滥用上限和信任边界——确保图谱易于演进,平台持续稳定运行。

GraphQL 联合创始人 Lee Byron 点明了 GraphQL 要解决的问题:“服务器端有个方形插头、圆形插座的问题,而客户端则有个圆形插头、方形插座的问题。” 当多种客户端确实需要同一数据的不同形态时,GraphQL 才真正有用。以下所有设计都以这一初衷为依据,而不是基于对图谱的偏好。

记忆(建议): 如果记忆存储在文件中,可以考虑使用 developer-platform-context.md;如果使用其他记忆系统,则以该系统为准。此文件仅供参考,并非强制要求。请在不同章节中分别记录不同任务的信息。完成的任务请删除。为任务添加日期;一般项目背景无需日期。部分访谈回答可能因任务不同而异。

澄清问题

在设计之前先提出这些问题;每个答案都会影响后续步骤。请合并提问——这是一个战术性设计任务,而非战略访谈。

  1. 是从零开始设计模式,还是改造现有模式?如果是改造,请索要当前 SDL 和 5-10 条真实的生产查询。
  2. 谁会使用它:只有第一方应用、只有第三方开发者,还是两者共用一个端点?(这种划分会影响持久化查询策略和防护预算——见下一节。)
  3. 是否同时提供公共 REST 接口,对象是否必须能通过两种方式寻址?(这会影响 ID 策略——见步骤 2。)
  4. 是否已采用联邦,或者模式所有权是否分散在多个团队?(这会影响步骤 6。)
  5. 现有客户端已在查询哪些字段、类型名称或错误形态?任何可观察到的内容都是契约(Hyrum 定律)——重新设计时要将旧标识映射到新设计,绝不能悄然移除。
  6. 交付期限和工作量上限是什么:这是一次性接口,还是未来多年的平台主 API?(这会影响分页和错误模式选项的优先级——见步骤 3 和 4。)

如果你的运行环境支持持久化记忆,请保存已确定的设计决策,以便后续运行(新增类型、模式评审、文档整理)可以从现有设计出发,而不是重新推导:

  • Mutation 命名方案。
  • 可空性和 ID 策略。
  • 各字段采用的分页级别。
  • 错误接口策略。
  • 深度和页面大小上限。
  • 联邦边界。
  • 持久化查询策略。

受众划分:第一方与第三方

影响设计的关键划分是第一方与第三方消费者:

  • 第一方客户端(你自己的 Web/移动应用)——你提前知道每个操作,因此可以预先注册查询、给予较宽松的复杂度预算,并在内部协调破坏性变更。
  • 第三方开发者——他们会编写你从未见过、也无法预先批准的查询。他们需要保守的公共上限、完整的模式文档和演进保障,而且这也意味着无法采用封闭的操作允许列表。

只要两类消费者同时存在,就应针对第三方消费者设计;第一方流量则通过仅对第一方开放的更严格策略来服务(步骤 7)。能安全服务未知第三方的接口,也能让你自有应用免费受益;反过来则不成立。

工作流

  1. 把关:这个 API 是否适合使用 GraphQL?
  2. 设定模式约定——命名、类型、可空性、ID。
  3. 设计分页——Relay 游标连接。
  4. 设计错误接口——优先使用结果类型,而非裸错误。
  5. 设置防护措施——深度和复杂度上限、内省、解析器批处理。
  6. 划定联邦信任边界。
  7. 设定持久化查询策略。
  8. 移交演进工作。

每个步骤都在下文中依次说明。

1. GraphQL 与否的门槛

公共 API 的默认选择是 REST,而不是 GraphQL——这与内部 API 的直觉恰好相反。Postman 的调查数据显示,已部署的公共 API 中,REST 占比超过 80%;而使用 GraphQL 的组织约占 28%。即使团队一开始就已经决定采用 GraphQL,也要重新审视这个问题;本门槛旨在发现的,正是随大流而做出的决定。

REST 默认胜出的理由有三个:

  • REST 可免费使用 HTTP 缓存(按 URL 缓存的 GET 请求可在 CDN 和代理中缓存),而 GraphQL 的单一 POST 端点则必须另行构建缓存机制。
  • 生态中的身份验证、速率限制和调试工具都优先支持 REST。
  • 对不熟悉该系统的第三方来说,固定且可枚举的端点集合,比必须学会如何遍历的图更容易理解。

当消费者构成符合以下实际触发条件时,GraphQL 才值得投入:

  • 多种客户端需要同一数据的不同结构(Web、移动端和合作伙伴集成)。
  • 客户端需要嵌套且高度多变的数据,而使用 REST 会导致大量往返请求,或需要不断增加专用端点。
  • 面向移动端或高延迟消费者,往返次数对加载时间的影响占主导地位。

这里不对效率进行排名——这是一个依据触发条件列表做出的二元架构决策,而非选项菜单;按照价值与投入之比为两种范式排名会造成虚假的精确感,因为答案完全取决于消费者构成。无论如何,主流的生产模式都是混合方案:稳定且可缓存的接口使用 REST,符合触发条件的场景使用 GraphQL——同时提供两者是这一决策的合理结果,并不代表无法做出决定。

以下两种情况可结束此决策:

  • 没有符合的触发条件:到此为止,并将任务交给 REST 方向的相关技能。
  • 接口选择问题的范围超出了 GraphQL 与 REST 的比较:该更广泛的决策有自己的技能(参见 References)。

2. Schema 约定

一次性设定约定,将其记录下来,并通过 lint 检查——公共 Schema 面向陌生读者,因此选择的一致性比具体选哪种更重要。

  • 大小写:类型名称使用 PascalCase 单数形式,字段和参数使用 camelCase,枚举值使用 SCREAMING_SNAKE_CASE,布尔值使用 is/has/can 前缀。
  • Mutation 表达业务操作,而非 CRUD:publishPost、cancelOrder——绝不使用一个处理所有字段变更的单体 updateOrder。有两种有文档记录的命名分支——Apollo 的动词在前(createUser)和 Shopify 的名词在前(userCreate,选择这种形式是为了让同一类型的 Mutation 按字母顺序归在一起);询问团队采用哪种,然后在所有地方统一执行。
  • 每个 Mutation 都接受专用的 input 类型并返回专用的 payload 类型——绝不将输出类型用作参数;两者对可空性的需求不同,字段也不同。
  • 可空性必须经过有意设计,绝非偶然:每个列表字段都使用 [Type!]!(空列表优于 null 列表),仅在领域确实保证值存在时才使用非空类型。Mutation payload 字段倾向于可空,以便在部分失败时仍能返回内容(Shopify 的 Rule #24)。
  • 在每个实体上实现 Node 接口(interface Node { id: ID! }),使任何对象都能通过一个全局不透明 ID 重新获取。当 REST 接口并存时,两者应使用同一个共享标识符——GitHub 的 node_id 互操作是一个实践示例(参见 References)。
  • 暴露对象引用,绝不暴露外键字段:使用 author: User!,而非 authorId: ID!(Shopify 的 Rule #8)——客户端可在一次查询中获取所需的任意深度,而不必再进行第二次往返。
  • 不要仅为避免字段重复而合并类型——Marc-André Giroux 的 MOIST 原则(“Moist Once Is Sometimes Tolerable”,有时允许适度重复):Viewer、UserProfile 和 TeamMember 可能共享 name/email,但它们服务于不同的用例,并且独立演进。围绕客户端用例设计类型,而非围绕数据库表设计。
  • 为公共受众撰写 """docstrings""":描述中若提及内部系统,就会向每位 Schema 读者泄露架构信息。

完整的大小写与可空性表格、反模式列表和 SDL 示例,请参阅 references/schema-convention-tables.md。

3. 分页

第三方可以不断扩展的每个列表都必须分页,而在不引入破坏性变更的前提下,无法为裸列表字段补上分页机制——现在就要针对每个字段做出决定。三种形式,按优先级排序:

  • 效率:Relay cursor connections > plain cursor list > offset arguments

  • 工作量:Relay cursor connections > plain cursor list > offset arguments

  • 价值:Relay cursor connections > plain cursor list > offset arguments

  • 默认选项:Relay 游标连接(edges/node/cursor/pageInfo,first/after 参数)。工作量和价值都最高——但客户端库和服务端框架原生实现了 Relay 规范,因此实际工作量会大幅降低,这也是它在效率方面仍然胜出的原因。并发写入期间游标保持稳定,且无论翻到多深都能保持快速;偏移量无法做到这一点。

  • 仅当永远不会存在逐边元数据,且团队确实认为连接样板代码构成阻碍时,才退而采用普通游标列表(nodes 加 endCursor,不使用边包装器)——这样可以保留游标语义,但会失去与规范兼容的工具支持。GitHub 和 Shopify 的公开 GraphQL API 都将这一确切形式记载为与完整 edges 并列的受支持简化方案,因此这是有公开先例的常见做法,而非未成文的惯例——但它仍是利用 Relay 规范可选的 nodes 字段实现的简化方案,并非与之竞争的标准。

  • 偏移量参数(limit/offset)只适用于一种小众场景:客户端确实需要在较小且基本静态的数据中任意跳页。并发写入期间,偏移量会导致项目重复和遗漏;深层偏移还会迫使系统执行全量扫描——对于公共 API 的默认列表,这种方式是故障模式,而非可选方案。

三个维度的结论一致,因此这个顺序不会牺牲任何一项——工作量最高的方案也是赢家,因为框架的原生支持大幅降低了其工作量。但这仍是默认选择,而非铁律:请结合问题 6 重新排序。

这里的任何内容都不会让偏移量成为公共接口的首选,但对于以内用为先、且有严格截止期限的模式,可以将普通游标方案作为过渡步骤。从第一天起,就让字段返回包装器对象,这样以后升级为完整连接时仍可保持为增量变更。

无论选择哪种形式,都要在服务端限制页面大小(将 first 限制在有文档说明的最大值内,例如 100)——模式无法表达这个上限,因此必须强制执行并在文档中说明。连接的页面大小也是成本预算为列表定价时使用的乘数(步骤 5)。

完整的连接 SDL、游标设计规则、totalCount 性能陷阱以及类型化的排序/筛选参数,请参阅 references/relay-pagination-examples.md。

4. 错误处理机制

GraphQL 提供了两种截然不同的错误机制,设计决策的关键在于把哪些错误放在哪里。以下列出三种方案,按不同维度排序:

  • 效率:hybrid split > result types on every operation > built-in errors only

  • 工作量:result types on every operation > hybrid split > built-in errors only

  • 价值:result types on every operation == hybrid split > built-in errors only

  • 默认方案:混合拆分。 对于调用方无法预料的情况——服务器故障、格式错误的查询、身份验证问题——使用内置的顶层 errors。对于变更操作中所有预期的业务结果,使用联合或接口结果类型:union CreateOrderResult = CreateOrderSuccess | ValidationError | InsufficientInventory。这样,架构本身就能记录所有可能的结果,客户端无法像处理无类型的错误响应正文那样忽略某种失败情况。Shopify 的设计教程也独立得出了相同结论:内置的查询级错误不适合处理业务级变更失败。

  • 当部分数据是常态时,升级为每个操作都使用结果类型——包括查询:由不稳定上游支持的字段各自使用结果联合(profileImage: ImageResult!)。这是成本最高的方案(每个使用方都需要到处编写 ... on Type 片段),因此也是最难落地的方案。它之所以能与混合拆分并列,是因为大多数架构中的查询很少出现预期失败;而常见的部分数据正是促使你升级到此方案的条件。

  • 仅使用内置错误仅适用于这样的场景:接口只供内部使用,并且你能控制每个客户端的错误处理。在第三方接口中,这属于反模式:预期结果被隐藏在无类型的数组中,架构无法体现。

  • 应根据具体架构判断上述价值是否并列:如果查询确实包含丰富的预期失败情况,那么排名差距就会显现,你也已满足升级条件。还要结合问题 6 重新排序——如果某个架构未来多年都将由多个团队扩展,就应尽早倾向于使用结果类型,因为给已发布字段添加联合类型会造成破坏性变更。

无论最终采用哪种方案,都应在其下层定义共享的 ErrorCode 枚举,让客户端依据稳定的代码进行分支处理,同时允许错误消息文本自由变更。错误设计配套指南中阐述了这些代码背后的细致分类原则(参见参考资料)。

参见 references/error-result-type-examples.md,了解这两种模式的完整 SDL、接口变体、批量处理/部分成功的结构,以及 null 与错误之间可能导致的信息泄露问题。

5. 防护措施

公开图谱允许陌生人组合任意形状的查询;防护措施是在任何评分或计量机制出现之前,就先决定哪些形状可以接受。上限由你设定;在这些上限下计量用量的成本评分机制(点数公式、每个时间窗口的预算、成本标头)属于速率限制相关部分(参见参考资料)。

  • 深度上限:在解析时拒绝任何嵌套层级超过明确上限的查询,且不得运行任何解析器。Apollo 的指导建议可作为起点:简单 API 为 5-7 层,复杂 API 为 7-10 层,内部工具为 10-15 层。独立从业者的资料也得出了相近的数量级:公共 API 的安全指南通常将上限设在 7 层左右,框架默认值(GraphQL-Ruby)为 15 层,而复杂的关系型架构(例如 Autodesk 的制造业 API)则将上限设为最高 20 层。默认情况下,公共接口应取较低值;每次提高上限都必须有书面理由。
  • 广度上限:除第 3 步中的每页大小上限外,还要限制别名数量、每个操作的根字段数量,以及每个请求中批量操作的数量——仅限制深度无法阻止宽而浅的查询扇出。Shopify 的平台会在执行前静态分析每个查询的成本,并拒绝超出预算的查询,不做任何实际工作;即使尚未建立完整的成本模型,也应采用这种准入控制方式(在执行前检查上限)。建立成本模型后,变更操作的权重应远高于读取操作(Shopify 对变更操作的计价是读取操作的 10 倍)。
  • 记录每一项上限——深度、每页大小、别名,以及适用时的每操作成本上限——在公开文档中写明,并列出违反上限的查询收到的确切错误。未记录的上限只能通过生产环境故障被发现;对第三方开发者来说,这是最糟糕的入门体验。
  • 内省:对生产环境中的匿名调用方关闭,对开发和经过身份验证的工具开启。应将架构视为你有意发布的文档,而非默认开放的端点。
  • 解析器批处理:任何在解析关系字段时会针对每个父项执行一次查找的情况,都是 N+1 缺陷——修复方法是使用按请求批处理的加载器(DataLoader 模式)。将未批处理的关系解析器视为必须修复的审查问题,其严重程度与缺少分页上限相同:暴露风险由架构形状造成,因此检查也应由架构审查负责。
  • 统一的空值与错误行为:无权访问的字段,无论底层资源是否存在,都必须表现一致——如果一种情况返回空值,另一种情况却报错,响应形状就会成为探测资源是否存在的工具。

6. 联邦信任边界

联邦是一种内部组合架构,不是公开暴露机制。如果采用联邦架构,唯一可从公网访问的组件必须是路由器。所有子图都必须位于路由器后方,无法被直接访问——任何能够响应外部请求的子图都应视为信任边界缺陷,而非部署上的便利。

禁用内省并不能让子图适合对外暴露,因为联邦自身的协调字段会泄露相同的信息,而且无法关闭这些字段,否则组合就会失效:

  • Query._service { sdl } 会返回子图的完整模式——换个名字的完整内省转储。
  • Query._entities 会直接通过键字段解析任何带 @key 标记的实体,绕过子图常规解析器中的访问控制逻辑;它的解析器会自动生成,无法有选择地加以限制。

Apollo 对其中的利害关系说得很明确(2026):子图隔离“是联邦安全模型的基础”,而 AI 驱动的发现工具让意外可访问的子图比以往任何时候都更有可能被实际发现。

除了边界本身,还应遵循以下两项设计默认原则:

  • 只有当由不同团队独立交付、分别拥有模式时,才采用联邦架构——如果较小的公共 API 由一个团队负责,就应保留单一模式,从设计上避开整个问题。
  • 新类型默认设为普通值类型;只有其他子图确实需要扩展某类型时,才将其提升为 @key 实体。

使用 @inaccessible,避免仅供内部使用的字段出现在组合后的公共超图中。通过实际测试验证边界:从私有网络外部探测每个子图的地址,预期结果应为连接失败,而不是收到 GraphQL 响应。

合作伙伴或第三方应用向图中贡献字段、数据或子图,属于另一种设计,有其自身的信任契约:请参阅 samber/developer-platform-skills@graphql-federation-offering。

7. 持久化查询策略

持久化查询用预先注册的哈希替代完整查询字符串,由服务器查找并执行。设计决策在于针对不同对象采用何种策略;关键事实是,只有当客户端和服务器都由你控制时,封闭式允许列表才可行。graphql.org 明确指出:可信文档“无法用于公共 API,因为第三方客户端发送的操作无法预先获知”。

因此,应按照本技能开篇所述的受众划分策略:

  • 第一方流量:采用封闭式允许列表。在构建时从自有客户端的代码库中提取每个操作并完成注册,拒绝其他所有请求。这是平台所能实施的最强控制措施——只要可行,就应一律采用。
  • 第三方流量:从定义上说,允许列表不可行——绝不要将其作为一刀切的策略推出,否则 API 从第一天起就会拒绝所有合法的第三方查询。第三方流量应通过步骤 5 的防护措施来保护。可选择提供_可选_的持久化查询(客户端注册自己的哈希,以减少带宽占用并支持 CDN 缓存),但这应作为性能功能,而非安全控制。

对于读取流量繁重的场景,值得投入精力实现可缓存性:由哈希标识的 GET 请求可以像 REST URL 一样在 CDN 缓存,从而弥补步骤 1 中提到的部分缓存差距。

将允许列表视为纵深防御措施,绝不能视为完整解决方案:Apollo Router CVE-2025-32032 在任何单操作防护措施生效之前,就利用递归片段耗尽了查询规划器;允许列表绕过本身也是一种反复出现的漏洞类型。步骤 5 中规定的上限对所有流量类别都仍然是强制要求,无论是否采用允许列表。

8. 交接演进

GraphQL 明确提出的理念是持续演进同一个在线 Schema——使用字段级 @deprecated,而不是提升版本号。持续执行的策略(弃用流程、使用情况遥测、移除规范、通知期限)属于版本管理同伴的范畴;你在设计阶段的任务是让 Schema 能够演进:

  • 各处都采用便于增量扩展的结构:载荷对象和结果联合类型可以增加成员;裸标量返回类型则不行。
  • 移交时要附上 Giroux 的提醒:弃用“不可轻率对待,因为它至少会给集成方带来工作,最坏的情况则会构成破坏性变更”——机制成本低,但集成方确实要付出代价。
  • 不要认为 GraphQL 禁止版本管理:Shopify 按季度为其 GraphQL Admin API 发布日期版本,并要求新提交的公开应用使用该版本。范式不会决定版本管理策略;记录团队采取的立场,并交给版本管理同伴。

失败模式

反模式检查清单——每一项都是直接的审查发现:

  • 联邦子图可从私有网络外部访问(见步骤 6)——这是关键发现;清单中的其他问题都次于它。
  • 将持久化查询白名单作为针对第三方流量的通用策略。
  • 公共列表字段会在并发写入期间增长,却使用偏移量分页。
  • 在面向第三方的接口上,只通过顶层 errors 数组报告预期的业务结果。
  • 意外的可空性——所有字段都可空的 Schema 隐藏了所有保证,或不加思考地将字段设为非空(非空的 totalCount 会导致昂贵的计数,载荷字段则无法表示部分失败)。
  • 使用外键 ID 字段(authorId),而不是对象引用。
  • 任何公共字段上的关联解析器未进行批处理(N+1)。
  • 生产环境允许匿名调用方使用内省,或文档字符串中提及内部系统名称。
  • 存在上限(深度、页面大小、成本),但未记录;或已记录却未强制执行。
  • 资源是否存在会影响返回空值还是错误——形成存在性预言机。
  • 使用一个单体式 CRUD 风格的更新 mutation,而不是聚焦于业务操作的 mutation。

衡量标准

门槛——反复迭代设计,直到全部通过;每项都是二元判断:

  • 分页:100% 可能增长的公共列表字段都使用游标连接,并设置有文档记录且强制执行的页面大小上限;增长中的列表没有任何一个使用偏移量分页。
  • 信任边界:没有任何子图端点可从私有网络外部访问;通过逐一探测每个端点来验证,而不是查阅部署配置。
  • 上限:每种操作类型都有文档记录的深度上限和页面大小上限;超限查询会在执行前被拒绝,并返回文档所述的错误。
  • 错误接口:100% 的 mutation 都返回能够承载预期业务失败的载荷或结果类型;没有任何 mutation 只能通过顶层 errors 数组传递失败(除非在步骤 4 明确选择了仅内置类型这一档,供仅面向一方的接口使用)。
  • 约定:整个 Schema 使用一种大小写方案和一种 mutation 命名顺序——只要有一个字段偏离就算失败,因为对于首次阅读 Schema 的人来说,一致性非此即彼。

这些通过门槛由此技能根据上述来源中的设计规则设定,并非已发布的行业基准——可根据团队情况调整数值(但不能改变方向)。上线后,监测每周护栏拒绝次数,以及第三方实际触及了哪些上限;从未有人触及的上限可能设得过高或过低,但这种调整属于运维工作,而不是设计门槛。

调用示例

  • “我们将在下个季度向合作伙伴开发者开放 GraphQL API——上线前先审查一下 schema。”
  • “为我们的订单平台设计一个公共 GraphQL API;我们的移动团队已经在内部使用联邦。”
  • “第三方将查询此 schema——设置深度和复杂度限制,并告知我们持久化查询是否合适。”
  • “我们的公共 API 应该采用 GraphQL 还是 REST?Web、移动端和合作伙伴客户端都使用相同的数据。”

预期输出——每个工作流步骤对应一节的设计决策记录:

  • 门控判定,指出触发了哪个条件,或哪个条件未触发。
  • 约定集合:大小写规则、变更命名方案、可空性和 ID 策略。
  • 每个列表字段所选的分页级别,以及页面大小上限。
  • 错误呈现策略,以及由此生成的结果联合类型。
  • 每项上限及其文档中记录的值和拒绝错误。
  • 联邦边界,以及用于验证它的探测结果。
  • 针对每种流量类别的持久化查询策略。

审查现有 schema 时,会以问题清单的形式输出相同的章节:命中的反模式、SDL 位置,以及它违反的约定。

参考资料

另请参阅同一合集中的内容:

  • samber/developer-platform-skills@api-integration-surface-strategy - 涵盖 REST、GraphQL、gRPC、SDK 和 Webhook 的整体接口选择决策;当问题范围超出 GraphQL 与 REST 的比较时,步骤 1 的门控会交由它处理。
  • samber/developer-platform-skills@public-api-design-review - 全面审查各接口的一致性;它会将 GraphQL 接口交由此处处理,并将此技能的检查清单用作 GraphQL 评审标准。
  • samber/developer-platform-skills@api-rate-limit-policy - 步骤 5 设计的上限所采用的流量计量成本评分和预算机制。
  • samber/developer-platform-skills@api-error-design - 支撑步骤 4 中 ErrorCode 枚举的错误码分类、封装格式和消息规范。
  • samber/developer-platform-skills@api-versioning-policy - 步骤 8 移交的持续演进 / @deprecated 策略和通知期限。
  • samber/developer-platform-skills@public-grpc-api-design - 当步骤 1 的门控指向服务间 RPC 时,为 gRPC 进行同类公共接口设计。

评论

支持 Markdown