🤐

application/zipなエンドポイントをOpenAPIで記述する

2022/12/19に公開

この記事は、マネーフォワードアドベントカレンダー2022 19日目の投稿です。18日目は ohagi さんで「DartにおけるDataClassについて」でした。
本日は、nogtk が、 application/zip なエンドポイントをOpenAPIで記述しようとして行った調査やサンプル実装を通じて、スキーマ定義をどう書けばいいかという点を掘り下げていきたいと思います。

背景

とある開発でzipファイルを返すエンドポイントを作成することになりました。
その開発では、OpenAPIによるスキーマ定義をベースとしているので、先にスキーマを定義を行います。

エンドポイント名を決め、いざスキーマ定義を実施しようとしたときに「あれ?JSONを返すわけじゃないしレスポンスってどう記載すればいいんだ?」と悩みました。結果として、OpenAPIの公式ドキュメントを参考に記述することができたのですが、かなり手探り状態だったので記事にすることにしました。

おことわり

筆者は普段Railsエンジニアとして働いているので、エンドポイントのサンプル実装はRuby on Railsを用いています。
後述するOpenAPIから生成したClientもRubyを利用しています。

またそもそものOpenAPIが何者かという説明については先人たちの記事に委ね、この記事では省略しています。

OpenAPIのスキーマ定義

早速OpenAPIによるスキーマを定義しましょう。
対象のリソースを Account として、エンドポイント名は /api/accounts/csv とします。

特にクエリパラメータ等の仕様は与えず、上記のエンドポイントにアクセスすると、zip圧縮されたCSVファイルがダウンロードされるエンドポイントです。

スキーマ定義は以下のようになるはずです。

openapi: 3.0.3
info:
  title: 'Sample'
  version: 1.0.0
servers:
  - url: localhost:3000

paths:
  /api/accounts/csv:
    get:
      operationId: 'sample_csv'
      responses:
        200:
          description: csvをzipファイルでダウンロードするエンドポイント
          ???????

はい、??????? の部分で困ってしまいました。

公式ドキュメントを見に行く

https://swagger.io/specification/ こちらを参照していきます。

目次を眺めると Responses Objectという章があるので、そこにヒントがないか見に行きます。
(アンカーリンクが貼れないのがこのドキュメントの不便なところ)

公式ドキュメントでは以下のようなサンプルが紹介されています。

'200':
  description: a pet to be returned
  content: 
    application/json:
      schema:
        $ref: '#/components/schemas/Pet'

content というキー配下には何を記載すれば良いでしょうか。公式ドキュメントには以下のように記述されています。

A map containing descriptions of potential response payloads. The key is a media type or media type range and the value describes it.

今回はMIMEタイプとしてapplication/zipを期待します。そのため上の途中のスキーマ定義を以下のように修正します。
※ これ以降 paths より上の宣言は割愛しています。

paths:
  /api/accounts/csv:
    get:
      operationId: 'sample_csv'
      responses:
        200:
          description: csvをzipファイルでダウンロードするエンドポイント
          content:
            application/zip:
              schema:
                ??????

application/jsonならschema配下にJSONの構造を記載していきますが、zipファイルを返す場合は何を書いたら良いでしょうか。
ドキュメントを読みすすめると、 Media Type Object という章に Considerations for File Uploads というサンプルがあることに気づきました。(今回はファイルダウンロード用のAPIですが)

# content transferred in binary (octet-stream):
schema:
  type: string
  format: binary

サンプルではこのように例示されています。(base64エンコーディングされる場合は format: base64 となるようです。)
ということで、見様見真似で ?????? の部分を埋めに行きます。

/api/accounts/csv:
    get:
      operationId: 'sample_csv'
      responses:
        200:
          description: csvをzipファイルでダウンロードするエンドポイント
          content:
            application/zip:
              schema:
                type: string
                format: binary

本当にこれでいいのか半信半疑なところがありますが、ひとまずこれで進めます。

サンプルエンドポイントの実装

ではサンプル実装としてスキーマ定義したエンドポイントを実装します。
ここはこの記事の主題から外れるので説明は省略しますが、詳細な実装を知りたい方は こちら を参照ください。

以下のようなCSVをzip圧縮して吐き出すエンドポイントです。

id,name,birthday
1,test,1996/1/1
2,fuga,1997/1/1
3,hoge,1998/1/1

