my_api_client v0.16.0 をリリースしました🚀
前回のリリースから 1 週間ほどですが、今日予定していたライブがコロナウイルスの影響で中止になったので暇を持て余しました 😷
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::Error
は raise
オプションで例外クラスを指定しなかった場合のデフォルトの例外クラスです。
この時、従来は 30
と 40
のように with
や block
を利用した場合は、処理の中で明示的に 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
が発生しますが、 with
や block
と同時に raise
を指定すれば、任意の例外クラスが発生するようになります。
これにより、 with
や block
は例外の前処理という位置付けになります。ユースケースとしてはログ出力や 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 機能の説明で出てきます。
要するに一度のリクエストで結果を全件取得させるのではなく、一定の件数を返却し、続きを取得できる 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 で指定します。
以下のような 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_get
は Enumerator::Lazy
を返却するので、 Enumerable
で定義されているメソッドは一通り利用可能です。
Enumerator
で返してしまうと #take
で 100 ページ目まで結果を取得するような処理を記述したときに、
- 100 ページ分の HTTP リクエストを実行
- 結果を
#each
で回す
という動きになり、 100 回分の HTTP リクエストが完了するまで次の処理に移ることができません。
Enumerator::Lazy
であれば、
という動きになってくれます。便利ですね ✨
Enumerator
と Enumerator::Lazy
の違いは以下の記事が参考になると思います。
駆け込みで Chrome 80 の SameSite=None; Secure の対応をやった🍪
ご存知の方も多いかと思いますが、 Chrome 80 から 3rd Party Cookie の取り扱いが厳しくなり、特に指定がないと外部サイトの Cookie は POST・iframe・XHR 等のリクエスト で送られなくなります。
2 月の Chrome 80 以降、SameSite 値が宣言されていない Cookie は SameSite=Lax として扱われます。外部アクセスは、SameSite=None; Secure 設定のある Cookie のみ可能になります。ただし、これらが安全な接続からアクセスされることが条件です。
とはいえ完全に無効になるわけではなく、 SameSite
という属性が宣言されていない Cookie は Lax
という区分がデフォルトで適用されるという物なので、サーバーから返す Cookie に対して明示的に SameSite
を None
に指定して、かつ Secure
という属性を付与すれば、従来どおり Cookie が送信されます。
去年の秋くらいにもこの Cookie の対応が必要かどうか、会社で調査していたんですが、その時点では影響を受ける箇所が無くて、特に何も対応せずにスルーしていたんですが、現在開発してる新機能がたまたまこの影響を受ける機能だったため、急遽対応することになりました。
結構厳しいな、と思ったのは、これが原因で動かないことに気付くのが結構難しいんですよね。 シングルサインオンみたいな機能を作ってるとドメインが異なるので 3rd Party Cookie の扱いになります。 そして Cookie に入っているはずの Session ID が送られてこないので、サーバー側で Session が見つからずにエラー。
自分の手元の Chrome は 79 で、まだ上記の制限が入っていなかったんですが、以前影響範囲を調査した時に chrome://flags
から有効にするフラグを ON にしていたので、他のエンジニアの環境では動作するけど、自分だけ動かないということになり、もしかして、と思って気付いた感じです。
リリース後に気付いてたらヤバかったですね。。
で、こういう問題は Rails みたいなフレームワークで対応してくれよって気持ちになるんですが、ちゃんと対応する PR は作られていて、すでに merge もされています。
ですが、 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 を読み込ませないようにするなどの工夫が必要だと思います。