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
endControllerの実装
バリデーションエラー時に例外を発生させない場合の実装は下記です。
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クライアントをどう実装すると良いかの練習になり、実装イメージが纏まったので、もうちょっとブラッシュアップしてライブラリとして公開できるようにしていきます。