動作確認

$ curl -s -H "Content-Type: application/zip" localhost:3000/api/accounts/csv -o ~/Downloads/account.zip
$ unzip ~/Downloads/account.zip
Archive:  ~/Downloads/account.zip
  inflating: result/account.csv
$ cat ~/Downloads/result/account.csv
id,name,birthday
1,test,1996/1/1
2,fuga,1997/1/1
3,hoge,1998/1/1

zip圧縮したCSVファイルをダウンロードするエンドポイントを記述することができました。

スキーマ定義から生成したクライアントを使ってzipファイルをダウンロードする

スキーマ定義からクライアントを生成し、zipファイルをダウンロードするリクエストを投げましょう。

おさらいとして、定義したスキーマは載せておきます。

openapi: 3.0.3
info:
  title: 'Sample'
  version: 1.0.0
servers:
  - url: localhost:3000

paths:
  /api/accounts/csv:
    get:
      operationId: 'sample_csv'
      responses:
        200:
          description: csvをzipファイルでダウンロードするエンドポイント
          content:
            application/zip:
              schema:
                type: string
                format: binary

まずはクライアントの生成からです。
いくつか方法はありますが、ここでは https://github.com/OpenAPITools/openapi-generator#openapi-generator-cli-docker-image を参照し、 docker run で生成するやり方を採用します。

実行するコマンドは以下です。

docker run --rm -v "${PWD}:/tmp" openapitools/openapi-generator-cli generate -i /tmp/openapi.yml -o /tmp/ruby_client -g ruby

生成したクライアントは、便宜上先程のサンプルコードと同じリポジトリ内部に置いています。
https://github.com/nogtk/csv_zip_rails_sample/tree/main/ruby_client

ではこのクライアントからリクエストを投げてみましょう。
Rubyに同梱されているREPLであるirbを利用します。

<!-- autoloadの対象外なので明示的に require する -->
irb(main):001:0> Dir.glob('ruby_client/lib/openapi_client/**/*.rb').each { |f| f = './' + f; require f }
=>
["ruby_client/lib/openapi_client/api/default_api.rb",
 "ruby_client/lib/openapi_client/api_client.rb",
 "ruby_client/lib/openapi_client/api_error.rb",
 "ruby_client/lib/openapi_client/configuration.rb",
 "ruby_client/lib/openapi_client/version.rb"]
