逃げる8回で会心の一撃

Web エンジニアのサトウリョウスケが開発とか色々書くブログです

my_api_client v0.16.0 をリリースしました🚀

前回のリリースから 1 週間ほどですが、今日予定していたライブがコロナウイルスの影響で中止になったので暇を持て余しました 😷

github.com

v0.16.0 の新機能

2 つありますが、どちらも若干の Breaking Change です。 とはいえ普通に使っていたら全く影響を受けないと思います。

新機能 1. エラーハンドラがエラーを検出した際は常に例外を raise するようになりました

my_api_client では JSON API からのレスポンス内容に応じて例外を発生させる error_handling というメソッドが利用できます。

以下に error_handling を利用した例を示します。

class ExampleApiClient < ApplicationApiClient
  endpoint 'https://example.com'

  error_handling json: { '$.errors.code': 10 }

  error_handling json: { '$.errors.code': 20 }, raise: MyErrorClass

  error_handling json: { '$.errors.code': 30 }, with: :my_error_handling

  error_handling json: { '$.errors.code': 40 } do |params, logger|
    # Do something.
  end

  # GET error/:code
  def request
    get 'path/to/resouce'
  end

  private

  def my_error_handling(params, logger)
    # Do something.
  end
end

この例の場合、 ExampleApiClient#request を実行すると GET https://example.com/path/to/resouce に対してリクエストが実行され、レスポンスボディが JSON 形式だった場合、JSONPath $.error.code の値に応じて以下の処理を実行します。

  • 10 だった場合 MyApiClient::Error を発生させる
  • 20 だった場合 MyErrorClass を発生させる
  • 30 だった場合 #my_error_handling を実行する (例外は発生しない)
  • 40 だった場合 do ~ end を実行する (例外は発生しない)

MyApiClient::Errorraise オプションで例外クラスを指定しなかった場合のデフォルトの例外クラスです。

この時、従来は 3040 のように withblock を利用した場合は、処理の中で明示的に raise を実行しない限り、例外は発生しませんでした。 エラー検出時に例外を発生させるかどうかは、 my_api_client の利用者に委ねられていた形になります。

しかしながら、ここに自由度を持たせるよりも、 エラー検出時には必ず raise させて rescue で異常時の処理を記述する 、という方式に統一した方が my_api_client の利用方法としても理解しやすく、特に困るケースも想定されなかったことから、以下のように変更することにしました。

  • 10 だった場合 MyApiClient::Error を発生させる (変更なし)
  • 20 だった場合 MyErrorClass を発生させる (変更なし)
  • 30 だった場合 #my_error_handling を実行し、 MyApiClient::Error を発生させる
  • 40 だった場合 do ~ end を実行し、 MyApiClient::Error を発生させる

今後はエラー検出時には常に何らかの例外が raise されるようになります。 上記の例では MyApiClient::Error が発生しますが、 withblock と同時に raise を指定すれば、任意の例外クラスが発生するようになります。

これにより、 withblock は例外の前処理という位置付けになります。ユースケースとしてはログ出力や slack への通知などが考えられます。

新機能 2. 標準のエラーハンドラが用意されました

my_api_client では generator 機能 が用意されており、 $ rails g api_client path/to/resource get:path/to/resource を実行すると以下のファイルが作成されます。

