APIs文档驱动开发

APIs文档驱动开发

译自Documentation-driven development for APIs: what is it, how do you do it and why you should do it?

文档驱动开发是API开发的一种方法,首先编写API文档,然后根据每个规范实现API。如果你的系统中有API的任何客户端(例如,前端应用程序),同样也要根据规范实现。这种方法通常也被叫做API优先。

我们通常是这样的思路,即API应该由后端驱动,并且后端可以随时更改API,然后API客户端(例如,前端应用程序)必须遵循后端对API的任意更改。

许多开发人员的思路是,在后端实现API之前,你不能开始使用API客户端(例如,前端应用程序)。事实不是这样的:如果你首先编写文档,这样客户端应用程序以及API服务可以同时实现。

但是,关于APIs并不是所有内容都算作文档。APIs文档编写必须要有标准格式,例如OpenAPI,这样我们就可以利用文档驱动开发了。我已经看到许多团队使用诸如Confluence、Google Docs、Sharepoint、Dropbox Papers这些类似的工具来编写API文档。这样是行不通的,因为我们无法从中生成标准的规范,所以我们就无法结合其他工具来测试我们的API实现。

在实践中这是怎么工作的?完全有益吗?我将展示用Flask开发REST API来实践文档驱动开发。该方法对于任何其他API都相同,并且可以与任何其他框架一起使用。

我们将要实现一个简单的API管理待办清单。待办事项结构具有下面的属性:

  • UUID格式ID
  • 创建的时间戳timestamp
  • 字符串格式任务task
  • 字符串格式状态status,可以是以下之一:pending,progress,completed

创建的属性ID由后端设置,因此对于API客户端只读。task属性代表需要做的任务,status属性代表任务的状态,并且只能采用枚举值之一。为了得到最佳实践和可靠性,在待办事项结构中未列出的属性请求我们视为无效。

我们有2个URL路径来管理待办事项:

  • /todo
  • /todo/{item_id}

/todo代表所有待办事项集合,我们将使用它来获取所有待办事项列表并且创建新的待办事项。我们将要使用/todo/{item_id}来管理指定的任务,并且我们将使用它获取指定任务的详细信息,然后更新和删除它。

现在,我们已经完成了API的设计过程,在实现它之前,我们首先来编写文档。

我们之前所说需要实现一个REST API,所以我们使用OpenAPI来编写。创建一个oas.yaml的文件并且将下面的内容写入进去:

openapi: 3.0.3

info:
  title: TODO API
  description: API that allows you to manage a to-do list
  version: 1.0.0

paths:
  /todo/:
    get:
      summary: Returns a list of to-do items
      responses:
        '200':
          description: A JSON array of tasks
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/GetTaskSchema'
    post:
      summary: Creates an task
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateTaskSchema'
      responses:
        '201':
          description: A JSON representation of the created task
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GetTaskSchema'
  /todo/{item_id}:
    parameters:
      - in: path
        name: item_id
        required: true
        schema:
          type: string
          format: uuid
    get:
      summary: Returns the details of a task
      responses:
        '200':
          description: A JSON representation of a task
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GetTaskSchema'
        '404':
          $ref: '#/components/responses/NotFund'
    put:
      summary: Updates an existing task
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateTaskSchema'
      responses:
        '200':
          description: A JSON representation of a task
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GetTaskSchema'
        '404':
          $ref: '#/components/responses/NotFund'
    delete:
      summary: Deletes an existing task
      responses:
        '204':
          description: The resource was deleted successfully
        '404':
          $ref: '#/components/responses/NotFund'

components:
  responses:
    NotFund:
      description: The specified resource was not found
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'

  schemas:
    Error:
      type: object
      properties:
        code:
          type: number
        message:
          type: string
        status:
          type: string
    CreateTaskSchema:
      type: object
      required: 
        - task
      additionalProperties: false
      properties: 
        status:
          type: string
          enum:
            - pending
            - progress
            - completed
          default: pending
        task:
          type: string
    GetTaskSchema:
      type: object
      required: 
        - created
        - id
        - priority
        - status
        - task
      additionalProperties: false
      properties: 
        id:
          type: string
          format: uuid
        created:
          type: integer
          description: Date in the form of UNIX timestmap
        status:
          type: string
          enum:
            - pending
            - progress
            - completed
        task:
          type: string

现在我们有了API规范,就可以开始实现API服务端。如果你与另一个实现API客户端(例如,前端应用程序)的团队一起工作,请确保API规范在*仓库(例如,GITHUB仓库或URL端)并且对所有团队成员可用。稍后,我将要写另一篇文章介绍你应该如何做。

