从你的 Phoenix/Elixir 代码中轻松生成 API 文档 – 第一部分

  2020 年 10 月 08 日

本文讲述的是事实以及让你能够有效地、轻松地直接从你的 Phoenix 代码中生成美观的 API 文档的技术。Phoenix 是一个用 Elixir 构建的高效 Web 框架。Elixir 是一种动态的函数式语言,旨在构建利用 Erlang VM 的可扩展和可维护的应用程序。我知道有很多新名词,但不用担心。最终我们将使用控制器测试,我们都知道这两个术语的含义。

为了给你一个简短的概述,我们将要做什么:我们将使用 phoenix_swagger 库直接从我们的控制器生成 swagger 文件。然后,我们将使用一个名为 bureaucrat 的库,该库会使用 swagger 文件,运行你的控制器测试,并生成一个包含来自两者(宏 + 测试)的信息的 Markdown 文件。最后,我们将使用 slate,它是一个静态 API 文档渲染器,用生成的 Markdown 文件喂给它,并从中生成漂亮的 HTML 文档。

PhoenixSwagger

PhoenixSwagger 是为 phoenix Web 框架提供 swagger 集成的库。该库目前支持 OpenAPI 规范版本 2.0 (OAS)。尚未支持 OAS 的 3.0 版本。要将 PhoenixSwagger 与 phoenix 应用程序一起使用,只需将其添加到 mix.exs 文件中的依赖项列表中即可


def deps do
  [
    {:phoenix_swagger, "~> 0.8"},
    {:ex_json_schema, "~> 0.5"} # optional
  ]
end

ex_json_schema 是 PhoenixSwagger 的可选依赖项,但我们稍后会用到它,所以请也安装它。现在运行 mix deps.get

接下来,向你的 Phoenix 应用程序添加一个配置条目,指定用于生成 swagger 文件的输出文件名、路由器和端点模块


config :my_app, :phoenix_swagger,
  swagger_files: %{
    "priv/static/swagger.json" => [
      router: MyAppWeb.Router, 
      endpoint: MyAppWeb.Endpoint
    ]
  }


你还可以配置 PhoenixSwagger 以使用 Jason 作为 JSON 库。我强烈建议这样做,因为 Jason 是 Phoenix 的默认 JSON 库,而且它似乎正在赢得与 Poison 相比作为主要 JSON Elixir 库的地位。


config :phoenix_swagger, json_library: Jason

现在我们需要创建你的 swagger 文档的概要。将概要想象为关于你的 API 的一般信息、名称、版本、服务条款、安全定义等… 概要以从你的 Router 模块中定义的 swagger_info/0 函数返回的映射形式实现。


defmodule MyApp.Router do
  use MyApp.Web, :router


  pipeline :api do 
   plug :accepts, ["json"]
  end

  scope "/api", MyApp do
    pipe_through :api
    resources "/users", UserController
  end 

  def swagger_info do
    %{
      schemes: ["http", "https", "ws", "wss"],
      info: %{
        version: "1.0",
        title: "MyAPI",
        description: "API Documentation for MyAPI v1",
        termsOfService: "Open for public",
        contact: %{
          name: "Vladimir Gorej",
          email: "[email protected]"
        }
      },
      securityDefinitions: %{
        Bearer: %{
          type: "apiKey",
          name: "Authorization",
          description:
          "API Token must be provided via `Authorization: Bearer ` header",
      in: "header"
        }
      },
      consumes: ["application/json"],
      produces: ["application/json"],
      tags: [
        %{name: "Users", description: "User resources"},
      ]
    }
  end
end    


有关可能包含的其他信息的详细信息,请参阅 Swagger 对象规范。现在运行以下命令,swagger 规范文件 (swagger.json) 将在 ./priv/static/ 目录中生成。

	
$ mix phx.swagger.generate
	

