🤩

OpenAPIを活用したスキーマの自動生成や型安全な開発について紹介

2022/05/25に公開

こんにちは、メディアエンジンの田中です!

弊社では、主にフロントエンド開発の効率化やバックエンドとフロントエンドのエンジニアのコミュニケーションの円滑化などを目的として、OpenAPIを活用しています。

この記事では、弊社でのOpenAPIの活用例などについて解説したいと思います。

OpenAPI Specificationとは

OpenAPI Specificationでは、特定の言語に依存せずにREST APIの仕様を記述するためのフォーマットなどが定義されています。

https://swagger.io/specification/

このOpenAPI Specificationを元にして様々なツールなどが開発されており、エコシステムが豊富であることなどが特徴です。

スキーマの管理について

バックエンドコードからのスキーマ自動生成

弊社では、バックエンドのAPIサーバをRailsまたはGo(+Echo)を使用して実装しています。

その際に、rswagswagなどを活用して、バックエンドのソースコードやテストコードなどからスキーマを自動生成させています。

https://github.com/rswag/rswag

https://github.com/swaggo/swag

rswag

rswagはrspecを使って記述されたテストコードの内容をもとに自動でOpenAPIスキーマを生成してくれるgemです。

例えば、下記のようにしてRailsで実装したAPIに対するテストケースを記述することで、自動でスキーマが生成されます。

spec/requests/api/users_spec.rb
require 'request_helper'
require 'swagger_helper'

RSpec.describe 'Api::Users', type: :request do
  path '/api/users/{id}' do
    get 'returns a user' do
      tags 'users'
      produces 'application/json'

      let(:user) { create(:user) }
      let(:id) { user.id }

      response '200', 'when authenticated' do
        schema type: :array, items: { '$ref' => '#/components/schemas/User' }
        run_test! do |response|
          data = JSON.parse(response.body)
          expect(data['id']).to eq(id)
        end
      end
    end
  end
end

