Apply OpenAPI and SwaggerUI to Phoenix Web

posted by donghyun

last updated

18 min read

태그

cover image

Documentation matters

Documentation is a love-hate relationship for developers. For the developer doing the documentation, it’s a chore that often takes up more time than writing code, doesn’t reward them in the short term, and isn’t something they can motivate themselves to do unless someone (usually a manager) encourages or forces them to do it. On the other hand, if you’re a developer who’s been thrown into an ongoing project and has to learn a lot of new things, or if you’ve suddenly taken over someone else’s work and have to maintain it, good documentation can feel like an oasis in the desert. Especially when you’re communicating between different areas, documentation is essential. For example, if a mobile client developer collaborates with a backend server developer, they’ll often develop based on API documentation.

Docs as Code

Having said that, documentation like this is hard to do diligently unless the person writing it is particularly motivated, and even then it’s not an easy task. Poorly written documentation can actually confuse the reader.

At Google, they said they’ve found that the most effective way to improve this critical piece of documentation is to “treat it like code” and integrate it into the engineering workflow. By treating writing and managing documentation the same as writing and maintaining code, they were able to weave documentation into the development process.

One way to manage documentation like code is to automatically documents the comments that the developer writes on code. This is a great way to integrate documentation into the development process, as you naturally modify comments as you maintain annotated modules and functions. However, it’s not very effective if you modify a function but don’t modify the function’s comments, or if you don’t realize you’ve documented it incorrectly. To avoid this, there are ways to combine documentation with testing. For example, the Elixir’s documentation tool, ex_doc, provides the ability to validate example code in function comments against tests via a macro called doctest.

Another area where combining code and documentation works well might be HTTP API documentation, because the API documentation is somewhat formatted and has a lot of overlap with the code, so there are a lot of related tools out there. The most famous tool would be (formerly Swagger) OpenAPI Specification(OAS).

OpenAPI Specification

OpenAPI or OpenAPI Specification (OAS) is the name given to Swagger when it passed to the OpenAPI Initiative in 2015. You could say that Swagger 2.0 led to OpenAPI 3.0, and as it addresses issues with Swagger 2.0, it serves as a very convenient tool for documenting APIs. While the OAS is an API documentation convention, tools like SwaggerUI can present it as a web document and even provide features like API test requests on the fly.

Web-Framework-Specific OAS Library

The original OAS specification is in the form of a json or yaml file, and each programming language has libraries that combine this with specific web frameworks to seamlessly combine code and specs. The advantage of combining code and API documentation in this way is that documentation management can go hand in hand with code management. This means that as you modify your API logic in branches and commits, you can also modify your documentation.

Such a library exists in Spring, for example, with SpringDoc. You can see how they use annotations to combine specs into existing controller method, as shown following code. Typically, the library will convert these combined specs into JSON format specs. As long as you have the converted specification, it’s very simple to visualize the API documentation web page with swagger-ui.

@RestController
@RequestMapping("/api/auth")
@Tag(name = "auth", description = "the authentication api")
public class AuthController {

    ...

    @Operation(summary = "login", description = "login by id password")
    @ApiResponses(value = {
        @ApiResponse(responseCode = "200", description = "successful operation", 
                  content = @Content(schema = @Schema(implementation = LoginResponse.class))),
        @ApiResponse(responseCode = "400", description = "bad request operation", 
                  content = @Content(schema = @Schema(implementation = LoginResponse.class)))
    })
    @PostMapping(value = "login", produces = { "application/json" })
    public ResponseEntity<LoginResponse> login(LoginRequest loginRequest) {
        LoginResponse loginResponse = new LoginResponse("accessTokenValue", "refreshTokenValue");
        return ResponseEntity.ok().body(loginResponse);
    }
}

open_api_spex library of Elixir

Elixir also has such a library, open_api_spex. It’s a library that combines with Phoenix, Elixir’s flagship web framework, and it’s actually the one I’ve adopted for my team. Using that library, the same controller above could be represented like:

defmodule MyAppWeb.AuthController do
  use MyAppWeb, :controller
  use OpenApiSpex.ControllerSpec

  alias MyAppWeb.Schemas.{LoginRequest, LoginResponse, ErrorResponse}

  tags ["auth"]

  operation :login,
    summary: "login",
    description: "login by id password",
    request_body: {"login params", "application/json", LoginRequest},
    responses: [
      ok: {"successful operation", "application/json", LoginResponse},
      bad_request: {"bad request operation", "application/json", ErrorResponse}
    ]

  def login(conn, _params) do
    ...
    render(conn, login_response)
  end