当然,此时,生成的 swagger 规范文件仅包含我们 Router 模块内的 swagger_info/0 函数提供的信息。为了使其具有实际价值,我们必须使用 PhoenixSwagger 宏来装饰我们的控制器操作。我在这里不详细介绍如何做到这一点。phoenix_swagger 文档在指导你正确创建这些宏方面做得足够好。

	
import Plug.Conn.Status, only: [code: 1]
use PhoenixSwagger

swagger_path :index do
  get("/users")
  description("List of users")
  response(code(:ok), "Success")
end

def index(conn, _params) do
  users = Repo.all(User)
  render(conn, :index, users: users)
end
	

此示例演示了一个非常简单的装饰示例。有关更复杂的宏,请参阅 phoenix_swagger 文档。另请注意,在 response 宏中,我使用的是 code(:ok) 函数将 :ok 原子转换为数值 200,而不是数字 HTTP 代码。这是我个人的约定。我宁愿阅读具有明确含义的原子,也不愿阅读 HTTP 数字代码,我总是要思考一会儿这个代码代表什么。

不幸的是,在使用 PhoenixSwagger 宏装饰我的控制器时,我遇到了两个问题。

1.) 共享公共模式

PhoenixSwagger 没有关于如何共享公共 模式 的惯用解决方案。模式是对端点返回的数据结构的声明性描述。为了定义什么是公共模式

公共模式是在多个控制器中使用的模式。

记住这一点非常重要。如果你想保持你的模式 DRY,你必须想出一种方法来实现跨控制器共享你的模式。由于必要性,我不得不提出一个解决方案。最初,我在 PhoenixSwagger 存储库上创建了 issue,然后我提供了一个 Pull Request (PR),其中包含提案/解决方案的完整文档。我们仍在与 Pull Request 中的库作者合作,以提出最佳解决方案,但以下是该提案的现状

这是你通常为你的控制器定义模式的方式

	
  def swagger_definitions do
  %{
    User:
      swagger_schema do
        title("User")
        description("A user of the application")

        properties do
          name(:string, "Users name", required: true)
        end
      end
  }
end
	

这是你使用公共模式支持为你的控制器定义模式的方式:

	
  def swagger_definitions do
  create_swagger_definitions(%{
    User:
      swagger_schema do
        title("User")
        description("A user of the application")

        properties do
          name(:string, "Users name", required: true)
        end
      end
  })
end
	

区别是微妙的,但非常重要。我们不是返回 map,而是调用 create_swagger_definitions/1 函数,它返回作为函数唯一参数提供的模式,并与公共模式合并。有关其工作原理的更多信息,请查看我上面提到的 PR。

注意:实际的解决方案可能会在 Pull Request 实际合并之前发生变化

2.) 不支持嵌套的 Phoenix 资源

这实际上让我很失望。Phoenix 框架支持 嵌套资源创建嵌套资源时,你最终会在控制器模块内部拥有具有相同名称但不同签名操作

Router 模块

	
resources "/groups", GroupController, only: 
[:show] do
resources "/users", UserController, only: 
[:index]
end
	

UserController 模块

	
swagger_path :index do
  get("/groups/{group_id}/users")
  description("List of users specific to group")
  response(code(:ok), "Success")
end

def index(conn, %{"group_id" => group_id}) do
  ...controller body...
end

# vs

swagger_path :index do
  get("/users")
  description("List of users")
  response(code(:ok), "Success")
end

def index(%Plug.Conn{} = conn, params) do
  ...controller body...
end
	

如你所见,我们使用模式匹配来将适当的控制器操作与路由器映射进行匹配。问题就在这里。PhoenixSwagger 无法为具有相同名称 (:index) 的控制器操作创建宏。第一个宏将始终匹配 :index 操作。


当我发现这可能是一个问题时,我做的第一件事是在 PhoenixSwagger 仓库上创建了一个issue。不幸的是,我等不及别人提供一个解决方案,所以我连接了我今晚剩余的脑细胞,自己想出了多个解决方法。其中一个解决方法看起来很有希望,所以我继续在此基础上进行构建,最终它变成了一个可行的理论。当然,我必须做出妥协。我必须在我的 Router 模块中摆脱嵌套资源,但我不需要修改我的控制器文件,这是一个巨大的胜利。魔力在于 defdelegate Elixir 宏