共通で使用するレスポンスのスキーマ(上記の例ですと#/components/schemas/User)はswagger_helper.rbで定義しており、下記のようなコードになっております。

spec/swagger_helper.rb
require 'rails_helper'

RSpec.configure do |config|
  config.swagger_root = Rails.root.join('swagger').to_s
  config.swagger_docs = {
    'v1/swagger.yaml' => {
      openapi: '3.0.1',
      components: {
        # 共通で使用するスキーマを定義
        schemas: {
          User: {
            type: :object,
            additionalProperties: false,
            properties: {
              id: { type: :integer },
              name: { type: :string }
            },
            required: %w[id name]
          }
      },
      info: {
        title: 'API V1',
        version: 'v1'
      },
      paths: {},
      servers: [
        {
          url: 'https://{defaultHost}',
          variables: {
            defaultHost: {
              default: 'www.example.com'
            }
          }
        }
      ],
    }
  }
end

rswagは、実際に返ってきたレスポンスの内容がスキーマの定義を満たしていない場合、テストを失敗させてくれるため、レスポンスの内容とスキーマの定義が食い違うことを防止することができて便利です。

swag

swagは、Goのソースコードに記述されたコメントなどの内容を元に、スキーマを自動生成してくれます。

例えば、下記はEchoのハンドラにコメントを記述した例です。

// @ID           getArticle
// @Summary      指定されたIDの記事を返却します
// @Tags         articles
// @Security     Bearer
// @Accept       json
// @Produce      json
// @Param        id path int true "記事のID"
// @Success      200 {object} getArticleResponse
// @Failure      500 {object} echo.HTTPError
// @Router       /api/articles/{id} [get]
func GetArticle(c echo.Context) error {
	// ...
}

swagは、このようなコメントや構造体などの定義を解析し、OpenAPIのスキーマ定義を自動で生成してくれます。

メリット・デメリットについて

これらのソースコードやテストコードからスキーマを生成するアプローチは、便利ではあるものの、チーム体制などによってはデメリットが出てくることも考えられそうです。

  • メリット
    • ソースコードやテストコードを元にスキーマが生成されるため、スキーマがメンテナンスされないという事態に陥りにくい (はず)
  • デメリット
    • フロントエンド開発の観点からすると、バックエンドのコードを触らないとスキーマを更新できないのが少々不便

そのため、チームの開発体制などによっては、ここで紹介したソースコードなどからスキーマを自動生成するアプローチをとるのではなく、直接スキーマを編集するようにした方がよい場合も考えられそうです。

その場合は、例えばStoplight Studioなどのツールの活用も検討するとよさそうです。

フロントエンド開発での活用

フロントエンド開発では、Vue.jsとTypeScriptを採用しています。

バックエンドとの間のやり取りはREST APIを介して行なっており、バックエンドで生成したOpenAPIのスキーマを様々な用途で活用しています。

ここでは、その一例について解説いたします。

型定義の自動生成

openapi-typescriptというパッケージを使用して、スキーマからTypeScriptの型定義を自動生成しています。

https://github.com/drwpow/openapi-typescript

使い方としては、下記のようにしてOpenAPIのスキーマからTypeScriptの型定義を自動生成できます。

$ yarn openapi-typescript path/to/swagger.json \
    -o types/openapi.ts \
    --prettier-config .prettier

ここで生成された型は後述するスタブサーバやAPIクライアントを型安全にするために利用しています。

APIのスタブについて

フロントエンド開発をする上で、ちょっとしたUIの修正をしたい場合などは、APIのスタブサーバを用意できると便利です。

弊社では、元々この用途としてPrismを利用していたのですが、現在はmswへ移行しています。

ここではその経緯などについて解説いたします。

1. Prism

元々、Prismというツールを使用してAPIのスタブサーバを用意していました。

https://github.com/stoplightio/prism/

Prismは、OpenAPIのスキーマファイルを引数に指定するだけでスタブサーバを起動でき、手軽に利用できるのがとてもよいです。

$ prism mock -d path/to/swagger.json 

また、Prismではx-faker属性を宣言しておくことで、Faker.jsを使用したダミーデータの生成ができるのが便利だったりします。

https://github.com/stoplightio/prism/blob/v4.9.3/docs/guides/01-mocking.md#dynamic-response-generation

ただし、このPrismを活用する中で、色々と課題も出てきたため、現在では次に解説するmswを活用しています。

2. msw

Prismが返却するデータをカスタマイズするためには、あらかじめexamplex-faker属性などを定義しておく必要があります。

しかし、先述した通り、弊社ではOpenAPIのスキーマをバックエンドのソースコードから自動生成するアプローチを採用しています。
そのため、フロントエンドエンジニアが開発時に使うテストデータを調整したいような場合でも、バックエンドのソースを修正する必要が出てきてしまいます。

これでは開発体験があまりよろしくないということで、より柔軟に利用ができるmswに移行することにしました。

https://github.com/mswjs/msw

また、mswを導入しておけば、テストコードやStorybookなどからもそれを活用できるなど様々な利点があります。

具体的な使用方法としては、openapi-typescriptで生成した型定義を活用するために、下記のようなヘルパを用意しています。

import assert from 'assert'
import { rest } from 'msw'
import type { RestRequest } from 'msw'
import type { Entry, ValueOf } from 'type-fest'
import type { paths, operations } from '@/types/openapi'

type MswMethod = keyof typeof rest

type Paths = keyof paths
type Operations = ValueOf<operations>

export function openapi<
  TPath extends Paths,
  TMethod extends keyof paths[TPath],
  TOperation extends Operations = paths[TPath][TMethod] & Operations
>(path: TPath, method: TMethod, handler: (req: RestRequest) => Entry<TOperation['responses']>) {
  const mswPath = openAPIPathToMswPath(path) // NOTE: OpenAPIとmswとではパスのフォーマットが異なるため、ここで正規化しています
  return rest[method as MswMethod](mswPath, (req, res, ctx) => {
    const [status, { schema }] = handler(req)
    return res(ctx.json(schema), ctx.status(status as number))
  })
}

function openAPIPathToMswPath(path: string): string {
  return path.replaceAll(/{([^}]+)}/g, (_, $1) => {
    return `:${$1}`
  })
}

以下のようなイメージでハンドラーを定義できます。

const users = [
  { id: 1, name: 'foo' },
  { id: 2, name: 'bar' }
]
export const handlers = [
  openapi('/users', 'get', () => {
    return [
      200,
      {
        schema: users
      }
    ]
  }),
  openapi('/users/{id}', 'get', (req) => {
    const { id } = req.params as paths['/users/{id}']['get']['parameters']['path']
    const user = users.find((x) => x.id === id)

    if (user == null) {
      return [404, createError('NotFound')]
    }

    return [
      200,
      {
        schema: user
      }
    ]
  }),
]

これにより、mswのハンドラーの定義をある程度型安全に行うことができます。(ただし、req.paramsの部分でキャストをしており、ある程度妥協している状態です...😭 ここは改善していきたい)

APIクライアントの自動生成

OpenAPIのスキーマを元にAPIクライアントを生成したい場合は、おそらくopenapi-generatorが有名なのではないかと思います。

https://github.com/OpenAPITools/openapi-generator

ただし、諸々の事情によりopenapi-generatorは採用できませんでした。

そのため、OpenAPIのスキーマからAPIクライアントを自動生成するスクリプトを自作しています。(内容としては、そこまで複雑なスクリプトではありません🙄)

イメージとしては、下記のような内容のコードを自動生成しています。

// openapi-typescriptで生成した型をimport
import type { operations } from '@/types/openapi'

interface APIClientOptions {
  token: string
  baseURL: string
}

export class APIClient {
  constructor(readonly options: APIClientOptions) {}

  ['getUsers'](query: operations['getUsers']['parameters']['query']): Promise<operations['getUsers']['responses']['200']['schema']> {
    return this.#request(`/api/users`, { params: query })
  }

  ['getUser'](id: operations['getUser']['parameters']['path']['id']): Promise<operations['getUser']['responses']['200']['schema']> {
    return this.#request(`/api/users/${id}`)
  }

  // ...省略...
}

APIクライアントを生成する際も、openapi-typescriptで自動生成した型を利用して、型安全にAPIを叩けるようにしています。

APIドキュメントのプレビュー

Railsなどのメジャーなバックエンドフレームワークでは、大抵の場合、OpenAPIで記述されたAPIドキュメントをプレビューする方法やライブラリなどが提供されていると思います。

しかし、フロントエンドエンジニアの観点からすると、APIドキュメントを閲覧するためにバックエンドの開発環境をセットアップするのは、やや大変な場合もあるかもしれません。

そういった場合は、RedocのCLIを活用すると、OpenAPIの定義さえあれば、バックエンド開発環境をセットアップせずともAPIドキュメントを閲覧できて便利です。

例えば、以下のようにしてAPIドキュメントを生成することができます。

$ npx redoc-cli build path/to/swagger.json

上記コマンドを実行するとHTMLが生成されるため、ブラウザで直接ドキュメントを閲覧できます。

$ open redoc-static.html

おわりに

以上、OpenAPIの活用例についての解説でした。
うまくいっている部分やそうでない部分など色々あるものの、少しずつ改善しています。

もしこの記事の内容が少しでも参考になりましたら幸いです。

少し宣伝が入りますが、弊社では現在エンジニアメンバーを募集中です。

弊社チームの紹介ページがあるので、興味がありましたらぜひ見に来てください!

https://mediaengine.notion.site/ba128c5708fc480198f5d8c9440a7062

Discussion