我们将要使用Flask和flask-smorest来实现API。flask-smorest是一个REST API框架并使用marshmallow认证schemas。为了演示,我们将使用最简单的方式:整个应用源码将放入一个文件中,并且待办事项在内存中表示。在真实的app中你应该使用持久层存储,并且不同的组件应该放入不同的模块中。本示例中只有一个依赖,请确保flask-smorest已经安装。创建app.py文件并且将下面内容拷贝进去:

import time
import uuid

from flask import Flask
from flask.views import MethodView
from flask_smorest import Api, Blueprint, abort
from marshmallow import Schema, fields, EXCLUDE, validate

app = Flask(__name__)
app.config['API_TITLE'] = 'TODO API'
app.config['API_VERSION'] = '1.0.0'
app.config['OPENAPI_VERSION'] = '3.0.3'
app.config['OPENAPI_JSON_PATH'] = "api-spec.json"
app.config['OPENAPI_URL_PREFIX'] = "/"
app.config['OPENAPI_SWAGGER_UI_PATH'] = "/docs"
app.config['OPENAPI_SWAGGER_UI_RUL'] = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/"

API_TITLE = 'Orders API'
API_VERSION = '1.0.0'
api = Api(app)

class CreateTaskSchema(Schema):
    class Meta:
        unknown = EXCLUDE
        
    status = fields.String(default = 'pending',
                           validate = validate.OneOf(['pending', 'progress', 'completed']))
    task = fields.String()
    
class GetTaskSchema(CreateTaskSchema):
    created = fields.Integer(required = True)
    id = fields.UUID(required = True)
    
blueprint = Blueprint('todo', 'todo', url_prefix = '/todo',
                     description = 'API that allows you to manage a to-do list')
TODO_LIST = []

@blueprint.route('/')
class TodoItems(MethodView):
    
    @blueprint.response(GetTaskSchema(many = True))
    def get(self):
        return TODO_LIST
    
    @blueprint.arguments(CreateTaskSchema)
    @blueprint.response(GetTaskSchema, code = 201)
    def post(self, item):
        item['created'] = time.time()
        item['id'] = str(uuid.uuid4())
        TODO_LIST.append(item)
        return item
    
@blueprint.route('/<item_id>')
class TodoItem(MethodView):
    
    @blueprint.response(GetTaskSchema)
    def get(self, item_id):
        for item in TODO_LIST:
            if item['id'] == item_id:
                return item
        abort(404, message = 'Item not found.')
        
    @blueprint.arguments(CreateTaskSchema)
    @blueprint.response(GetTaskSchema)
    def put(self, update_data, item_id):
        for item in TODO_LIST:
            if item['id'] == item_id:
                item.update(update_data)
                return item
        abort(404, message = 'Item not found.')
        
    @blueprint.response(code = 204)
    def delete(self, item_id):
        for index, item in enumerate(TODO_LIST):
            if item['id'] == item_id:
                TODO_LIST.pop(index)
                return
        abort(404, message = 'Item not found.')
        
api.register_blueprint(blueprint)

你可以使用以下命令运行app:

$ FLASK_APP=app:app flask run

根据app的配置,你可以在/docs下访问使用Swagger UI主题从app自动生成的API文档。

太好了,现在我们如何测试实现以确保它符合规范?你可以使用不同的工具和框架来完成。在这里,我将演示如何使用Dredd。Dredd是一个npm包,所以你可以运行下面命令来安装它:

$ npm install dredd

执行下面的命令来运行dredd:

$ ./node_modules/.bin/dredd oas.yaml http://127.0.0.1:5000 --server "flask run"

