写了一份 Web API 设计规范

基于 GitHub REST API v3RFC7807 ,并结合自己在工作中的一些实践,编写了这份 Web API 规范。

本规范虽然整体上遵循 RESTful 风格,但并未强制要求使用超媒体表示,严格来说不符合 REST API 的要求,因此基于此规范构建的项目应该定义为 Web API。

概览

所有 API 应该使用统一的根域名。

所有访问 API 的请求均基于 HTTP 协议,出于安全性考虑尽量使用 HTTPS

除文件上传下载外所有的数据均通过 JSON 格式发送和接收,请求和响应的 Content Type 首部均设置为 application/json

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
curl -i -H "Authorization: Bearer TOKEN" http://qcs.woqutech.com/api/employees
HTTP/1.1 200 OK
Server: nginx/1.12.2
Date: Thu, 19 Sep 2019 09:07:27 GMT
Content-Type: application/json
Content-Length: 1115
Connection: keep-alive
Access-Control-Allow-Origin: *

{RESPONSE BODY}

空白字段将提供为 null 的值,而不是忽略该字段或提供 "" 空字符串值。

所有响应中的时间戳以 ISO 8601 格式表示:

1
2019-09-16T08:42:47Z

URI命名

动词 or 名词?

基于 RESTful 的基本原则:

将所有的 API 划分为逻辑的资源,并通过 HTTP 请求方法来对资源进行操作

所有的资源都应该表述为名词而不是动词,且该资源并不需要和应用程序的数据模型完全对应。

单数 or 复数?

为了保持简单和一致,不管是资源的单个实例还是集合,在 URI 中都将使用复数形式来表示。如 /tickets/tickets/12

如何表示集合关系?

当资源之间存在集合关系时(即某一资源存在于另一资源的作用域中),我们应当在请求 URI 中体现集合关系,以工单中的评论为例:

  • GET /tickets/12/comments - 获取编号为12的工单的所有评论
  • GET /tickets/12/comments/5 - 获取编号为12的工单中编号为5的评论

若想获取某一集合中的子资源,必须通过其父资源端点进行访问,在接口中也将对其上一级资源进行校验。

1
GET https://{serviceRoot}/{collection}/{id}/{subcollection}/{id}

但当集合层级过于复杂时,会考虑分拆集合关系以构造简单易懂的 URI,如:

1
2
/api/tickets/12/comments/5/replies/20/likes
---> /api/replies/20/likes

如何表示不适用于 CRUD 的行为?

  1. 将该行为重新组织为资源下的某一字段,通过 PATCH 方法更新该字段执行操作。
  2. 将其视为一种 RESTful 原则下的子资源,如 GitHub's API 通过 PUT /gists/:id/star 来 star 某个仓库,通过 DELETE /gists/:id/star 取消 star。
  3. 某些动作实在无法映射到合理的 RESTful 结构, 例如对多种资源同时进行搜索,很难对应到特定资源的端点。在这种情况下,即使它不是资源也可以使用 /search 作为端点,只需要API的使用者能够清晰地理解其意义即可。

HTTP 方法

REST 以资源为核心,HTTP 方法所操作的对象也是资源,对资源的操作即为状态转移。

数据的增删改查则是持久层的具体实现,一次状态转移可能会对应多个数据库表的增删改操作,所以 HTTP 方法和数据库 CRUD 之间也不是严格的一一对应关系。

对资源的操作在任何时候都必须使用正确的HTTP方法,并且必须遵守操作幂等性。

支持的HTTP方法:

方法 描述 成功状态码 是否幂等
GET 获取资源 200 OK Yes
POST 创建资源 201 Created No
PUT 替换资源 200 OK Yes
PATCH 对资源进行局部更新 200 OK No
DELETE 删除资源 204 No Content Yes
OPTIONS 获取接受的请求方法 200 OK Yes
HEAD 获取HTTP首部信息 200 OK Yes

POST 方法的响应

POST 请求成功时将返回 201 Created 状态码,此外响应根据需要有两种形式:

  • 在响应中添加 Location 首部并指定被创建资源的位置

    1
    
    Location: http://qcs.woqutech.com/api/employees/123
    
  • 在响应中返回被创建资源的数据表示

PUT 和 PATCH方法

PUT 和 PATCH 都表示更新资源的操作,但是 PUT 是全量替换而 PATCH 是部分更新。理论上来说前者应当将未传入的字段视作为 null 或默认值并且更新到数据库中。目前我们的实现中分别使用了 PUT 和 PATCH,但是 PUT 方法会忽略未传字段,本质上也是一种部分更新。有置空需求的业务需要显式地传入字段并设为 null

注意置空或表示 统一使用 null (后端及数据库可能表示为 NoneNULL),不使用空字符串 ""

身份认证

访问需要身份认证的端点时,若请求未携带凭据或凭据无效,将返回 401 Unauthorized

进行身份认证所需的 OAuth2 令牌需要放置在请求的 Authorization 首部中,并使用 Bearer 认证方式:

1
curl -H "Authorization: Bearer TOKEN" http://qcs.woqutech.com/api/employees

请求参数

对于 GET 请求,将参数作为 HTTP 查询字符串添加到 URL 中可对获取的结果进行查询和过滤。

对于 POSTPATCHPUTDELETE 请求,仍然可以在 URL 中使用查询字符串,URL中未包含的参数应编码为 JSON 。

请求体内的内容不应该进行包装,若发送的内容为集合,应当直接发送包含对象集合的数组类型。

响应格式

所有的响应都使用 200 OK 状态码是错误的。

包装与可见性

