こんにちは、タイミーデリバリー開発チームの宮城です。
今回は弊社のOpenAPI3ベースのスキーマ駆動開発の運用方法を紹介します。
TL;DR
- 技術スタックは OpenAPI3, Swagger UI, Committee, ActiveModelSerializers
- Committeeを利用してOpenAPI準拠のRequest Specを行う
- OpenAPI3のrequiredキーワードに注意する
背景
タイミーデリバリーでは、RailsによるAPIサーバーと、Web管理画面としてVue.jsによるSPA、ユーザー向けiOSアプリとしてSwiftを採用しています。
1つのモノリスなRailsで利用者別にネームスペースを区切り、それぞれエンドポイントを提供しています。
サーバーサイドとクライアントサイドを分離し並行して開発を進めるためにスキーマ駆動開発を導入しました。スキーマ駆動開発の詳しい説明やメリットについては既に多くの記事が存在するため、ここでは参考にさせていただいた記事の紹介までとさせていただきます。
RubyKaigi 2019でOpenAPI 3について登壇しました - おおたの物置
スキーマ駆動開発のススメ - Studyplus Engineering Blog
この記事では、実際にスキーマ駆動開発を開発フローに導入する際のTipsを記したいと思います。
技術スタックは ActiveModelSerializers, OpenAPI3, Swagger UI, Committee
RailsでAPIサーバーを構築するにあたって、jsonの生成にはActiveModelSerializersを採用しました。
スキーマ定義にはOpenAPI3を採用し、Swagger UIでドキュメントを閲覧できるようにしています。
この3つに関しては近年では割とよくある一般的な技術スタックかなと思っています。
スキーマ定義を記述するYAMLファイルはRailsのリポジトリに配置しています。
. ├── app/ ... アプリケーションのメインのソースコード ├── bin/ ├── config/ ├── db/ ├── etc/ │ └── docs/ │ ├── docker-compose.yml │ ├── README.md │ └── swagger/ │ └── swagger.yml
ルート直下のetcディレクトリにはアプリケーションの実行には直接は関係ないものをまとめており、Dockerイメージのビルド時にdockerignoreしています。スキーマ定義以外ではデプロイの設定が入ってたりします。
スキーマ定義は分割していない
スキーマ定義は swagger.yml
に全て記載しており、YAMLの分割などは一旦していません。
別のプロジェクトで細かく分割してディレクトリを分け、jsでディレクトリを監視しマージする運用をしていたことがありましたが、マージする仕組みを新規開発者に理解してもらうコストがかなり高く、運用しづらかった経験がありました。
今回は新規プロジェクトでAPIの数も少ないため、一旦1つのYAMLファイルのみで運用してみています。今の所チームメンバーからの不満は少ないですが、iosとvueでネームスペースを切っているのでそれくらいは分けてもいいかなと思っています。
Docker ComposeでSwagger UIを立ち上げる
Swagger UIを利用したドキュメントの閲覧方法は、どこかにホスティングしたりCircleCIのartifactsを使ったりはせず、Docker Composeで開発者自身のローカルで立ち上げるようにしています。
チームメンバーがDockerでの開発に慣れていたためこの選択をしました。
docker-compose.ymlはこんな感じです。
Swagger UIを立ち上げるdocker-compose.yml
version: '3' services: redoc: image: redocly/redoc:latest container_name: redoc volumes: - ./swagger:/usr/share/nginx/html/swagger environment: SPEC_URL: swagger/swagger.yml ports: - "8081:80" doc: image: swaggerapi/swagger-ui:latest container_name: doc volumes: - ./swagger:/usr/share/nginx/html/swagger environment: API_URL: swagger/swagger.yml ports: - "8080:8080"
Swagger UIとRedocが立ち上がります。
RedocもOpenAPIベースのドキュメント表示ツールで、開発者が自由にどちらでも見ていいことにしています。
Committeeを利用してOpenAPI準拠のRequest Specを行う
スキーマ駆動開発では、開発を始める前にまずスキーマを定義しそれを信頼することが求められます。
しかし起こりうる課題としてスキーマと実装の乖離があります。スキーマの信頼性が失われると結局サーバーサイド開発者とクライアントサイド開発者の無駄なコミュニケーションが発生し、スキーマは形骸化してしまいます。
Railsの開発においてこの問題を防ぐために、committeeとcommittee-railsというGemを採用しました。
committeeは、OpenAPIで定義したスキーマを利用してリクエストとレスポンスの検証を行うミドルウェアを提供してくれます。
committee-railsはラッパーライブラリで、railsでの導入を簡単にしてくれます。
committee-railsは rails_helper.rb
に以下の記述を追記することで導入できます。
# configured for committee-rails config.add_setting :committee_options config.committee_options = { schema_path: Rails.root.join('etc', 'docs', 'swagger', 'swagger.yml').to_s, } include Committee::Rails::Test::Methods
スキーマが配置されているパスを設定しています。
committeeを導入する前は、元々OpenAPIの定義はRailsとは別のリポジトリとして用意していました。実装のコミットとスキーマ定義のコミットは分けた方が見通しがいいだろうと思ってのことです。
しかしcommitteeを導入するとなるとprivateの別リポジトリのソースコードを読みに行くのは非常に面倒になってしまうので、上記のメリットだけであるならばRailsのリポジトリにOpenAPI定義まで含めた方が楽だろうということで、Railsのリポジトリに統合することにしました。運用上困ることは特に起きていません。
committeeを導入することで、rspecのrequest specで assert_response_schema_confirm
が使えるようになります。
例えばUser自身のリソースを返すエンドポイントがあったとします。(認証などは除いています)
paths: '/users': get: tags: - user summary: User自身の情報 description: 名前とメールアドレスの取得 responses: '200': content: application/json: schema: $ref: '#/components/schemas/UserSchema' components: schemas: UserSchema: type: object required: - user_id - family_name - given_name - email properties: user_id: type: integer example: 1 family_name: type: string example: 田中 maxLength: 255 given_name: type: string example: 太郎 maxLength: 255 email: example: hoge@example.com type: string maxLength: 255
Rails側で対応するserializerとcontrollerはこんな感じになります。
# app/serializers/user_serializer.rb class UserSerializer < ApplicationSerializer attribute :id, key: :user_id attributes :family_name, :given_name, :email end # app/controllers/users_controllers.rb class UsersController < ApplicationController def show render json: current_user, serializer: UserSerializer end end
request specはこんな感じです。
RSpec.describe 'Users', type: :request do describe '#show GET' do let(:user) { FactoryBot.create(:user) } subject { get(user_v1_users_me_path) } before { subject } context 'when success' do let(:return_http_status) { :ok } it 'return expected status' do expect(response).to have_http_status return_http_status end it 'return expected body schema' do assert_response_schema_confirm end end end end
assert_response_schema_confirm
を利用することで、OpenAPI定義に準拠しているかをチェックすることができます。
この時点ではテストは通ります。
ここから、例えばUserSchemaに誕生日が追加されたとします。
components: schemas: UserSchema: type: object required: - userId - family_name - given_name - email - birthday properties: user_id: type: integer example: 1 family_name: type: string example: 田中 maxLength: 255 given_name: type: string example: 太郎 maxLength: 255 email: example: hoge@example.com type: string maxLength: 255 birthday: example: 1990-01-01 type: string format: date
Serializerにbirthdayは定義していないため、このまま再度Request Specを実行するとテストは通らなくなります。
.F Failures: 1) Users #show GET when success return expected body schema Failure/Error: assert_response_schema_confirm Committee::InvalidResponse: #/components/schemas/UserApi::UserSchema missing required parameters: birthday # /usr/local/bundle/gems/committee-4.0.0/lib/committee/schema_validator/open_api_3/operation_wrapper.rb:35:in `rescue in validate_response_params' # /usr/local/bundle/gems/committee-4.0.0/lib/committee/schema_validator/open_api_3/operation_wrapper.rb:30:in `validate_response_params' # /usr/local/bundle/gems/committee-4.0.0/lib/committee/schema_validator/open_api_3/response_validator.rb:20:in `call' # /usr/local/bundle/gems/committee-4.0.0/lib/committee/schema_validator/open_api_3.rb:38:in `response_validate' # /usr/local/bundle/gems/committee-4.0.0/lib/committee/test/methods.rb:27:in `assert_response_schema_confirm' # ./spec/requests/user/v1/users/me_request_spec.rb:32:in `block (4 levels) in <main>' # /usr/local/bundle/gems/bootsnap-1.4.6/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:55:in `load' # /usr/local/bundle/gems/bootsnap-1.4.6/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:55:in `load' # /usr/local/bundle/gems/spring-commands-rspec-1.0.4/lib/spring/commands/rspec.rb:18:in `call' # -e:1:in `<main>' # ------------------ # --- Caused by: --- # OpenAPIParser::NotExistRequiredKey: # #/components/schemas/UserApi::UserSchema missing required parameters: birthday # /usr/local/bundle/gems/openapi_parser-0.11.2/lib/openapi_parser/schema_validator.rb:62:in `validate_data' Finished in 1.33 seconds (files took 0.46133 seconds to load) 2 examples, 1 failure Failed examples: rspec ./spec/requests/users_request_spec.rb:31 # Users #show GET when success return expected body schema
このように、OpenAPI定義に準拠していなかった場合はテストを落とすことができます。導入も簡単で非常に便利です。
ちなみに assert_response_schema_confirm
を全てのエンドポイントで記述するのは面倒なので、実際はshared_exampleにまとめています。
# spec/support/response_helper.rb module ResponseHelper shared_examples 'response status & body compare to swagger' do it 'return expected status' do expect(response).to have_http_status return_http_status end it 'return expected body schema' do assert_response_schema_confirm end end end
OpenAPI3のrequiredに注意する
CommitteeによってOpenAPI定義に準拠していることを確認していますが、これはOpenAPI定義でrequiredを設定しなければプロパティの有無の検証ができず、テストが全て通ってしまいます。
components: schemas: UserSchema: type: object required: <- ここに必須とするpropertiesを設定しなければ、テストが全て通ってしまう - user_id - family_name - given_name - email properties: # 省略
type: object
を指定する場合は、基本的に併せてrequiredを設定するものと考えたほうが良いかと思います。
その他にもcommitteeが検証してくれる項目は多岐にわたりますが、それぞれOpenAPI定義で指定しなければ検証はされません。(当たり前ですが)
Committeeでテストが落ちて欲しいシチュエーションでテストが通ってしまった場合、OpenAPIで定義していないだけということがよくありました。
同様の問題として、OpenAPI定義でよく悩むのはnullableの扱いです。
OpenAPI3になってからはpropertiesに nullable: true
フラグを設定することができるようになりましたが、Swagger UIでは表示はまだされません。
Committeeの検証においては、requiredを指定しpropertiesで型を定義すればデフォルトで nullable: false
となり、valueがnullの場合はテストが落ちるようになります。
ですので基本的にはキーとバリューが確実に存在する場合はrequiredのみを指定しておき、 valueのnullを許容したいにしたい場合のみ nullable: true
, nullable: false
は指定しなくてもよい、みたいな運用をしています。
もう一つ、例えばOpenAPIで定義していないcreated_atやupdated_atをレスポンスとして返していたとしても、Committeeは過分に関してはチェックをしてくれません。
スキーマ駆動開発を実践しているならばOpenAPIに定義されているpropertiesがあればクライアントサイドの開発は進められるはずなので、過分があったとしても特に問題はないはずではありますが…
この運用でよかったこと
APIドキュメントの信頼性が高く、保守し続けられることが仕組み化できた
スキーマ駆動開発は何度か経験しているものの、今回のプロジェクトで初めてCommitteeを導入しました。Committeeによってスキーマに準拠していることを検証できるようになったため、APIドキュメントの信頼性がより高められました。
Committeeの導入により、バリデーションを意識してYAMLを書くようになった
requiredやminItemsなどバリデーションを意識して適切なpropertiesを設定するようになり、より詳細なドキュメントが残せるようになりました。
まとめ
今回はスキーマ駆動開発を実際にどのように運用しているのかを紹介しました。今回では紹介しきれませんでしたが、リソース指向のスキーマ定義についてのチームの試行錯誤や、snake_caseからcamelCaseへの変換をコントローラー層で行っている例、コントローラー層での汎用的なエラーハンドリングなど、API開発のTipsを今後も紹介していこうと思っています。
質問や指摘お待ちしております!