Railsで外部APIを叩く機会はたまにあるかと思います。
利用するサービスのSDKなんかがあればまだいいですが、SDKがないこともあるので、その場合にどのようにAPIクライアントやそれに関連するクラスを実装するか?と言うのを毎回迷うので、迷わないように実装指針をまとめてみることにしました。
個人的にActiveModelのインターフェイスが好みで、やるなら使いたいと思っていました。
ActiveModelを利用したWebAPIクライアントもあるにはあるのですが、Asssociationがあったりで、あまり必要がない部分で機能が多かったり、あまりメンテナンスされておらず決定版がないと言う状態でした。
なので、ライブラリを作るために実装イメージをまとめるためこのブログを書いてみることにしました。
利用するサービスのSDKなんかがあればまだいいですが、SDKがないこともあるので、その場合にどのようにAPIクライアントやそれに関連するクラスを実装するか?と言うのを毎回迷うので、迷わないように実装指針をまとめてみることにしました。
個人的にActiveModelのインターフェイスが好みで、やるなら使いたいと思っていました。
ActiveModelを利用したWebAPIクライアントもあるにはあるのですが、Asssociationがあったりで、あまり必要がない部分で機能が多かったり、あまりメンテナンスされておらず決定版がないと言う状態でした。
なので、ライブラリを作るために実装イメージをまとめるためこのブログを書いてみることにしました。
今回の実装ポリシーについて
設計の健全性よりもRailsらしいことを追求してみました。
設計の健全性の話をすると、データを取得するクラスとWebAPIにリクエストを行うクラスを分けるべきで、それはもうActiveModelを使ってはいけないことになり、今回の趣旨に反するので。
実装の全文だったり、サンプルはGistにも掲載しているので読みにくければこちらもご覧ください。
設計の健全性の話をすると、データを取得するクラスとWebAPIにリクエストを行うクラスを分けるべきで、それはもうActiveModelを使ってはいけないことになり、今回の趣旨に反するので。
実装の全文だったり、サンプルはGistにも掲載しているので読みにくければこちらもご覧ください。
base.rb
GitHub Gist: instantly share code, notes, and snippets.
ベースモデルの実装及び解説
以後、ベースになるモジュールのことをモジュール名に沿ってApiModelと呼ぶことにします。
このApiModelを利用するクラスでincludeして利用するのが大枠の流れになります。
ベースモジュールの実装は下記です。
ベースモジュール
このApiModelを利用するクラスでincludeして利用するのが大枠の流れになります。
ベースモジュールの実装は下記です。
ベースモジュール
require_relative 'errors' module ApiModel module Base extend ActiveSupport::Concern include ActiveModel::Model include ActiveModel::Attributes include ActiveModel::Serializers::JSON included do private attr_accessor :response end class_methods do def api_url 'http://localhost:3000' end def client Faraday.new(url: api_url) do |faraday| faraday.adapter Faraday.default_adapter faraday.request :json faraday.response :json, parser_options: { symbolize_names: true } end end def raise_api_exception(response) case response.status when 404 raise ApiNotFound.new(response), "Record not found" when 422 raise ApiInvalidError.new(response), "Invalid data" when 400..499 raise HttpClientError.new(response), "Client error" when 500..599 raise HttpServerError.new(response), "Server error" else raise ApiError.new(response), "Api Error" end end def request(method, *args, &block) response = client.public_send(method, *args) do |req| yield req if block_given? end if response.success? response else raise_api_exception(response) end end end def has_error? errors.any? end def validate! raise RecordInvalidError.new(self) unless valid? end private def request(method, *args, &block) @response = self.class.client.public_send(method, *args) do |req| yield req if block_given? end if response.success? true else handle_api_errors end end def request!(method, *args, &block) @response = self.class.client.public_send(method, *args) do |req| yield req if block_given? end if response.success? self else raise_api_exception end end def raise_api_exception case response.status when 404 raise ApiNotFound.new(response), "Record not found" when 422 add_api_errors raise RecordInvalidError.new(self, response) when 400..499 raise HttpClientError.new(response), "Client error" when 500..599 raise HttpServerError.new(response), "Server error" else raise ApiError.new(response), "Api Error" end end def handle_api_errors raise_api_exception rescue RecordInvalidError false end def add_api_errors return unless response.status == 422 error_message = :unknown_error # Get the custom error message if available, otherwise use the default message custom_error_key = "activemodel.errors.models.#{self.class.name.underscore}.response.#{error_message}" common_error_key = "activemodel.errors.api_record.response.#{error_message}" error_message_text = I18n.t(custom_error_key, default: I18n.t(common_error_key)) errors.add(:response, error_message_text) error_response = response.body if error_response.is_a?(Hash) error_response.each do |key, error_details| error_details.each do |message| errors.add(key, message) end end end end end end
例外クラス
module ApiModel class RecordInvalidError < StandardError attr_reader :record, :response def initialize(record, response = nil) @record = record @response = response message = "Validation failed: #{record.errors.full_messages.join(', ')}" super(message) end end class ApiError < StandardError attr_reader :response def initialize(response) @response = response end end class HttpClientError < ApiError; end class HttpServerError < ApiError; end class ApiNotFound < HttpClientError; end end
必要な実装はここまでのはず。
何か注意があるとすれば、FaradayというRubyでよく利用されているWebAPIクライアントライブラリを利用しているので、そちらを忘れずにインストールしておいてください。
基本的なモジュールの利用方法
ここでは、カード番号とユーザーオブジェクトを渡すことで、WebAPIで支払いの認可を行うクラスである、Api::Paymentクラスを実装してみました。
module Api class Payment include ApiRecord::Base attribute :user, :user attribute :card_number, :integer validates :card_number, presence: true, numericality: { only_integer: true, greater_than: 0 } validates :user, presence: true def save return false unless valid? params = { user_id: user.id, card_number: card_number } request(:post, "/users/#{user.id}/payments", params) end def save! validate! params = { user_id: user.id, card_number: card_number } request!(:post, "/users/#{user.id}/payments", params) end end end
Controllerの実装
バリデーションエラー時に例外を発生させない場合の実装は下記です。
class Payment::AuthorizeController < ApplicationController def create payment = Api::Payment.new(card_number: params[:card_number], user: user) if payment.save head 204 else render json: { payment.errors }, status: 422 end end end
バリデーションエラー時に例外を発生させる場合の実装は下記です。
class Payment::AuthorizeController < ApplicationController def create payment = Api::Payment.new(card_number: params[:card_number], user: user) payment.save! head 204 end end
外部APIを使う場合はこういった使い方がメインなのかなぁと思いました。
request メソッドでHTTPリクエストができるのが、責務が密結合になってしまい悪いところでもあるのですが、個人的にRailsのような振る舞いを表現できた部分で気に入っているところです。
CRUDを実装する場合
サンプルAPIとして、DummyJSONを使ったCRUDをWebAPIを使って作成してみます。
https://dummyjson.com
今回はproductsエンドポイントを利用した、Api::Productモデルを実装しました。
ApiModel::RelationをStructで作成します
まずは、indexアクションの結果を格納するためのApiModel::Relationを追加します。
この命名もActive Recordを参考にしました。ただ、Collectionと言う名前でもここは良いかも知れなかったです。
今回は手抜きでStructにしていますが別にクラスでも良いと思います。
ここでは実装していませんがページネーションの実装なんかもここでやるとよいのかなーと考えたりしていました。
https://dummyjson.com
今回はproductsエンドポイントを利用した、Api::Productモデルを実装しました。
ApiModel::RelationをStructで作成します
まずは、indexアクションの結果を格納するためのApiModel::Relationを追加します。
この命名もActive Recordを参考にしました。ただ、Collectionと言う名前でもここは良いかも知れなかったです。
今回は手抜きでStructにしていますが別にクラスでも良いと思います。
ここでは実装していませんがページネーションの実装なんかもここでやるとよいのかなーと考えたりしていました。
module ApiModel Relation = Struct.new(:data, :response) end
CRUDは長いので、こちらのGistからApi::Productクラスを読んでみてください。
CRUDの利用サンプル
実装したモデルをControllerで呼び出す際のサンプルコードを、
Controllerのサンプルコードだけ抜粋しておきました。
これはまんまRailsですねぇ。。。
base.rb
GitHub Gist: instantly share code, notes, and snippets.
CRUDの利用サンプル
実装したモデルをControllerで呼び出す際のサンプルコードを、
Controllerのサンプルコードだけ抜粋しておきました。
これはまんまRailsですねぇ。。。
class ProductsController < ApplicationController before_action :set_product, only: [:show, :update, :destroy] def index @products = Api::Product.index.data end def show end def create @product = Api::Product.new(params) if @product.save render :show, status: 201 else render json: { @product.errors }, status: 422 end end def update if @product.update(params) render :show else render json: { @product.errors }, status: 422 end end def destroy @product.destroy head :no_content end private def set_product @product = Api::Product.find(params[:id]) end # Only allow a list of trusted parameters through. def product_params params.require(:product).permit(:title) end end
工夫したところ
requestメソッド
HTTPクライアントにはFaradayを利用していますが、なるべくFaradayを使っている感じがないようにしました。
クライアントなどではなくApiModelという名前、概念なのだから、requestメソッドを実行することで処理がスムーズになるように実装しています。
例外を発生させるsave!にちなんで、APIでのバリデーションエラー発生時に例外を発生させる場合は、request!メソッドを利用すると例外が発生するようになります。
エラーハンドリング
どのようなアプローチを取ると、エラーハンドリングのインターフェースを統一化できるかを検討していました。
結果的にはActiveModelとルールを合わせるのが良いと思い試してみました。
バリデーションエラーは想定できるエラーなので、例外を発生させないで、ActiveModel上にある、errorsに対してActiveModel::Errorオブジェクトを追加します。(errors.addです)
たとえば404や401などのステータスコードが422以外のHTTP上でのエラーは基本的には想定外のことがほとんどであると思ったので、例外にしています。
これはActiveRecordの挙動を大いに参考にした結果で、ActiveRecordでもsaveメソッドであっても、
書き込み時にRDBMSのユニーク制約によって書き込みができなかったりしたときも例外が発生するので、同じルールで実装を行いました。
こんな感じでベースモジュールを実装しておけば、外部APIを使った実装のエラーハンドリングにの統一感を計ることができるのではないかと考えています。
HTTPクライアントにはFaradayを利用していますが、なるべくFaradayを使っている感じがないようにしました。
クライアントなどではなくApiModelという名前、概念なのだから、requestメソッドを実行することで処理がスムーズになるように実装しています。
例外を発生させるsave!にちなんで、APIでのバリデーションエラー発生時に例外を発生させる場合は、request!メソッドを利用すると例外が発生するようになります。
エラーハンドリング
どのようなアプローチを取ると、エラーハンドリングのインターフェースを統一化できるかを検討していました。
結果的にはActiveModelとルールを合わせるのが良いと思い試してみました。
バリデーションエラーは想定できるエラーなので、例外を発生させないで、ActiveModel上にある、errorsに対してActiveModel::Errorオブジェクトを追加します。(errors.addです)
たとえば404や401などのステータスコードが422以外のHTTP上でのエラーは基本的には想定外のことがほとんどであると思ったので、例外にしています。
これはActiveRecordの挙動を大いに参考にした結果で、ActiveRecordでもsaveメソッドであっても、
書き込み時にRDBMSのユニーク制約によって書き込みができなかったりしたときも例外が発生するので、同じルールで実装を行いました。
こんな感じでベースモジュールを実装しておけば、外部APIを使った実装のエラーハンドリングにの統一感を計ることができるのではないかと考えています。
作ってみてどうだったか
正直万人におすすめできるものではないですが、RailsでWebAPIを利用して作成する管理画面を作るみたいな要件の場合、WebAPIとActiveModelを組み合わせて使いたいケースもあるので、その場合にどうしたらいいかわからない人は参考にするとよいと思いました。(ライブラリができたら使って欲しい)
個人としてはWebAPIクライアントをどう実装すると良いかの練習になり、実装イメージが纏まったので、もうちょっとブラッシュアップしてライブラリとして公開できるようにしていきます。
個人としてはWebAPIクライアントをどう実装すると良いかの練習になり、実装イメージが纏まったので、もうちょっとブラッシュアップしてライブラリとして公開できるようにしていきます。