使用 REST 规范你从未做过的 5 件事
已发表: 2022-03-11大多数前端和后端开发人员之前都处理过 REST 规范和 RESTful API。 但并非所有 RESTful API 都是一样的。 事实上,它们很少是 RESTful 的……
什么是RESTful API?
这是一个神话。
如果您认为您的项目具有 RESTful API,那么您很可能错了。 RESTful API 背后的理念是以遵循 REST 规范中描述的所有架构规则和限制的方式进行开发。 然而,实际上,这在实践中基本上是不可能的。
一方面,REST 包含太多模糊不清的定义。 例如,在实践中,HTTP 方法和状态码字典中的某些术语的使用与预期目的相反,或者根本没有使用。
另一方面,REST 开发造成了太多的限制。 例如,对于移动应用程序中使用的实际 API,原子资源的使用不是最理想的。 完全拒绝请求之间的数据存储基本上禁止了随处可见的“用户会话”机制。
但是等等,这并没有那么糟糕!
您需要什么 REST API 规范?
尽管有这些缺点,但通过明智的方法,REST 仍然是创建真正出色的 API 的惊人概念。 这些 API 可以是一致的,并且具有清晰的结构、良好的文档和较高的单元测试覆盖率。 您可以通过高质量的API 规范来实现所有这些。
通常,REST API 规范与其文档相关联。 与规范(对 API 的正式描述)不同,文档旨在供人类阅读:例如,由使用 API 的移动或 Web 应用程序的开发人员阅读。
正确的 API 描述不仅仅是写好 API 文档。 在本文中,我想分享一些示例,说明如何:
- 让您的单元测试更简单、更可靠;
- 设置用户输入预处理和验证;
- 自动化序列化并确保响应一致性; 乃至
- 享受静态类型的好处。
但首先,让我们先介绍一下 API 规范世界。
开放API
OpenAPI 是目前最广泛接受的 REST API 规范格式。 该规范以 JSON 或 YAML 格式编写在单个文件中,由三个部分组成:
- 带有 API 名称、描述和版本以及任何其他信息的标头。
- 所有资源的描述,包括标识符、HTTP 方法、所有输入参数、响应代码和正文数据类型,以及指向定义的链接。
- 所有可用于输入或输出的定义,采用 JSON Schema 格式(是的,也可以用 YAML 表示。)
OpenAPI 的结构有两个显着的缺点:它太复杂而且有时是多余的。 一个小项目可以有数千行的 JSON 规范。 手动维护此文件变得不可能。 这对在开发 API 时保持规范最新的想法构成了重大威胁。
有多个编辑器允许您描述 API 并生成 OpenAPI 输出。 基于它们的其他服务和云解决方案包括 Swagger、Apiary、Stoplight、Restlet 等。
然而,这些服务对我来说并不方便,因为快速规范编辑和使其与代码更改保持一致很复杂。 此外,功能列表取决于特定服务。 例如,基于云服务工具创建完整的单元测试几乎是不可能的。 代码生成和模拟端点虽然看起来很实用,但在实践中几乎没有用处。 这主要是因为端点行为通常取决于各种因素,例如用户权限和输入参数,这对 API 架构师来说可能很明显,但不容易从 OpenAPI 规范中自动生成。
小规格
在本文中,我将使用基于我自己的 REST API 定义格式tinyspec的示例。 定义由具有直观语法的小文件组成。 它们描述了项目中使用的端点和数据模型。 文件存储在代码旁边,提供快速参考和在代码编写期间进行编辑的能力。 Tinyspec 会自动编译成成熟的 OpenAPI 格式,可以立即在您的项目中使用。
我还将使用 Node.js(Koa、Express)和 Ruby on Rails 示例,但我将演示的实践适用于大多数技术,包括 Python、PHP 和 Java。
API 规范动摇的地方
现在我们有了一些背景知识,我们可以探索如何充分利用正确指定的 API。
1. 端点单元测试
行为驱动开发 (BDD) 是开发 REST API 的理想选择。 最好不要为单独的类、模型或控制器编写单元测试,而是为特定的端点编写单元测试。 在每个测试中,您模拟一个真实的 HTTP 请求并验证服务器的响应。 对于 Node.js,有用于模拟请求的 supertest 和 chai-http 包,对于 Ruby on Rails,有 airborne。
假设我们有一个User
模式和一个返回所有用户的GET /users
端点。 下面是一些描述这一点的 tinyspec 语法:
# user.models.tinyspec User {name, isAdmin: b, age?: i} # users.endpoints.tinyspec GET /users => {users: User[]}
下面是我们如何编写相应的测试:
节点.js
describe('/users', () => { it('List all users', async () => { const { status, body: { users } } = request.get('/users'); expect(status).to.equal(200); expect(users[0].name).to.be('string'); expect(users[0].isAdmin).to.be('boolean'); expect(users[0].age).to.be.oneOf(['boolean', null]); }); });
Ruby on Rails
describe 'GET /users' do it 'List all users' do get '/users' expect_status(200) expect_json_types('users.*', { name: :string, isAdmin: :boolean, age: :integer_or_null, }) end end
当我们已经有了描述服务器响应的规范时,我们可以简化测试并检查响应是否符合规范。 我们可以使用 tinyspec 模型,每个模型都可以转换为遵循 JSON Schema 格式的 OpenAPI 规范。
JS 中的任何文字对象(或 Ruby 中的Hash
、Python 中的dict
、PHP 中的关联数组,甚至 Java 中的Map
)都可以验证 JSON Schema 合规性。 甚至还有用于测试框架的适当插件,例如 jest-ajv (npm)、chai-ajv-json-schema (npm) 和用于 RSpec (rubygem) 的 json_matchers。
在使用模式之前,让我们将它们导入到项目中。 首先,根据 tinyspec 规范生成openapi.json
文件(您可以在每次测试运行之前自动执行此操作):
tinyspec -j -o openapi.json
节点.js
现在您可以在项目中使用生成的 JSON 并从中获取definitions
键。 此键包含所有 JSON 模式。 架构可能包含交叉引用 ( $ref
),因此如果您有任何嵌入式架构(例如, Blog {posts: Post[]}
),您需要解开它们以用于验证。 为此,我们将使用 json-schema-deref-sync (npm)。
import deref from 'json-schema-deref-sync'; const spec = require('./openapi.json'); const schemas = deref(spec).definitions; describe('/users', () => { it('List all users', async () => { const { status, body: { users } } = request.get('/users'); expect(status).to.equal(200); // Chai expect(users[0]).to.be.validWithSchema(schemas.User); // Jest expect(users[0]).toMatchSchema(schemas.User); }); });
Ruby on Rails
json_matchers
模块知道如何处理$ref
引用,但在指定位置需要单独的架构文件,因此您需要swagger.json
文件拆分为多个较小的文件:
# ./spec/support/json_schemas.rb require 'json' require 'json_matchers/rspec' JsonMatchers.schema_root = 'spec/schemas' # Fix for json_matchers single-file restriction file = File.read 'spec/schemas/openapi.json' swagger = JSON.parse(file, symbolize_names: true) swagger[:definitions].keys.each do |key| File.open("spec/schemas/#{key}.json", 'w') do |f| f.write(JSON.pretty_generate({ '$ref': "swagger.json#/definitions/#{key}" })) end end
下面是测试的样子:
describe 'GET /users' do it 'List all users' do get '/users' expect_status(200) expect(result[:users][0]).to match_json_schema('User') end end
以这种方式编写测试非常方便。 如果您的 IDE 支持运行测试和调试(例如,WebStorm、RubyMine 和 Visual Studio),尤其如此。 这样就可以避免使用其他软件,并且整个 API 开发周期仅限于三个步骤:
- 在 tinyspec 文件中设计规范。
- 为添加/编辑的端点编写一整套测试。
- 实现满足测试的代码。
2. 验证输入数据
OpenAPI 不仅描述了响应格式,还描述了输入数据。 这允许您在运行时验证用户发送的数据并确保一致且安全的数据库更新。
假设我们有以下规范,它描述了用户记录的修补以及允许更新的所有可用字段:
# user.models.tinyspec UserUpdate !{name?, age?: i} # users.endpoints.tinyspec PATCH /users/:id {user: UserUpdate} => {success: b}
之前,我们探索了用于测试内验证的插件,但对于更一般的情况,有 ajv (npm) 和 json-schema (rubygem) 验证模块。 让我们用它们来编写一个带有验证的控制器:
Node.js (Koa)
这是 Express 的继承者 Koa 的一个示例,但等效的 Express 代码看起来很相似。
import Router from 'koa-router'; import Ajv from 'ajv'; import { schemas } from './schemas'; const router = new Router(); // Standard resource update action in Koa. router.patch('/:id', async (ctx) => { const updateData = ctx.body.user; // Validation using JSON schema from API specification. await validate(schemas.UserUpdate, updateData); const user = await User.findById(ctx.params.id); await user.update(updateData); ctx.body = { success: true }; }); async function validate(schema, data) { const ajv = new Ajv(); if (!ajv.validate(schema, data)) { const err = new Error(); err.errors = ajv.errors; throw err; } }
在此示例中,如果输入与规范不匹配,服务器将返回500 Internal Server Error
响应。 为避免这种情况,我们可以捕获验证器错误并形成我们自己的答案,其中将包含有关未通过验证的特定字段的更详细信息,并遵循规范。
让我们添加FieldsValidationError
的定义:
# error.models.tinyspec Error {error: b, message} InvalidField {name, message} FieldsValidationError < Error {fields: InvalidField[]}
现在让我们将其列为可能的端点响应之一:
# users.endpoints.tinyspec PATCH /users/:id {user: UserUpdate} => 200 {success: b} => 422 FieldsValidationError
当无效数据来自客户端时,这种方法允许您编写单元测试来测试错误场景的正确性。
3.模型序列化
几乎所有现代服务器框架都以一种或另一种方式使用对象关系映射 (ORM)。 这意味着 API 使用的大部分资源由模型及其实例和集合表示。
为要在响应中发送的这些实体形成 JSON 表示的过程称为序列化。

有许多用于序列化的插件:例如,sequelize-to-json (npm)、acts_as_api (rubygem) 和 jsonapi-rails (rubygem)。 基本上,这些插件允许您提供必须包含在 JSON 对象中的特定模型的字段列表以及其他规则。 例如,您可以重命名字段并动态计算它们的值。
当您需要为一个模型提供多种不同的 JSON 表示,或者当对象包含嵌套实体(关联)时,这会变得更加困难。 然后你开始需要继承、重用和序列化链接等特性。
不同的模块提供不同的解决方案,但让我们考虑一下:规范能否再次提供帮助? 基本上所有关于 JSON 表示的要求的信息,所有可能的字段组合,包括嵌入的实体,都已经在其中了。 这意味着我们可以编写一个自动序列化程序。
让我介绍一下小型 sequelize-serialize (npm) 模块,它支持对 Sequelize 模型执行此操作。 它接受模型实例或数组以及所需的模式,然后遍历它以构建序列化对象。 它还考虑了所有必填字段,并为其关联实体使用嵌套模式。
因此,假设我们需要从 API 返回所有用户在博客中的帖子,包括对这些帖子的评论。 让我们用以下规范来描述它:
# models.tinyspec Comment {authorId: i, message} Post {topic, message, comments?: Comment[]} User {name, isAdmin: b, age?: i} UserWithPosts < User {posts: Post[]} # blogUsers.endpoints.tinyspec GET /blog/users => {users: UserWithPosts[]}
现在我们可以使用 Sequelize 构建请求并返回与上述规范完全对应的序列化对象:
import Router from 'koa-router'; import serialize from 'sequelize-serialize'; import { schemas } from './schemas'; const router = new Router(); router.get('/blog/users', async (ctx) => { const users = await User.findAll({ include: [{ association: User.posts, required: true, include: [Post.comments] }] }); ctx.body = serialize(users, schemas.UserWithPosts); });
这几乎是神奇的,不是吗?
4. 静态打字
如果你足够酷,可以使用 TypeScript 或 Flow,你可能已经问过,“我宝贵的静态类型呢?!” 使用 sw2dts 或 swagger-to-flowtype 模块,您可以基于 JSON 模式生成所有必要的静态类型,并在测试、控制器和序列化程序中使用它们。
tinyspec -j sw2dts ./swagger.json -o Api.d.ts --namespace Api
现在我们可以在控制器中使用类型:
router.patch('/users/:id', async (ctx) => { // Specify type for request data object const userData: Api.UserUpdate = ctx.request.body.user; // Run spec validation await validate(schemas.UserUpdate, userData); // Query the database const user = await User.findById(ctx.params.id); await user.update(userData); // Return serialized result const serialized: Api.User = serialize(user, schemas.User); ctx.body = { user: serialized }; });
和测试:
it('Update user', async () => { // Static check for test input data. const updateData: Api.UserUpdate = { name: MODIFIED }; const res = await request.patch('/users/1', { user: updateData }); // Type helper for request response: const user: Api.User = res.body.user; expect(user).to.be.validWithSchema(schemas.User); expect(user).to.containSubset(updateData); });
请注意,生成的类型定义不仅可以在 API 项目中使用,还可以在客户端应用程序项目中用于描述与 API 一起使用的函数中的类型。 (Angular 开发人员会对此感到特别高兴。)
5. 转换查询字符串类型
如果您的 API 出于某种原因使用application/x-www-form-urlencoded
MIME 类型而不是application/json
的请求,则请求正文将如下所示:
param1=value¶m2=777¶m3=false
查询参数也是如此(例如,在GET
请求中)。 在这种情况下,Web服务器将无法自动识别类型:所有数据都是字符串格式,因此解析后会得到这个对象:
{ param1: 'value', param2: '777', param3: 'false' }
在这种情况下,请求将无法通过模式验证,因此您需要手动验证正确的参数格式并将它们转换为正确的类型。
正如您所猜到的,您可以使用规范中的旧模式来做到这一点。 假设我们有这个端点和以下模式:
# posts.endpoints.tinyspec GET /posts?PostsQuery # post.models.tinyspec PostsQuery { search, limit: i, offset: i, filter: { isRead: b } }
以下是对该端点的请求的外观:
GET /posts?search=needle&offset=10&limit=1&filter[isRead]=true
让我们编写castQuery
函数将所有参数转换为所需的类型:
function castQuery(query, schema) { _.mapValues(query, (value, key) => { const { type } = schema.properties[key] || {}; if (!value || !type) { return value; } switch (type) { case 'integer': return parseInt(value, 10); case 'number': return parseFloat(value); case 'boolean': return value !== 'false'; default: return value; } }); }
cast-with-schema (npm) 模块中提供了支持嵌套模式、数组和null
类型的更完整的实现。 现在让我们在代码中使用它:
router.get('/posts', async (ctx) => { // Cast parameters to expected types const query = castQuery(ctx.query, schemas.PostsQuery); // Run spec validation await validate(schemas.PostsQuery, query); // Query the database const posts = await Post.search(query); // Return serialized result ctx.body = { posts: serialize(posts, schemas.Post) }; });
请注意,四行代码中的三行使用规范模式。
最佳实践
我们可以在这里遵循许多最佳实践。
使用单独的创建和编辑模式
通常,描述服务器响应的模式与描述输入的模式不同,它们用于创建和编辑模型。 例如, POST
和PATCH
请求中可用的字段列表必须严格限制, PATCH
通常将所有字段标记为可选。 描述响应的模式可以更加自由。
当您自动生成 CRUDL 端点时,tinyspec 使用New
和Update
后缀。 User*
模式可以通过以下方式定义:
User {id, email, name, isAdmin: b} UserNew !{email, name} UserUpdate !{email?, name?}
尽量不要对不同的操作类型使用相同的模式,以避免由于旧模式的重用或继承而导致意外的安全问题。
遵循架构命名约定
对于不同的端点,相同模型的内容可能会有所不同。 在模式名称中使用With*
和For*
后缀来显示差异和目的。 在 tinyspec 中,模型也可以相互继承。 例如:
User {name, surname} UserWithPhotos < User {photos: Photo[]} UserForAdmin < User {id, email, lastLoginAt: d}
后缀可以变化和组合。 他们的名字必须仍然反映本质并使文档更易于阅读。
根据客户端类型分离端点
通常,同一个端点会根据客户端类型或发送请求的用户的角色返回不同的数据。 例如,对于移动应用程序用户和后台管理人员, GET /users
和GET /messages
端点可能有很大不同。 端点名称的更改可能是开销。
要多次描述同一个端点,您可以在路径后的括号中添加其类型。 这也使标签的使用变得容易:您将端点文档分成组,每个组都用于特定的 API 客户端组。 例如:
Mobile app: GET /users (mobile) => UserForMobile[] CRM admin panel: GET /users (admin) => UserForAdmin[]
REST API 文档工具
在获得 tinyspec 或 OpenAPI 格式的规范后,您可以生成漂亮的 HTML 格式文档并发布。 这将使使用您的 API 的开发人员感到高兴,而且它肯定比手动填写 REST API 文档模板要好。
除了前面提到的云服务,还有 CLI 工具可以将 OpenAPI 2.0 转换为 HTML 和 PDF,可以部署到任何静态主机。 这里有些例子:
- bootprint-openapi(npm,在 tinyspec 中默认使用)
- swagger2markup-cli(jar,有使用示例,会在tinyspec Cloud中使用)
- redoc-cli (npm)
- widdershins (npm)
你有更多的例子吗? 在评论中分享它们。
可悲的是,尽管在一年前发布,OpenAPI 3.0 仍然缺乏支持,我未能在云解决方案和 CLI 工具中找到基于它的适当文档示例。 出于同样的原因,tinyspec 还不支持 OpenAPI 3.0。
在 GitHub 上发布
发布文档的最简单方法之一是 GitHub Pages。 只需在存储库设置中为您的/docs
文件夹启用对静态页面的支持,并将 HTML 文档存储在此文件夹中。
您可以在您的scripts/package.json
文件中添加命令以通过 tinyspec 或不同的 CLI 工具生成文档,以便在每次提交后自动更新文档:
"scripts": { "docs": "tinyspec -h -o docs/", "precommit": "npm run docs" }
持续集成
您可以将文档生成添加到 CI 周期并将其发布到,例如,根据环境或 API 版本(例如/docs/2.0
、 /docs/stable
和/docs/staging
)在不同地址下的 Amazon S3。
Tinyspec 云
如果您喜欢 tinyspec 语法,您可以成为 tinyspec.cloud 的早期采用者。 我们计划基于它构建一个云服务和一个用于自动部署文档的 CLI,具有多种模板选择和开发个性化模板的能力。
REST 规范:一个奇妙的神话
REST API 开发可能是现代 Web 和移动服务开发中最令人愉快的过程之一。 没有浏览器、操作系统和屏幕大小的动物园,一切尽在您的掌控之中,触手可及。
对自动化和最新规范的支持使这个过程变得更加容易。 使用我所描述的方法的 API 变得结构良好、透明且可靠。
底线是,如果我们在创造一个神话,为什么不让它成为一个奇妙的神话呢?