使用REST规范从未完成的5件事


原文请访问 5 Things You Have Never Done with a REST Specification

大多数的前后端开发人员以前就已经接触过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规范世界。

OpenAPI

OpenAPI是目前最广泛接受的REST API规范格式。规范以JSON或YAML格式编写在单个文件中,包含三个部分:

  1. 包含API名称,描述和版本的标头,以及任何其他信息。
  2. 所有资源的描述,包括标识符,HTTP方法,所有输入参数,响应代码和正文数据类型,以及定义的链接。
  3. 所有可用于输入或输出的定义,采用JSON Schema格式(是的,也可以用YAML表示)。

OpenAPI的结构有两个明显的缺点:它过于复杂,有时甚至是多余的。一个小项目可以拥有数千行的JSON规范。手动维护此文件变得不可能。这对于在开发API时保持规范最新的想法是一个重大威胁。

有多个编辑器允许您描述API并生成OpenAPI输出。基于它们的其他服务和云解决方案包括Swagger,Apiary,Stoplight,Restlet等等。

但是,由于快速规范编辑的复杂性以及与代码更改的对齐,这些服务对我来说不方便。此外,功能列表取决于特定服务。例如,基于云服务的工具创建完整的单元测试几乎是不可能的。代码生成和嘲弄端点虽然看似实用,但在实践中却变得无用。这主要是因为端点行为通常取决于各种事物,例如用户权限和输入参数,这对于API架构师来说可能是显而易见的,但是不容易从OpenAPI规范自动生成。

Tinyspec

在本文中,我将使用基于我自己的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则有空中传输。

假设我们有一个User模式和一个GET /users返回所有用户的端点。这是一些描述这个的tinyspec语法:

# user.models.tinyspec
User {name, isAdmin: b, age?: i}

# users.endpoints.tinyspec
GET /users  
    => {users: User[]}

以下是我们如何编写相应的测试:

Node.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模式格式的OpenAPI规范。

JS(或HashRuby,dictPython中的关联数组,甚至MapJava中)中的任何文字对象都可以验证JSON模式合规性。甚至有适当的插件测试框架,例如 笑话,ajv(NPM),柴ajv-JSON-模式(NPM)和json_matchers对RSpec的(rubygem)。

在使用模式之前,让我们将它们导入到项目中。首先,openapi.json根据tinyspec规范生成文件(您可以在每次测试运行之前自动执行此操作):

tinyspec -j -o openapi.json  
Node.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开发周期仅限于三个步骤:

  1. 在tinyspec文件中设计规范。
  2. 为添加/编辑的端点编写一整套测试。
  3. 实现满足测试的代码。

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),actsasapi(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&param2=777&param3=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;
    }
 });
}

null在cast-with-schema(npm)模块中提供了一个支持嵌套模式,数组和类型的更全面的实现。现在让我们在代码中使用它:

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}  

后缀可以变化和组合。他们的名字仍然必须反映其本质,并使文档更易于阅读。

就像你在读什么?首先获取最新的更新。

Enter your email address... 没有垃圾邮件。只是很棒的文章和见解。 基于客户端类型分离端点 通常,同一端点会根据客户端类型或发送请求的用户的角色返回不同的数据。例如,移动应用程序用户和后台管理员的端点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文档存储在此文件夹中。

通过GitHub Pages从/ docs文件夹托管REST规范的HTML文档。

您可以通过tinyspec或文件中的其他CLI工具添加命令以生成文档,scripts/package.json以在每次提交后自动更新文档:

"scripts": {
    "docs": "tinyspec -h -o docs/",
    "precommit": "npm run docs"
}

持续集成

您可以根据环境或者API版本在不同的地址生成文档添加到您的CI周期并发布,例如,到Amazon S3(如/docs/2.0,/docs/stable和/docs/staging)。

Tinyspec云

如果你喜欢tinyspec语法,你可以成为tinyspec.cloud的早期采用者。我们计划基于它构建一个云服务和一个CLI,用于自动部署文档,其中包含多种模板选项和开发个性化模板的能力。

REST规范:一个奇妙的神话

REST API开发可能是现代Web和移动服务开发中最令人愉快的过程之一。没有浏览器,操作系统和屏幕尺寸的动物园,一切都在您的控制之下,触手可及。

通过支持自动化和最新规范,这一过程变得更加容易。使用我所描述的方法的API变得结构良好,透明且可靠。

最重要的是,如果我们要成为一个神话,为什么不把它变成一个奇妙的神话呢?

Egor

一位喜欢开发的翻译人员,艾翻译运营之一。