end

The open_api_spex library uses macros to combine controllers and specifications. The tags and operation macros are both syntaxes defined by the library to represent the OpenAPI Spec.

You can read more about the library’s usage in its documentation. The names of the macros and their respective terms mostly follow the OAS official documentation.

open_api_spex has two special abilities

In addition to this ability to combine documentation with code, open_api_spex provides two more special features. One is the ability to automatically perform validation on request parameters defined by the specification, and the other is the ability to add response validation to your tests to verify that the specification in the documentation matches the actual logic results. Let’s take a closer look at these two features.

Ability of CastAndValidate plug

First up is the ability to validate parameters. I mentioned earlier that HTTP API documentation is great to combine with code, and this is evident in parameter validation, where you can define the specification of a parameter in OAS format, and it will appear in the swagger-ui documentation, while at the same time validating it with the same conditions when processing the actual web request. For example, you can specify which parameters are required, where the range of an integer parameter starts and ends, the maximum length of a string parameter, and so on, and these become the validation check conditions for the controller action. The open_api_spex provides this validation and also a casting function that is very useful in dynamic languages like elixir. Casting is simply identifying and converting the type of each parameter field, such as string, integer, boolean, etc. It can also identify maps, arrays, and put in custom castings, so it is more powerful.

Let’s take a look at an example. Without open_api_spex in Phoenix project, we used to put parameter validation in part of each controller’s logic as shown below. Of course, it’s common practice to collect these helper functions in a utility module for reusing.

defmodule MyAppWeb.UsersController do
  use MyAppWeb, :controller

  def show(conn, %{"id" => id}) do
    with {:ok, id} <- validate_integer(id),
      ... do
      render(conn, %{user: user})
    else 
      err ->
        render_error(conn, err)
    end
  end
  
  # if `id` is a query parameter, this validation of missing parameters is needed
  def show(conn, _params) do
    render_error(conn, {:missing_parameter, "id is missing"})
  end

  defp validate_integer(id) do
    id = String.to_integer(id)
    if id > 0 do
      {:ok, id}
    else
      {:invalid_parameter, "#{id} must greater than 0"}
    end
  rescue 
    ArgumentError -> {:invalid_parameter, "#{id} is not valid integer"}
  end
end

While this isn’t necessarily inconvenient for simple parameter validation, it can become progressively more logic heavy and unmanageable for complex parameter validation. In particular, it has the potential to not match the API documentation exactly.

The CastAndValidate plug in open_api_spex allows you to perform parameter validation with the declared specification before the controller’s action is called, and to call a given error handler if the validation fails. Let’s see an example.

defmodule MyAppWeb.UsersController do
  use MyAppWeb, :controller
  use OpenApiSpex.ControllerSpec 

  alias MyAppWeb.Schemas.Commons.ID
  alias MyAppWeb.Schemas.UserResponse

  plug OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true

  operation :show,
    summary: "get a user",
    parameters: [
      id: [in: :path, description: "user ID", schema: ID]
    ],
    responses: %{
      200 => {"User", "application/json", UserResponse},
      400 -> OpenApiSpex.JsonErrorResponse.response()
    }

  def show(conn, %{id: id}) do
    with {:ok, user} <- ... do
      render(conn, %{user: user})
    else
      err ->
        render_error(conn, err)
    end
  end
end

For commonly used ID type parameter, we’ve defined them as schema components. The specification in the schema is then validated by the CastAndValidate plug. If you declare a schema like this, even a complex JSON body can be validated against the specification.

defmodule MyAppWeb.Schemas.UpdateUserParams do
  require OpenApiSpex
  alias OpenApiSpex.Schema

  OpenApiSpex.schema(%{
    title: "UpdateUserParams",
    required: [:name, :email],
    properties: %{
      id: %Schema{type: :integer}
      name: %Schema{type: :string, pattern: ~r/[a-zA-Z][a-zA-Z0-9_]+/},
      email: %Schema{type: :string, format: :email}
    }
  })
end

Ability of validation of response Spec

Now that we’ve seen validation for parameters, let’s take a look at validation for API responses. Unlike validation of parameters, validation of response values is not done in a production environment. It is not necessary to validate the response schema for each API request. Instead, the response schema can be validated in unit tests. Where you would normally write unit tests for controllers in Phoenix, you can use the schema validation tools provided by the library to verify that the API’s response matches the schema.