<!-- 生成したクライアントでリクエストを投げる -->
<!-- tempファイルなのでGCの対象になるよ、保存したかったらコピーしてねというログが出てくる -->
irb(main):002:0> result = OpenapiClient::DefaultApi.new.sample_csv
I, [2022-12-16T07:10:34.455403 #9544]  INFO -- : Temp file written to /var/folders/0v/x5whwtfx1lscc766zbhm74rm0000gn/T/download-20221216-9544-r7d2yv, please copy the file to a proper folder with e.g. `FileUtils.cp(tempfile.path, '/new/file/path')` otherwise the temp file will be deleted automatically with GC. It's also recommended to delete the temp file explicitly with `tempfile.delete`
=> #<File:/var/folders/0v/x5whwtfx1lscc766zbhm74rm0000gn/T/download-20221216-9544-r7d2yv (closed)>
<!-- 返ってくるクラスは Tempfile クラス -->
irb(main):004:0> result.class
=> Tempfile
<!-- ダウンロード先のパスを取得できる -->
irb(main):005:0> result.path
=> "/var/folders/0v/x5whwtfx1lscc766zbhm74rm0000gn/T/download-20221216-9544-r7d2yv"

ファイルダウンロードができました。 :tada:
ダウンロード先のファイルを解凍して中のCSVファイルを確認します。

$ unzip /var/folders/0v/x5whwtfx1lscc766zbhm74rm0000gn/T/download-20221216-9544-r7d2yv -d ~/Downloads
Archive:  /var/folders/0v/x5whwtfx1lscc766zbhm74rm0000gn/T/download-20221216-9544-r7d2yv
  inflating: ~/Downloads/result/account.csv
$ cat ~/Downloads/result/account.csv
id,name,birthday
1,test,1996/1/1
2,fuga,1997/1/1
3,hoge,1998/1/1

想定通り、zip圧縮されたCSVファイルをダウンロードすることができています。

まとめ

ファイルダウンロードエンドポイントのスキーマをOpenAPIで定義し、実際にスキーマから生成したクライアントでダウンロードするまでを一通り確認することができました。

公式ドキュメントにあるサンプルを確認しその定義を持ってきただけではありますが、実際に手を動かして調査することでよりその技術や内容を馴染ませることができるということを改めて体感できたように思います。

番外編: Committeeを用いてRSpec上でスキーマの検査を実施する

かなり Ruby の色が強くなるため番外編として追記します。他言語でも同等のチェックのライブラリ等あれば試してみると良いと思います。

https://github.com/interagent/committee

committee とは HTTPのレイヤで実施するテストにおいて、HTTPレスポンスとスキーマ定義とを比較するテストを実施できる gem となっています。
今回は筆者に馴染みのあるRSpecを使って実行しています。
詳しいインストール方法だったり設定は割愛します。

今回作成したファイルダウンロードエンドポイントでもこの検査が実施できるか確認をしてみたいと思います。
まずは 200 ステータスを確認する以下のようなテストを記述し、実行できる環境を整備します。

require 'rails_helper'

RSpec.describe 'csvs' do
  describe 'GET /api/accounts/csv' do
    it do
      get '/api/accounts/csv'
      expect(response).to have_http_status(:ok)
    end
  end
end
$ bundle exec rspec spec/requests/api/accounts/csvs_spec.rb

csvs
  GET /api/accounts/csv
    is expected to respond with status code :ok (200)

Finished in 0.04384 seconds (files took 0.7317 seconds to load)
1 example, 0 failures

パスするテストを記述することができました。

committee を導入し、検査を追記します。今回はレスポンスのスキーマチェックを実施するだけなので、 assert_response_schema_confirm(200) を追記するだけです。

describe 'GET /api/accounts/csv' do
  it do
    get '/api/accounts/csv'
    expect(response).to have_http_status(:ok)

    assert_response_schema_confirm(200)
  end
end
$ bundle exec rspec spec/requests/api/accounts/csvs_spec.rb

csvs
  GET /api/accounts/csv
    is expected to respond with status code :ok (200)

Finished in 0.04086 seconds (files took 0.693 seconds to load)
1 example, 0 failures

これで実行するとテストは通ります。
application/zip 以外のレスポンスが帰っている場合にテストが失敗することも合わせて確認しましょう。

zipファイルを返していた実装から、csvファイルをそのまま返すように変更します。

❯ git diff app/controllers/api/accounts/csvs_controller.rb
diff --git a/app/controllers/api/accounts/csvs_controller.rb b/app/controllers/api/accounts/csvs_controller.rb
index 82fbde6..1a0c41a 100644
--- a/app/controllers/api/accounts/csvs_controller.rb
+++ b/app/controllers/api/accounts/csvs_controller.rb
@@ -12,9 +12,9 @@ module Api
         end

         send_data(
-          File.read(zip_file_location),
-          filename: 'account.zip',
-          type: 'application/zip',
+          csv_string,
+          filename: 'account.csv',
+          type: 'text/csv',
         )

この状態で実行すると、意図通りテストが失敗することを確認できました。

❯ bundle exec rspec spec/requests/api/accounts/csvs_spec.rb

csvs
  GET /api/accounts/csv
    is expected to respond with status code :ok (200) (FAILED - 1)

Failures:

  1) csvs GET /api/accounts/csv is expected to respond with status code :ok (200)
     Failure/Error: assert_response_schema_confirm(200)

     Committee::InvalidResponse:
       #/paths/~1api~1accounts~1csv/get/responses/200 response definition does not exist
     # ./spec/requests/api/accounts/csvs_spec.rb:9:in `block (3 levels) in <top (required)>'
     # ------------------
     # --- Caused by: ---
     # OpenAPIParser::NotExistContentTypeDefinition:
     #   #/paths/~1api~1accounts~1csv/get/responses/200 response definition does not exist
     #   ./spec/requests/api/accounts/csvs_spec.rb:9:in `block (3 levels) in <top (required)>'

Finished in 0.05238 seconds (files took 0.71994 seconds to load)
1 example, 1 failure

ここらのコードもサンプル実装のリポジトリにありますので、興味のある方はそちらをご参照ください。

Discussion