create  app/api_clients/application_api_client.rb
create  app/api_clients/path/to/resource_api_client.rb
invoke  rspec
create    spec/api_clients/path/to/resource_api_client_spec.rb` 

この時、 application_api_client.rb に標準のエラーハンドラの例がいくつか記載されるのですが、例というより必須のエラーハンドラだよね、ということで、 my_api_client の内部で標準実装するようにしました。 これにより、ステータスコード 4xx と 5xx のレスポンスに対しては標準で例外が発生するようなります。また、ネットワーク系のエラーに対しても標準で 300 msec 間隔を空けて 3 回リトライが試行されるようになります。(リトライ処理も従来は明示的な定義が必須でした)

# 従来の `application_api_client.rb` に出力されていた標準のエラーハンドラ例
error_handling status_code: 400..499, raise: MyApiClient::ClientError
error_handling status_code: 500..599, raise: MyApiClient::ServerError

# 従来の `application_api_client.rb` に出力されていた標準のリトライ処理例
retry_on MyApiClient::NetworkError, wait: 5.seconds, attempts: 3

標準で定義されているエラーハンドラは my_api_client/default_error_handlers.rb から参照できます。

error_handling は後から定義した物が優先されますので、例えばステータスコード 400 に対しては独自の例外クラスを発生させるようにしたい場合、継承先のクラスで error_handling status_code: 400, raise: MyErrorClass のように定義すれば、 MyErrorClass が例外として発生するようになります。

所感

社内のプロダクト用に作った gem ですが、少しずつ自分以外のエンジニアも利用してくれるようになってきました。 一方で、自由度が高過ぎると熟知していないと使えない機能が増えてしまう点を課題感として感じるようになってきました。

なるべく自由度の高い gem を意識しつつ、標準の状態でも高度な機能の恩恵を受けられる状態を目指していきたいと思います。

恐らく次の新機能は async/await っぽい機能、または sawyer gem の依存からの脱却なると思います。

my_api_client v0.15.0 をリリースしました🚀

その前に v0.14.0 もリリースしているのですが、こちらはリファクタリングと Integration Test の実装だけで新機能はありませんでした。 差分が +2,799 -1,246 もあるので中身は結構書き換わっています。

Release v0.14.0 · ryz310/my_api_client · GitHub

Integration Test では Ruby on Jets を使って AWS Lambda でサーバーを建てて、CI でのテストで my_api_client を使って実際に HTTP リクエストが成功することを確認しているので、デグレの心配が随分と緩和されました 😌

新機能: Pagination API のサポート

ここからは v0.15.0 の話になります。

Release v0.15.0 · ryz310/my_api_client · GitHub

v0.15.0 のメイン機能が Pagination API のサポートになります。JSON:API というしっかりとした仕様もあるようですが、 my_api_client ではそこまで厳密な仕様に則っている訳ではなく、レスポンスに含まれる URL を認識して enumerable に HTTP リクエストを実行する、というざっくりした機能になります。 レスポンスヘッダの Link などで次のページの URL を返すケースもあるようですが、そちらは現時点では未対応です 🙏

Pagination API という単語は Django REST Framework の Pagination 機能の説明で出てきます。

www.django-rest-framework.org

要するに一度のリクエストで結果を全件取得させるのではなく、一定の件数を返却し、続きを取得できる Link を一緒に返却する API のことですね。

Request:

GET https://api.example.org/accounts/?page=4

Response:

HTTP 200 OK
{
    "count": 1023
    "next": "https://api.example.org/accounts/?page=5",
    "previous": "https://api.example.org/accounts/?page=3",
    "results": []
}

使い方

my_api_client での使い方は以下のようになります。

class MyPaginationApiClient < ApplicationApiClient
  endpoint 'https://example.com/v1'

  # GET pagination?page=1
  def pagination
    pageable_get 'pagination', paging: '$.links.next', headers: headers, query: { page: 1 }
  end

  private

  def headers
    { 'Content-Type': 'application/json;charset=UTF-8' }
  end
end

通常であれば #get を使って HTTP リクエストを実行させるのですが、ここでは #pageable_get というメソッドを使用しています。 #pageable_get だと長いので #pget というエイリアスも用意しています。 また、 paging というキーワード引数も新たに出てきました。 paging ではレスポンスのどの部分に次のページの URL が含まれるかを JSONPath expression で指定します。

goessner.net

以下のような JSON であれば、 $.links.next という JSONPath expression は "https://example.com/pagination?page=3" を取得します。

{
  "links": {
    "next": "https://example.com/pagination?page=3",
    "previous": "https://example.com/pagination?page=1",
  },
  "page": 2
}

作成した API Client は以下のように使用できます。

api_clinet = MyPaginationApiClient.new
api_clinet.pagination.each do |response|
  # Do something.
end

p = api_clinet.pagination
p.next # => 1st page result
p.next # => 2nd page result
p.next # => 3rd page result

結果は Enumerator::Lazy で返却される

#pageable_getEnumerator::Lazy を返却するので、 Enumerable で定義されているメソッドは一通り利用可能です。

docs.ruby-lang.org

Enumerator で返してしまうと #take で 100 ページ目まで結果を取得するような処理を記述したときに、

  1. 100 ページ分の HTTP リクエストを実行
  2. 結果を #each で回す

という動きになり、 100 回分の HTTP リクエストが完了するまで次の処理に移ることができません。

Enumerator::Lazy であれば、

  1. 1 ページ目の HTTP リクエストを実行
  2. 結果を処理する
  3. 2ページ目の HTTP リクエストを実行
  4. 結果を処理する
  5. ...

という動きになってくれます。便利ですね ✨

EnumeratorEnumerator::Lazy の違いは以下の記事が参考になると思います。

qiita.com

駆け込みで Chrome 80 の SameSite=None; Secure の対応をやった🍪

ご存知の方も多いかと思いますが、 Chrome 80 から 3rd Party Cookie の取り扱いが厳しくなり、特に指定がないと外部サイトの CookiePOST・iframe・XHR 等のリクエス で送られなくなります。

developers-jp.googleblog.com

2 月の Chrome 80 以降、SameSite 値が宣言されていない Cookie は SameSite=Lax として扱われます。外部アクセスは、SameSite=None; Secure 設定のある Cookie のみ可能になります。ただし、これらが安全な接続からアクセスされることが条件です。

とはいえ完全に無効になるわけではなく、 SameSite という属性が宣言されていない CookieLax という区分がデフォルトで適用されるという物なので、サーバーから返す Cookie に対して明示的に SameSiteNone に指定して、かつ Secure という属性を付与すれば、従来どおり Cookie が送信されます。

去年の秋くらいにもこの Cookie の対応が必要かどうか、会社で調査していたんですが、その時点では影響を受ける箇所が無くて、特に何も対応せずにスルーしていたんですが、現在開発してる新機能がたまたまこの影響を受ける機能だったため、急遽対応することになりました。

結構厳しいな、と思ったのは、これが原因で動かないことに気付くのが結構難しいんですよね。 シングルサインオンみたいな機能を作ってるとドメインが異なるので 3rd Party Cookie の扱いになります。 そして Cookie に入っているはずの Session ID が送られてこないので、サーバー側で Session が見つからずにエラー。

自分の手元の Chrome は 79 で、まだ上記の制限が入っていなかったんですが、以前影響範囲を調査した時に chrome://flags から有効にするフラグを ON にしていたので、他のエンジニアの環境では動作するけど、自分だけ動かないということになり、もしかして、と思って気付いた感じです。

リリース後に気付いてたらヤバかったですね。。

で、こういう問題は Rails みたいなフレームワークで対応してくれよって気持ちになるんですが、ちゃんと対応する PR は作られていて、すでに merge もされています。

github.com

ですが、 2020/2/20 現在、これを反映した Rails はまだリリースされていないみたいですね。(最新が 2019/12/19 にリリースされた 6.0.2.1 ) また、 Rails 5.2 に反映されるかどうかは微妙な感じになっています。

https://github.com/rails/rails/pull/28297#issuecomment-577414543

We will backport to 6.0 as a bug fix, but I don't know this warrants a backport to a security only release like 5.2. Rails 6.0 was released 6 months ago, and upgrading applications could be high, high priority if that problem is so important.

バグ修正として6.0にバックポートしますが、これが5.2のようなセキュリティのみのリリースへのバックポートを保証するかどうかわかりません。 Rails 6.0は6か月前にリリースされました。その問題が非常に重要な場合、アプリケーションのアップグレードは優先度が高くなる可能性があります。

自分の開発環境は恥ずかしながら Rails 4.2 (今年中にアップデートします!)なので、当然反映されるはずもないので、自前で Rack を作成して対応しました。 参考まで以下のようなコードになります。

# config/initializers/custom_rack_middleware.rb

# NOTE: Rails 6.0.x であれば以下の処理は不要となる。
Rails.application.config.middleware.insert_before(
  ActionDispatch::Cookies,
  CustomRackMiddleware::SetSameSiteOptionOnCookie
)
# lib/custom_rack_middleware/set_same_site_option_on_cookie.rb

# TODO: Rails 6.0.x にアップデートしたら削除する
module CustomRackMiddleware
  class SetSameSiteOptionOnCookie
    def initialize(app)
      @app = app
    end

    def call(env)
      status, headers, body = @app.call(env)

      cookies = headers['Set-Cookie']
      if cookies.present?
        processed_cookies = cookies.split("\n").map do |cookie|
          "#{cookie}; SameSite=None; Secure"
        end
        headers['Set-Cookie'] = processed_cookies.join("\n")
      end

      [status, headers, body]
    end
  end
end

2020/02/22 追記

ローカルの開発環境など HTTP リクエストをする環境だと、 SameSite=None の設定を入れると Cookie が送られなくなるようです。

ローカルでは上述の Rack を読み込ませないようにするなどの工夫が必要だと思います。