use MyAppWeb.ConnCase
import OpenApiSpex.TestAssertions

test "UserController produces a UsersResponse", %{conn: conn} do
  json =
    conn
    |> get(user_path(conn, :index))
    |> json_response(200)


  api_spec = MyAppWeb.ApiSpec.spec()
  assert_schema(json, "UsersResponse", api_spec)
end

By adding tests that validate these responses, you can ensure that your API documentation is always up to date by ensuring that if changes are made to the API in the future, the tests will fail if the schema is missing those changes. This is a practical example of how documentation and testing can be combined to ensure that your documentation is up to date.

These two special abilities are why open_api_spex is such a useful tool. It simplifies the logic of the controller by letting you document the specification you declare to the client through parameter validation, while automatically giving error responses for invalid arguments. This also aligns with the pragmatic programmer‘s “DRY” principle. In the book The Pragmatic Programmer, the DRY principle is stated as follows.

every piece of knowledge must have a single, unambiguous, authoritative representation within a system

The Pragmatic Programmer expands on this principle in the revised edition, noting that DRY is not just about duplication of code. If you change one aspect of your code and you change it in multiple places, it’s not DRY. With open_api_spex, you’re representing a single piece of knowledge - the API specification - in a single place, and you can control the parts of your code that relate to that knowledge at once.

Customize open_api_spex: schema definition

While open_api_spex is a great tool for adhering to these DRY principles, as an open-source library, there is still a lot of room for improvement. In particular, I don’t think the syntax is very comfortable, and while the library provides some optional syntaxes for defining specifications, I kept thinking that it could be easier and more intuitive to use.

Elixir’s ecto and absinthe libraries provide similar syntax for expressing database schema and graphql schema, respectively, and have very intuitive syntax. The schema representation for ecto is as follows.

defmodule User do
  use Ecto.Schema

  schema "users" do
    field :name, :string
    field :age, :integer
  end
end

It would be nice to be able to write these syntaxes similarly, so I tried defining them myself via macros. For example, the following schema represented above,

defmodule MyAppWeb.Schemas.UpdateUserParams do
  require OpenApiSpex
  alias OpenApiSpex.Schema

  OpenApiSpex.schema(%{
    title: "UpdateUserParams",
    required: [:name, :email],
    properties: %{
      id: %Schema{type: :integer}
      name: %Schema{type: :string, pattern: ~r/[a-zA-Z][a-zA-Z0-9_]+/},
      email: %Schema{type: :string, format: :email}
    }
  })
end

could be like this:

defmodule MyAppWeb.Schemas.UpdateUserParams do
  use OpenApiSpex.Schemax

  @required [:name, :email]
  schema "UpdateUserParams" do
    property :id, :integer
    property :name, :string, pattern: ~r/[a-zA-Z][a-zA-Z0-9_]+/
    property :email, :string, format: :email
  end
end

While it may not seem like much of a difference, I think it serves the purpose of making it look more like the schema syntax of other existing libraries to eliminate disparity and improve readability, and I’ve actually had people on my team tell me that they use the syntax I defined and they think it looks better.

I’ve also defined some of my own usage with macros to guide my team. For example, showing operations together in controller didn’t distribute the logic properly in huge legacy controllers, resulting in too much code, so I’ve taken steps to separate the controller and operation logic for readability. The beauty of elixir is that, when used properly, these macros allow you to bend the arbitrary syntax of the library to you will.

Conclusion

In this article, I introduced Docs as Code, a way to help developers with their love-hate relationship with documentation, and an example of how to combine API documentation with code. With a structured specification tool like OpenApi Spec, and a library like open_api_spex with a special feature to provide schema validation, developers can “DRY” and efficiently develop APIs. I also customized the library’s syntax to be similar to other schema syntaxes through a handy macro in Elixir to further improve usability.

The open_api_spex library is actually a library that I carefully selected and introduced to the team, and I tried to devise various macros to improve its usability. Now, the team members are gradually recognizing its convenience and usefulness, and I want to use it better as I have been thinking about it for a long time, so I will try various things in the future.

According to the book Software Engineering at Google, the state of documentation in the late 2010s, when the book was written, was similar to the state of software testing in the late 1980s.

Everyone recognizes that more effort needs to be made to improve it, but there is not yet organizational recognition of its critical benefits.

I believe that documentation is part of the development process and that there are increasingly optimal ways to make it happen efficiently in parallel with development. This article is one of them, and it’s part of the process of becoming a pragmatic programmer.