Router 模块

	
resources "/groups", GroupController, only: [:show]
get "/groups/:group_id/users", UserController, :index_by_group
	

UserController 模块

	
defdelegate index_by_group(conn, params),
  to: UserController,
  as: :index

swagger_path :index_by_group do
  get("/groups/{group_id}/users")
  description("List of users specific to group")
  response(code(:ok), "Success")
end

def index(conn, %{"group_id" => group_id}) do
  ...controller body...
end

# vs

swagger_path :index do
  get("/users")
  description("List of users")
  response(code(:ok), "Success")
end


def index(%Plug.Conn{} = conn, params) do
   ...controller body...
end
	

defdelegate 允许我为 :index 函数之一创建本地别名,而 PhoenixSwagger 宏会使用这个委托。这样一来,PhoenixSwagger 就认为它有两个不同的控制器动作名称。正如我之前提到的,你不能再使用嵌套资源,但嵌套是我愿意接受的代价。有关嵌套 Phoenix 资源与 PhoenixSwagger 的更多信息、替代解决方案或任何其他重要信息,请查看 GitHub issue

现在所有问题都解决了,让我们再次运行以下命令,你新生成的 swagger spec 文件将包含控制器宏中定义的所有信息,并转换为 JSON 表示形式。

	       
$ mix phx.swagger.generate
	

现在,PhoenixSwagger 库还有一个最终的巧妙技巧。该库包含一个 plug,其中包含从你的 Phoenix 应用程序托管 SwaggerUI 所需的所有静态资源。在你的路由器中添加一个 swagger 范围,并将所有请求转发到 SwaggerUI

	
scope "/api/swagger" do
  forward "/", PhoenixSwagger.Plug.SwaggerUI,
    otp_app: :my_app,
    swagger_file: "swagger.json"
end
	

使用 `mix phx.server` 运行服务器,并浏览到 `localhost:4000/api/swagger`。应该显示 SwaggerUI,并加载你的 swagger spec 文件。

为了在不使用 SwaggerUI 的情况下访问你的 swagger spec 文件,只需访问以下 URL:`localhost:4000/api/swagger/swagger.json`。

现在你了解了 PhoenixSwagger 库的主要功能和局限性。如果你到了这里,你已经知道如何利用它并直接从你的代码生成 API 文档。就我个人而言,当我需要快速了解控制器在做什么以及它返回哪些状态代码时,PhoenixSwagger 宏会帮助我,而无需实际读取控制器代码,只需查看控制器宏即可。

现在唯一缺少的是,我们为每个使用宏装饰的 phoenix 控制器动作生成的 swagger spec 文件中缺少 API 调用示例(请求/响应对)。是的,SwaggerUI 将允许我们针对我们的 API 发出实际的 HTTP 请求,但这还不够。而这正是 bureaucrat 发挥作用的地方。它可以轻松地集成到我们现有的解决方案中。但这将在另一篇文章,本系列的第 2 部分中介绍。

提示 1

PhoenixSwagger 支持 x-nullable,以允许 Schema 属性或请求参数可为空。

	
last_modified(:string, "Datetime of video file last modification",
  format: "date-time",
  "x-nullable": true
)
	

提示 2

避免使用实际类型和 :null 原子对来表示 Schema 属性或请求参数可为空。请改用提示 1 中提到的 x-nullable。当你验证 swagger editor 中的 swagger spec 文件时,像这样定义的字段会产生错误。

	
      
# Don't do this
last_modified([:string, :null], "Datetime of video file last modification",
  format: "date-time"
)
	

如果你喜欢这篇文章,可以在 Twitter 上关注我:@vladimirgorej

 

从你的 Phoenix/Elixir 代码轻松生成 API 文档系列

  • 第 1 部分:Phoenix Swagger
  • 第 2 部分:Bureaucrat - 从测试中生成 API 文档
  • 第 3 部分:Slate - 美丽的静态 HTML 文档