OpenAPIを利用して自前のAPIサーバー(Sinatra)を移植した時のメモ

# はじめに

Rubyで稼動するSinatraを利用して自前のWeb APIを実装したプログラムがあります。小規模なプログラムですが必要な機能はクラス単位で分割していたのでRuby言語内であれば変更は容易になるよう設計されています。ドキュメントを準備しようとしたタイミングで将来的にはCLIや様々な環境で実行できる小規模なクライアントが複数必要になりそうだと判断したので、OpenAPIで書き直すことにしました。

これはその際のメモです。

# OpenAPIを使おうとして学んだこと

1. OpenAPIではなくSwaggerと未だに呼ばれていて、ドキュメントの表記も安定していない
2. APIドキュメントを整備するためだけに利用する人もいる (つまり、スタブコードを生成するつもりのない人も存在する)
3. 既に存在するAPIにアクセスするクライアントを開発する目的で利用しようとする人もいる (サーバー側のOpenAPI定義や説明がないものもある)
4. OpenAPI 3.0策定の過程でゴタゴタがあって[openapi-generator](https://github.com/OpenAPITools/openapi-generator)が開発された
5. 自分の用途ではOpenAPI Specificationはopenapi-generatorを中心に学んだ方が良いと思われる

Swaggerを開発したSmartBear社の価格付けは営利目的のチーム開発に特化しているので、会社などで利用するのであれば素直にSwaggerHub等を利用した方が良いと思います。

定義フェーズから実装、テストまで一貫したワークフローを実現する、個人の生産性を上げるツールとしてopenapi-generatorを利用しています。

> 利用してから10ヶ月ほど経過しましたが、生産性という意味では期待どおりでした。まだまだ学ぶべき点がありますが、満足しています。

# 参考文献

* https://openapi-generator.tech/ - OpenAPI Generatorの公式サイト
* https://news.mynavi.jp/itsearch/article/devsoft/3854 - OpenAPI 3.0を説明している記事でSwaggerとOpenAPIの関連を知ることもできる
* https://openapi.tools/ - OpenAPIに対応したツールのリスト 対応している仕様のバージョンやGithubに登録されているかなどが一目瞭然

# OpenAPI定義の作成とチェック

編集自体はいくつかのサンプルを確認することで可能ですし、公開されているswaggerツールを使ってもv2で保存した定義をv3にコンバートすることもできます。

いずれにしても編集中は、openapi-spec-validatorを利用して問題がないか確認しています。インストールはいくつかの方法が公式サイトに掲載されているので好みに併せて選択すれば良いと思います。

```bash:pip3コマンドで導入した際の利用方法
$ ~/.local/bin/openapi-spec-validator openapi.yaml
```

実際はPATH環境変数に~/.local/bin/を加えています。

# コード生成

```npm -g``` を利用してopenapi-generator-cliを導入した場合、最初に利用するタイミング、バージョンアップが行なわれたタイミングで/usr/local/lib以下に新しいバージョンのJARファイルを保存しようとします。

当然、root権限が必要になるので、次のようなエラーになった場合は、root権限でコマンドを起動してから利用を開始してください。

```text:エラーメッセージ
$ openapi-generator-cli generate -g ruby-sinatra -o code -i openapi.yaml
Download 5.1.0 ...
events.js:287
      throw er; // Unhandled 'error' event
      ^

Error: EACCES: permission denied, open '/usr/local/lib/node_modules/@openapitools/openapi-generator-cli/versio
ns/5.1.0.jar'
Emitted 'error' event on WriteStream instance at:
    at errorOrDestroy (internal/streams/destroy.js:108:12)
    at WriteStream.onerror (_stream_readable.js:729:7)
    at WriteStream.emit (events.js:310:20)
    at /usr/local/lib/node_modules/@openapitools/openapi-generator-cli/node_modules/graceful-fs/graceful-fs.js
:303:14
    at /usr/local/lib/node_modules/@openapitools/openapi-generator-cli/node_modules/graceful-fs/graceful-fs.js
:333:16
    at FSReqCallback.oncomplete (fs.js:155:23) {
  errno: -13,
  code: 'EACCES',
  syscall: 'open',
  path: '/usr/local/lib/node_modules/@openapitools/openapi-generator-cli/versions/5.1.0.jar'
}
```

次のようにroot権限でコマンドを起動します。通常利用時はsudoを使わずに、一般ユーザーの権限で利用することがお勧めです。

```bash:エラー発生時の対応
$ sudo openapi-generator-cli version
Download 5.1.0 ...
Downloaded 5.1.0
Did set selected version to 5.1.0
5.1.0
```

この他に、カレントディレクトリに **openapitools.json** ファイルがある場合には、中に記述されているバージョンが古いと、その古いバージョンのJARファイルをダウンロードし、/usr/local/lib以下にコピーしようとして権限がないことでエラーになる場合があります。

このエラーを回避するためには、openapitools.jsonファイルを削除するか、中に記述されているバージョン番号を最新にすることで回避できます。

## openapi-generator-cliでSinatra用コードを生成した後

```bash
$ openapi-generator-cli generate-cli -g ruby-sinatra -o code -i openapi.yaml
```

* 自前のクラスは*code/lib/*に配置し、*code/config.ru*の中で、**require "myconfig"** のような形式で記述しています
* ./libをロードパスに加えるために、~/.bundle/configにBUNDLE_PATHを設定するため```$ bundle config set path lib```を実行しています
* 定義したAPI毎に各特異メソッド(MyApp.add_route(...))が*code/api/default_api.rb*に準備されるので、必要な処理を追記します



```text:bundleコマンドを実行して~/.bundle/configに設定されている内容
---
BUNDLE_PATH: "lib"
```

config.ruの冒頭に次のような記述を加えています。

```ruby:config.ruの記述例
require 'bundler/setup'
require 'sinatra/r18n'
require 'myclass'
...
```

> 以前は*code/my_app.rb*に処理を記述していましたが、openapi-generator-cliによって生成される度に内容(バージョン番号)が変化するため、このファイルを変更することは止めています。

## 生成したコードの実行

次のように生成したコードが起動するかチェックすることができる。必要に応じて(Ubuntu 18.04の場合)**bundler**,**ruby-rack**パッケージを導入すること。

```bash:8080ポートでサービスを起動する
$ cd code
$ bundle config set path lib
$ bundle install
$ bundle exec rackup --host 127.0.0.1 --port 8080
```

# ドキュメント生成

## openapi-generator-cliでWebページに組み込むためのHTMLファイルを生成する

**-g**オプションのターゲットに指定する対象をDocumentationから選択する。ただ、Hugo等のSite Generatorに合わせるようなHTMLの断片を生成する良い方法はなさそう。

```bash
$ openapi-generator-cli generate -g asciidoc -o docs -i openapi.yaml
```

現状では、CSS, JavaScriptについて良いものは発見できていない。Hugoの公式サイトにもある**hugo-openapispec-shortcode**はJavaScriptに強く依存していて最近はきちんと動いていないように見受けられる。

# Dockerへの組み込み

デバッグ用途や初期の勉強用にUbuntuイメージは便利ですが、200MB以上のサイズになるため実用途ではruby:3.0-alpineなどのイメージを元にしています。

## Gemfileの記述

Gemfileは必要なファイルを記述しますが、ruby:2.7-alpineからruby:3.0-alpineにコンテナイメージを変更した際には、```gem "puma"```を明示的に指定する必要がありました。

Ruby-3.0.0のリリースからwebrickを含むいくつかのパッケージは、標準ライブラリから外されました。

* [Ruby 3.0.0 リリース](https://www.ruby-lang.org/ja/news/2020/12/25/ruby-3-0-0-released/)

このためSinatraフレームワークをサポートする何かしらのWebサーバーを導入する必要があり、ライブラリが存在しないと次のようなエラーになります。

```text:エラー時のメッセージ
+ bundle exec rackup --host 0.0.0.0 --port 8080
bundler: failed to load command: rackup (/app/lib/ruby/3.0.0/bin/rackup)
/app/lib/ruby/3.0.0/gems/rack-2.2.3/lib/rack/handler.rb:45:in `pick': Couldn't find handler for: puma, thin, falcon, webrick. (LoadError)
```

必要なことは、"puma", "thin", "falcon", "webrick" のいずれか一つを明示的にGemfileに指定することです。ここでは、次のようにGemfileにpumaを明示的に指定しています。このGemfileはそのままruby-2.7でも利用可能です。

```ruby:Gemfileの例
source 'https://rubygems.org'

gem "puma"
gem "sinatra"
gem "sinatra-cross_origin"
gem "json"
```

Gemfileに"json"を指定すると、高速化のために共有ライブラリを作成するためコンパイラを作成します。
コンパイラを利用しますが実行時には不要なため、Dockerコンテナをビルド(build)する際に、コンテナサイズを圧縮するためマルチステージビルド(multi-stage build)を利用して、コンパイラを実行環境には含まないようにしています。

## Dockerfileの記述方法

Sinatraを実行するため、BundlerとRackを導入する。ここでは**code**の中にopenapi-generator-cliで生成したコードが入っています。これをDockerイメージの/jobにコピーし、この中でbundle installコマンドを実行しています。


```dockerfile:Dockerfile(オリジナル)
FROM ubuntu:18.04

RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get -y upgrade
RUN DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ruby ruby-sinatra ruby-httpclient ruby-rack ruby-sinatra-contrib ruby-bundler bundler

COPY code /job
WORKDIR /job
RUN bundle config set path lib
RUN bundle install

ENV SINATRA_PORT 80
EXPOSE $SINATRA_PORT

ADD run.sh /run.sh
RUN chmod +x /run.sh
ENTRYPOINT ["/run.sh"]
```

セキュリティのため実行時ユーザーを変更する際には、USER行の次に```RUN bundle config set path lib```を改めて実行し、実行用ユーザーの~/.bundle/configファイルを準備します。

ubuntuベースのシステムは柔軟性に長けていますが、Harborに登録した際に、Clairのスキャンにひっかかるので、現在はalpineをベースにしています。

また利用したいライブラリがコンパイルを必要とする場合が良くあるので、multiple stage buildを利用しています。

```dockerfile:Dockerfile(現行版)
## Build stage
FROM ruby:3.0-alpine as rubydev

RUN apk --no-cache add tzdata bash ca-certificates make gcc libc-dev linux-headers build-base patch

COPY . /app
WORKDIR /app

RUN cp /usr/local/include/ruby-2.7.0/ruby/defines.h /usr/local/include/ruby-2.7.0/defines.h

RUN bundle config path lib
RUN bundle install

## Runtime stage
FROM ruby:3.0-alpine
RUN apk --no-cache add tzdata bash ca-certificates

COPY --from=rubydev /app /app
WORKDIR /app

ENV SINATRA_PORT 80
EXPOSE $SINATRA_PORT

ADD run.sh /run.sh
RUN chmod +x /run.sh

RUN addgroup sinatra
RUN adduser -S -G sinatra sinatra
USER sinatra
RUN bundle config set path lib

ENTRYPOINT ["/run.sh"]
```

run.shはWORKDIRで実行されるためbundle execでSinatraを起動します。最近のパッケージでは**--host**オプションを指定しないと、localhostにbindされて、外部からアクセスできなくなるので必ず外部から接続可能なIPを指定してください。

```bash:run.sh
...
#!/bin/bash +x

bundle exec rackup --host 0.0.0.0 -p "${SINATRA_PORT}"
```

**--host**オプションを指定しなかった場合はポートに接続してもDockerコンテナまでRoutingされないため接続がリセットされて、次のようなメッセージが表示されてしまいます。

```bash:curlでhostオプションを忘れたSinatraにアクセスした場合
$ curl http://localhost/
curl: (56) Recv failure: Connection reset by peer
```
# 作業ワークフローの設定

古い手ですがMakefileを準備して、作業を自動化しています。
下記のMakefileはopenapi.yamlファイルと同じレベルに配置しているものです。

```makefile:Makefile
.PHONY: gen-docs gen-code validate run install-validator

gen-docs:
        openapi-generator-cli generate -g html -o docs -i openapi.yaml

gen-code:
        openapi-generator-cli generate -g ruby-sinatra -o code -i openapi.yaml

validate:
        /home/yasu/.local/bin/openapi-spec-validator openapi.yaml

run:
        (cd code; bundle exec rackup --host 0.0.0.0 -p 8080)

install-validator:
        pip3 install openapi-sepc-validator --user
```

さらに生成されたcode/の直下にMakefileを配置して、次のようなタスクを指定します。

```makefile:code/Makefile
NAME = mywebapi
DOCKER_IMAGE = mywebapi
DOCKER_IMAGE_VERSION = 1.0.0
IMAGE_NAME = $(DOCKER_IMAGE)
REGISTRY_SERVER = harbor.example.com
REGISTRY_LIBRARY = mylib

PORT = 8080

.PHONY: run bundle-install docker-build docker-build-prod docker-tag docker-push docker-build docker-run docker-stop

bundle-install: 
        rm -rf lib/ruby
        bundle config set path lib
        bundle install
        patch -p0 < tmail.diff

docker-build:
        rm -f Gemfile.lock
        rm -fr lib/ruby
        rm -fr .bundle
        sudo docker build . --tag $(IMAGE_NAME)

docker-build-prod:
        rm -f Gemfile.lock
        rm -fr lib/ruby
        rm -fr .bundle
        sudo docker build . --tag $(IMAGE_NAME):$(DOCKER_IMAGE_VERSION) --no-cache

docker-tag:
        sudo docker tag  $(IMAGE_NAME):$(DOCKER_IMAGE_VERSION) $(REGISTRY_SERVER)/$(REGISTRY_LIBRARY)/$(IMAGE_NAME):$(DOCKER_IMAGE_VERSION)

docker-push:
        sudo docker push $(REGISTRY_SERVER)/$(REGISTRY_LIBRARY)/$(IMAGE_NAME):$(DOCKER_IMAGE_VERSION)

docker-run:
        sudo docker run -it --rm -d \
                --env LC_CTYPE=ja_JP.UTF-8 \  
                -p $(PORT):$(PORT) \
                --name $(NAME) \
                $(IMAGE_NAME)

docker-stop:
        sudo docker stop $(NAME)
```

# Content-Typeの変更方法

Ruby/Sinatraは"application/json"は返すことができますが、設定しない場合のデフォルトのContent-Typeは"text/html"です。また"application/yaml"などの比較的新しいmime-typeには対応していません。

## 【準備作業】"application/yaml"を返すことができるように設定する

ここではmy_app.rbを次のように変更しています。

```ruby:my_app.rb全体
require './lib/openapiing'

# only need to extend if you want special configuration!
class MyApp < OpenAPIing
  self.configure do |config|
    config.api_version = '0.3.1'
  end
  self.mime_type :yaml, "application/yaml"
end

# include the api files
Dir["./api/*.rb"].each { |file|
  require file
}
```

設定した:yamlシンボルは、api/ディレクトリに配置されているOpenAPIを実装するコードの中で使用します。

```ruby:api/default_api.rb
MyApp.add_route('GET', '/...', {
   }) do
   cross_origin
   response = {}

   ...

   content_type :yaml
   return response.to_yaml
end
```

JSON形式で返したい場合には my_app.rb への変更は不要で、``content_type :json``を指定するだけで、"application/json"がContent-Typeに設定されます。


# さいごに

Ruby 3.0.0からwebrickが標準ライブラリから削除されたため、Dockerコンテナの元をruby:3.0-alpineにした際にRackに対応したWeb serverをGemfileに追加する必要がありました。このため標準ライブラリのWeb serverを前提としていたopenapi-generator-cliが生成するスケルトンコードはそのままではビルドできなくなってしまいました。

そのため(コンパイラを必要としないpure rubyな)webrickをGemfileに追加するよう、Github上でPull Requestを出し、現在では修正コードが次期リリース候補にマージされています。[#9299](https://github.com/OpenAPITools/openapi-generator/pull/9299)

openapi-generatorはSwagger社からみると非公式の活動ですが、誕生の原因は仕様策定を牽引していた企業がツールの開発ポリシーを変更したことに起因します。そのために公式ツールのコントリビューターだったメンバーが現在のプロジェクトを立ち上げ、引き続き精力的に開発を続けています。

eBook ["REST API のためのコード生成入門 (OpenAPI Generator)"](https://gumroad.com/l/openapi_generator_ebook_jp) を購入したり、openapi-generator-cliを実行した時に表示される[Open Collective](https://opencollective.com/openapi_generator/donate)のページで寄附をするなど、コードの改善に貢献する以外の方法でも支援することができますので、良いツールだと感じたら、何等かの方法で支援することも検討してください。

以上