在约定响应的格式时,应始终考虑可见性。API 客户端可以根据约定理解和解析响应,从消息体获取所需要的详细信息,但服务端并非直接与客户端交互,还需考虑反向代理、网关、缓存、监控中间件等 HTTP 中间处理层。HTTP 消息体是用来表述资源的,HTTP 分层系统的中间层不会解析消息体,而是根据头部信息进行相应处理(如缓存层根据状态码决定是否缓存响应内容,监控层根据状态码监控可用状态)。

使用包装结构的响应并弃用状态码时,请求是否成功以及响应内容仅在消息体中体现,对中间层组件不可见,可能会导致其执行错误的逻辑或增加不必要的解析逻辑。

为了确保可见性,应始终保证响应具有丰富和准确的头部信息,消息体中不应包含除资源表述之外的其他信息,除非资源的表述需要(如集合资源的分页)不进行多余的包装。

错误

对于未成功的请求,服务端应当对请求过程中的错误或异常进行处理,并返回统一的响应对象。

API 客户端需要(通过状态码)被告知响应的高级错误类,比如当用户无权限访问端点时,返回的 403 Forbidden 状态码会告知 HTTP 中间组件(如客户端库、缓存和代理)响应的整体语义。

同时,API 客户端还需要获取关于错误的详细信息,比如发生错误的具体原因以及解决方案,当这些信息以机器可读的方式包含在响应体中时,客户端还能根据错误响应触发相应操作。

对象示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json

{
	"type": "ValadationError",
	"message": "请求数据验证失败",
	"status": 422,
	"detail": [
  {
    "loc": ["name"],
    "msg": "field required",
    "type": "value_error.missing"
  }
]
}

对象成员:

  • type (string) required

    由服务端定义,一个简短的、人类可读的、指示问题类型的标识。在不同服务间对于同类问题的类型标识唯一,API 客户端能够对所有的问题类型进行处理。

  • message (string) required

    一个人类可读的关于发生该问题的简要解释。该信息返回给客户端开发者使用。

  • status (number) optional

    由源头服务器生成的关于发生该问题的 HTTP 状态码

    API 客户端可以使用该字段来确定发生该问题时使用的原始状态代码,以防它在传递时被改变(如中间设备或缓存),而消息体在没有 HTTP 信息的情况下能够维持一致。 HTTP 中间组件仍将使用响应的 HTTP 状态码。

  • detail (object) optional

    关于发生该问题的详细信息,可用于帮助 API 客户端修正错误。

注意:在必要时,可对错误响应对象的成员进行扩展。

不要轻易去定义新的问题类型,只有当区别对待问题类型对客户端有意义时,才设计对应的问题类型。尽量将问题归类到已有的能够自解释的 HTTP 错误状态码中,仅通过 message 字段传递必要的提示信息,同时注意不应在错误响应对象描述过多和过于详细的服务端错误细节,避免引入安全风险。

详情表示

详情表示无需进行任何封装。

获取单个独立的资源时,响应通常包括该资源的所有属性。 这种情况定义为资源的详情表示。(API 客户端的身份认证有时会影响详情表示中包含的信息量。)

1
2
3
4
5
{
    "isbn": "9780321125217", 
    "name": "Domain-Driven Design"
		"authors": ["Matin Fowler", "Levis Knuth"]
}

集合表示

获取资源列表时,响应包括该资源的属性子集,这种情况定义为资源的集合表示。(出于计算和IO性能原因,集合表示会排除资源的部分属性,可通过详情表示获取这部分属性。)

集合资源通过标准的 JSON 数组来表示,对于集合资源,分页是一种常规需求,集合表示响应中可能会包括资源集合表示和分页信息表示两部分。为了保持简单和一致,所有的集合表示响应无论是否包含分页信息都会对结构进行包装,示例如下:

  • 包含分页信息
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{
    "data": [
        {"isbn": "9780321125217", "name": "Domain-Driven Design"},
        ......
        {"isbn": "9780596805821", "name": "REST in Practice"}
    ],
    "pagination": {
        "page": 1,
				"per_page": 30,
        "pages": 9,
        "total": 256
    }
}
  • 不包含分页信息
1
2
3
4
5
6
7
8
{
    "data": [
        {"isbn": "9780321125217", "name": "Domain-Driven Design"},
        ......
        {"isbn": "9780596805821", "name": "REST in Practice"}
    ],
    "pagination": null
}

分页

默认情况下,集合表示的分页行为由 API 客户端驱动,使用 ?page 参数指定要返回页面编号(服务端默认值为 1), 使用 ?per_page 参数指定每一页包含的资源数量(服务端默认值不大于 30)。

注意页码编号从 1 开始,省略 ?page 参数将使用服务端的默认值而不是返回集合表示的所有资源,以免返回的数据量过大造成服务端数据库阻塞以及网络阻塞。API 客户端必须能够对消费任何给定请求的分页或非分页集合表示具有弹性,并进行相应约束(如设置分页参数的默认值和最大值)。

过滤与排序

在事先约定的情况下,可根据需要对指定的资源字段进行过滤或排序。

CORS

API 支持指定来源的跨域资源共享(CORS)请求。示例:

1
2
3
4
5
6
7
curl -i -H "Authorization: Bearer TOKEN" http://qcs.woqutech.com -H "Origin: http://example.com"
HTTP/1.1 200 OK
...
Access-Control-Allow-Origin: http://example.com
Vary: Origin

RESPONSE BODY

版本化

本规范暂不涉及。

超媒体

对资源进行操作的请求地址由 API 客户端自行构造,不使用超媒体表示。

接口文档

使用 OpenAPI Specification (OAS) 3.0 作为 API 的交互式文档标准。

API 客户端可访问指定地址获取拥有 UI 界面的接口文档,以及 JSON 格式的文档文本。

参考

updatedupdated2023-06-062023-06-06