个性化阅读
专注于IT技术分析

REST规范从未做过的5件事

本文概述

大多数前端和后端开发人员以前都处理过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 Schema格式的OpenAPI规范。

可以验证JS中的任何文字对象(或Ruby中的Hash, Python中的dict, PHP中的关联数组, 甚至Java中的Map), 以验证是否符合JSON Schema。甚至还有用于测试框架的适当插件, 例如jest-ajv(npm), chai-ajv-json-schema(npm)和RSpec的json_matchers(rubygem)。

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

tinyspec -j -o openapi.json

Node.js

现在, 你可以在项目中使用生成的JSON并从中获取定义键。此项包含所有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), 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&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;
    }
 });
}

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使用”新建”和”更新”后缀。可以通过以下方式定义用户*模式:

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(ASL)
  • 维德欣(ASL)

你还有更多的例子吗?在评论中分享它们。

令人遗憾的是, 尽管OpenAPI 3.0在一年前发布了, 但仍缺乏很好的支持, 我在云解决方案和CLI工具中都找不到基于它的适当文档示例。出于同样的原因, tinyspec还不支持OpenAPI 3.0。

在GitHub上发布

发布文档的最简单方法之一是GitHub Pages。只需在存储库设置中为/ docs文件夹启用对静态页面的支持, 并将HTML文档存储在此文件夹中。

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

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

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

持续集成

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

Tinyspec云

如果你喜欢tinyspec语法, 则可以成为tinyspec.cloud的早期采用者。我们计划基于此服务和CLI构建云服务, 以通过多种模板以及开发个性化模板的能力来自动部署文档。

REST规范:奇妙的神话

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

对自动化和最新规范的支持使该过程变得更加容易。使用我描述的方法的API变得结构合理, 透明且可靠。

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

相关:ActiveResource.js ORM:快速为你的JSON API构建强大的JavaScript SDK

赞(0)
未经允许不得转载:srcmini » REST规范从未做过的5件事

评论 抢沙发

评论前必须登录!