Webuilder240

ActiveModelを使って外部APIをRailsらしく実装する

2023-05-20 09:55:11 +0900

ActiveModel Faraday Ruby Ruby on Rails
Railsで外部APIを叩く機会はたまにあるかと思います。
利用するサービスのSDKなんかがあればまだいいですが、SDKがないこともあるので、その場合にどのようにAPIクライアントやそれに関連するクラスを実装するか?と言うのを毎回迷うので、迷わないように実装指針をまとめてみることにしました。

個人的にActiveModelのインターフェイスが好みで、やるなら使いたいと思っていました。
ActiveModelを利用したWebAPIクライアントもあるにはあるのですが、Asssociationがあったりで、あまり必要がない部分で機能が多かったり、あまりメンテナンスされておらず決定版がないと言う状態でした。
なので、ライブラリを作るために実装イメージをまとめるためこのブログを書いてみることにしました。

今回の実装ポリシーについて

設計の健全性よりもRailsらしいことを追求してみました。
設計の健全性の話をすると、データを取得するクラスとWebAPIにリクエストを行うクラスを分けるべきで、それはもうActiveModelを使ってはいけないことになり、今回の趣旨に反するので。

実装の全文だったり、サンプルはGistにも掲載しているので読みにくければこちらもご覧ください。

ベースモデルの実装及び解説

以後、ベースになるモジュールのことをモジュール名に沿ってApiModelと呼ぶことにします。
この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にしていますが別にクラスでも良いと思います。

ここでは実装していませんがページネーションの実装なんかもここでやるとよいのかなーと考えたりしていました。

module ApiModel
  Relation = Struct.new(:data, :response)
end
CRUDは長いので、こちらのGistからApi::Productクラスを読んでみてください。



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を使った実装のエラーハンドリングにの統一感を計ることができるのではないかと考えています。

作ってみてどうだったか

正直万人におすすめできるものではないですが、RailsでWebAPIを利用して作成する管理画面を作るみたいな要件の場合、WebAPIとActiveModelを組み合わせて使いたいケースもあるので、その場合にどうしたらいいかわからない人は参考にするとよいと思いました。(ライブラリができたら使って欲しい)

個人としてはWebAPIクライアントをどう実装すると良いかの練習になり、実装イメージが纏まったので、もうちょっとブラッシュアップしてライブラリとして公開できるようにしていきます。

関連しそうなブログ