如果你现在运行此命令,则会收到一条错误消息,提示我们需要在/todo/{item_id}URL路径中提供有关item_id参数的示例。你还会看到一些有关规范格式的问题的警告,由于API规范有效,你可以放心忽略这些警告(你可以使用外部工具来验证,例如:https://editor.swagger.io/)。让我们继续来添加item_id的示例:

/todo/{item_id}:
  parameters:
    - in: path
      name: item_id
      required: true
      schema:
        type: string
        format: uuid
      example: d222e7a3-6afb-463a-9709-38eb70cc670d
      ...

如果你现在再运行dredd,你的测试会有5个通过3个失败。你仔细查看这些失败,你就会发现所有这些都与/todo/{item_id}URL路径下对现有资源的操作有关。看来dredd正在选择规范中提供的示例ID,并期望具有该ID的资源存在。显然,在开始运行API之前,不存在任何资源,因此我们希望dredd首先使用POST/todo/实际创建资源,获取已创建资源的ID,然后使用示例来测试/todo/{item_id}URL路径端。我们应该怎么做呢?答案是dredd hooks!

dredd hooks提供了一个简单的接口允许用户在事务之前和之后执行动作。每个事务通过名字标记,该名字是唯一标识操作的不同参数的组合。运行下面的命令可以列出你规范中所有可用的名字:

$ ./node_modules/.bin/dredd oas.yaml http://127.0.0.1:5000 --server "flask run" --names

可用名称是命令列出的每个info块:

APIs文档驱动开发

我们想要在/todo/{item_id}URL路径下成功操作http动作名称,即返回操作成功状态码(例如,200和204)。创建一个hooks.py文件,并将下面内容拷贝进去:

import json
import dredd_hooks

response_stash = {}

@dredd_hooks.after('/todo/ > Create an task > 201 > application/json')
def save_created_task(transaction):
    response_payload = transaction['results']['fields']['body']['values']['actual']
    task_id = json.loads(response_payload)['id']
    response_stash['created_task_id'] = task_id
    
@dredd_hooks.before('/todo/{item_id} > Returns the details of a task > 200 > application/json')
def before_get_task(transaction):
    transaction['fullPath'] = '/todo/' + response_stash['created_task_id']
    
@dredd_hooks.before('/todo/{item_id} > Updates an existing task > 200 > application/json')
def before_put_task(transaction):
    transaction['fullPath'] = '/todo' + response_stash['created_task_id']
    
@dredd_hooks.before('/todo/{item_id} > Deletes an existing task > 204')
def before_delete_task(transaction):
    transaction['fullPath'] = '/todo/' + response_stash['created_task_id']

在另一个教程中,我将详细介绍dredd和dread hooks。你可以通过下面的命令来运行dredd with hooks:

$ ./node_modules/.bin/dredd oas.yaml http://127.0.0.1:5000 --hookfiles=./hooks.py --language=python --server "flask run"

现在所有测试都通过了:

APIs文档驱动开发

所以现在我们可以确定我们实现的API是符合规范的。如果另一个前端工作团队也做了同样的测试,我们就可以肯定服务端和前端的应用可以完美的集成并且没有任何错误。或者说至少我们不会应为不遵守API而遇到集成错误。

现在,我们要更改API的话。需求这样,我们希望为每个任务分配优先级。这将在task资源payload中添加一个新的字段叫做priority,这个字段具有以下值之一:

  • low
  • medium
  • high

默认值为low

我们应该如何应对这种改变呢?我们这是文档驱动开发实践,所以首先当然修改规范啦。我们更新一次规范,我们就应当同时更新后端和前端。任何后端不符合API规范的都会测试失败,并且都不应该被发布上线。

更新oas.yaml中的shemas如下:

...
  CreateTaskSchema:
    type: object
    required: 
      - task
    additionalProperties: false
    properties: 
      properties:
        type: string
        enum:
          - low
          - medium
          - high
        default: low
      status:
        type: string
        enum:
          - pending
          - progress
          - completed
        default: pending
      task:
        type: string
  GetTaskSchema:
    type: object
    required: 
      - created
      - id
      - priority
      - status
      - task
    additionalProperties: false
    properties: 
      id:
        type: string
        format: uuid
      created:
        type: integer
        description: Date in the form of UNIX timestmap
      priority:
        type: string
        enum:
          - low
          - medium
          - high
        default: low
      status:
        type: string
        enum:
          - pending
          - progress
          - completed
      task:
        type: string

如果你现在运行dredd命令则测试会失败:

$ ./node_modules/.bin/dredd oas.yaml http://127.0.0.1:5000 --hookfiles=./hooks.py --language=python --server "flask run"

APIs文档驱动开发

为了解决这个问题,我们只需要更新marshmallow schemas:

class CreateTaskSchema(Schema):
    class Meta:
        unknown = EXCLUDE
        
    priority = fields.String(default = 'low',
                            validate = validate.OneOf(['low', 'medium', 'high']))
    status = fields.String(default = 'pending',
                           validate = validate.OneOf(['pending', 'progress', 'completed']))
    task = fields.String()
    
class GetTaskSchema(CreateTaskSchema):
    created = fields.Integer(required = True)
    id = fields.UUID(required = True)

如果你再次运行dredd命令,现在测试通过。在这个例子中,更新marshmallow schemas足够更新app了。在真实应用中,你需要持久化到数据库并且你还需要运行一些迁移以更新你的模块。

如果你喜欢这篇文章,别忘了点赞加收藏哦。

如果有任何翻译有问题的地方请联系goalidea@outlook.com

上一篇:java抽象方法和抽象类


下一篇:StructedStreaming-基于事件时间的窗口计算