🍣

Rails API + Firestore Local Emulator 開発の雛形

2022/12/24に公開3

Rails API で DB に Firestore を使用する開発の雛形を作成しました。
docker-compose で簡単にセットアップができるような構成となっています。

作成したサンプルはこちら
https://github.com/chikugoy/rails_firestore

Rails の API を下記記事で作成していますので、そちらを本記事で流用しています。
https://zenn.dev/korezen/articles/cc72b917132ad3

Rails API の初期設定

$ docker-compose build
$ docker-compose run rails_firestore_web rails db:create
$ docker-compose run rails_firestore_web rake db:migrate
$ docker-compose up -d

Firebaseの設定

firebase login --no-localhost をした後にログイン用のURLが出力されるのでそこからログインを行います

$ docker-compose exec rails_firestore_firebase bash
root@328d6705d4cf:/opt/workspace# firebase login --no-localhost

firebase init をして firebase emulator の初期設定を行います。
自分はAuthentication emulatorFunctions emulatorFirestore emulatorを有効にしました。

root@328d6705d4cf:/opt/workspace# firebase init

Firebase の設定のサービスアカウントの設定から『新しい秘密鍵の生成』を行い、config/firebase/service_account.json に設置します。

docker-compose.ymlGCP_PROJECT_ID の編集を行います。
(編集後コンテナの再生成をする必要あります)

docker-compose.yml
  rails_firestore_web:
    build:
      context: .
      dockerfile: docker/web/Dockerfile
    command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
    volumes:
      - .:/myapp
    environment:
      FIRESTORE_EMULATOR_HOST: 'rails_firestore_firebase:8080'
      GCP_AUTH_KEY_FILE: 'service_account.json'
      GCP_PROJECT_ID: 'regex-bc723'

コンテナ再生成

$ docker-compose down
$ docker-compose up -d

firebase の emulator を開始します。

$ docker-compose exec rails_firestore_firebase bash
root@328d6705d4cf:/opt/workspace# firebase emulators:start

確認

firestore の動作確認

http://127.0.0.1:4000/firestore で以下のような画面が表示されます。

Postmanでの動作確認

rails routesで現在のURLが確認可能です。

root@854380043c21:/myapp# rails routes

テストコードの実行(RSpec)

主要なGET, POST, PUT, DELETE の API の確認と model の validation のテストを実行しています。

root@854380043c21:/myapp# bundle exec rspec
I, [2022-11-17T16:11:38.464039 #1173]  INFO -- : Started GET "/api/v1/regexes" for 127.0.0.1 at 2022-11-17 16:11:38 +0900
.I, [2022-11-17T16:11:38.524595 #1173]  INFO -- : Started GET "/api/v1/regexes?text=test_regex_text_1" for 127.0.0.1 at 2022-11-17 16:11:38 +0900
.I, [2022-11-17T16:11:38.596267 #1173]  INFO -- : Started GET "/api/v1/regexes?id=test_regex_id_2" for 127.0.0.1 at 2022-11-17 16:11:38 +0900
.I, [2022-11-17T16:11:38.625263 #1173]  INFO -- : Started GET "/api/v1/regexes/test_regex_id_1" for 127.0.0.1 at 2022-11-17 16:11:38 +0900
.I, [2022-11-17T16:11:38.651968 #1173]  INFO -- : Started GET "/api/v1/regexes/test_regex_id_9" for 127.0.0.1 at 2022-11-17 16:11:38 +0900
.I, [2022-11-17T16:11:38.678816 #1173]  INFO -- : Started POST "/api/v1/regexes" for 127.0.0.1 at 2022-11-17 16:11:38 +0900
.I, [2022-11-17T16:11:38.708699 #1173]  INFO -- : Started POST "/api/v1/regexes" for 127.0.0.1 at 2022-11-17 16:11:38 +0900
.I, [2022-11-17T16:11:38.738239 #1173]  INFO -- : Started POST "/api/v1/regexes" for 127.0.0.1 at 2022-11-17 16:11:38 +0900
.I, [2022-11-17T16:11:38.794039 #1173]  INFO -- : Started POST "/api/v1/regexes" for 127.0.0.1 at 2022-11-17 16:11:38 +0900
.I, [2022-11-17T16:11:38.823189 #1173]  INFO -- : Started PUT "/api/v1/regexes/test_regex_id_1" for 127.0.0.1 at 2022-11-17 16:11:38 +0900
.I, [2022-11-17T16:11:38.852462 #1173]  INFO -- : Started PUT "/api/v1/regexes/test_regex_id_1" for 127.0.0.1 at 2022-11-17 16:11:38 +0900
.I, [2022-11-17T16:11:38.884358 #1173]  INFO -- : Started DELETE "/api/v1/regexes/test_regex_id_1" for 127.0.0.1 at 2022-11-17 16:11:38 +0900
.I, [2022-11-17T16:11:38.914986 #1173]  INFO -- : Started DELETE "/api/v1/regexes/test_regex_id_8" for 127.0.0.1 at 2022-11-17 16:11:38 +0900
....

Finished in 0.50873 seconds (files took 0.96425 seconds to load)
16 examples, 0 failures

コード解説

model

  • models
    • firestore
      • firestore.rb
      • firestore_recored.rb
    • regex.rb

firestore.rbfirestore への接続を制御。
docker-composeFIRESTORE_EMULATOR_HOST の設定をしなければ、config/firebase/service_account.json の生成元の firebase の firestore に繋がります。

firestore.rb
module Firestore
  class Firestore
    require 'google/cloud/firestore'
    require 'singleton'

    class_attribute :firestore

    self.firestore ||= Google::Cloud::Firestore.new(
      project_id: ENV["GCP_PROJECT_ID"],
      credentials: Rails.root.join('config/firebase', ENV["GCP_AUTH_KEY_FILE"]).to_s
    )
    freeze
  end
end

既存の model と同じように Model.find のような形で使いたかったのでベースクラスを作成しています。
このクラスを継承させれば find, find_by, create, save, delete などのメソッドが同じような感じで使用できます。
(FIXME: トランザクション制御を入れるなど改善したいポイントは多々あり)

firestore_recored.rb
module Firestore
  class FirestoreRecord < Firestore
    require 'singleton'
    class_attribute :collection_path

    class << self
      def find(*ids)
        results = []
        ids.each do |id|
          result = firestore.col(@collection_path).doc(id).get.fields
          next if !result
          results << self_new(result.merge({id: id}))
        end
        results
      end

      def find_row(id)
        results = self.find(id)
        nil if !results || results.length == 0
        results[0]
      end

      def find_by(args = {})
        return nil unless args && args.is_a?(Hash)

        ref = firestore.col @collection_path
        args.each do |key, value|
          ref = ref.where(key, "=", value)
        end

        ref.get.map do |record|
          self_new(record.fields.merge({:id => record.document_id}))
        end
      end

      def create(data)
        raise Exception.new("#{self.class.name} create args not hash") unless data.is_a?(Hash)

        instance = self_new(data)
        instance.created_at ||= Time.current
        instance.updated_at ||= Time.current

        return instance unless instance.valid?

        ref = firestore.col @collection_path
        instance.attributes.delete('id')
        ref = ref.add instance.attributes
        instance.id = ref.document_id

        instance
      end

      def create_by_id(data)
        raise Exception.new("#{self.class.name} create args not hash") unless data.is_a?(Hash)
        raise Exception.new("#{self.class.name} create_by_id id unset") if !data[:id] || data[:id].empty?

        instance = self_new(data)
        instance.created_at ||= Time.current
        instance.updated_at ||= Time.current

        return instance unless instance.valid?

        ref = firestore.doc "#{@collection_path}/#{instance.id}"
        ref.set(instance.attributes)

        instance
      end
    end

    def save(data)
      raise Exception.new("#{self.class.name} save id unset") if !self.id || self.id.empty?

      self.text = data[:text] if data[:text] || !data[:text].empty?
      self.created_at ||= Time.current
      self.updated_at = Time.current

      return false unless self.valid?

      ref = firestore.doc "#{self.class::COLLECTION_PATH}/#{self.id}"
      ref.set(self.attributes, merge: true)

      true
    end

    def delete
      raise Exception.new("#{self.class.name} delete id unset") if !self.id || self.id.empty?

      ref = firestore.doc "#{self.class::COLLECTION_PATH}/#{self.id}"
      ref.delete
      true
    end

    freeze
  end
end

model本体。
Firestore::FirestoreRecord を継承させてます。
(FIXME: def self.self_new(data) を現状すべての model に書く必要があり、改善したい)

regex.rb
class Regex < Firestore::FirestoreRecord
  COLLECTION_PATH = 'regex'.freeze
  @collection_path = COLLECTION_PATH

  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :id, :string
  attribute :text, :string
  attribute :created_at, :time
  attribute :updated_at, :time

  validates :text, presence: true
  validates :created_at, presence: true
  validates :updated_at, presence: true

  attr_reader :child_model

  def self.self_new(data)
    Regex.new(data)
  end
end

controller

Regex で firestore の操作を行っていますが、Rails 標準の model のような使用感で使えているかと思います。

regexes_controller.rb
module Api
  module V1
    class RegexesController < ApplicationController
      before_action :set_regex, only: [:show, :update, :destroy]

      def index
        regexes = Regex.find_by(query_params)
        render json: { status: 'SUCCESS', message: 'Loaded regexes', data: regexes }
      end

      def show
        render json: { status: 'SUCCESS', message: 'Loaded the regex', data: @regex.attributes }
      end

      def create
        if regex_params[:id]
          regex = Regex.create_by_id(regex_params.to_h)
        else
          regex = Regex.create(regex_params.to_h)
        end

        if regex.valid?
          render json: { status: 'SUCCESS', data: regex.attributes }
        else
          render json: { status: 'ERROR', message: 'Not created', data: regex.errors.full_messages }, status: :bad_request
        end
      end

      def destroy
        @regex.delete
        render json: { status: 'SUCCESS', message: 'Deleted the regex', data: {} }
      end

      def update
        if @regex.save(regex_params.to_h)
          render json: { status: 'SUCCESS', message: 'Updated the regex', data: @regex.attributes }
        else
          render json: { status: 'SUCCESS', message: 'Not updated', data: @regex.errors.full_messages  }, status: :bad_request
        end
      end

      private

      def set_regex
        @regex = Regex.find_row(params[:id])
      end

      def regex_params
        params.permit(:id, :text)
      end

      def query_params
        query = {}
        query[:id] = params[:id] unless params[:id].blank?
        query[:text] = params[:text] unless params[:text].blank?
        query
      end
    end
  end
end

test

Regex で firestore 操作を行っています。
こちらも通常の model のような使用感で使えているかと思います。

spec/requests/api/v1/regexes_spec.rb
require 'rails_helper'

# logger
Rails.logger = Logger.new(STDOUT)

RSpec.describe "Regexes", type: :request do
  before do
    (1..10).each { |i|
      Regex.create_by_id(
        :id => "test_regex_id_#{i}",
        :text => "test_regex_text_#{i}",
        'created_at' => Time.current,
        'updated_at' => Time.current
      )
    }

    @regexes = Regex.find_by
    @regexes.freeze
  end

  context "index" do
    it 'all data' do
      get '/api/v1/regexes'
      json = JSON.parse(response.body)

      expect(response.status).to eq(200)

      expect(json['data'].length).to eq(@regexes.length)
    end

    it 'where text = *' do
      get '/api/v1/regexes?text=test_regex_text_1'
      json = JSON.parse(response.body)

      expect(response.status).to eq(200)

      expect(json['data'].length).to eq(1)
      expect(json['data'][0]['attributes']['text']).to eq('test_regex_text_1')
    end

    it 'where id = *' do
      get '/api/v1/regexes?id=test_regex_id_2'
      json = JSON.parse(response.body)

      expect(response.status).to eq(200)

      expect(json['data'].length).to eq(1)
      expect(json['data'][0]['attributes']['id']).to eq('test_regex_id_2')
    end
  end

  context "show" do
    it 'id = test_regex_id_1' do
      get '/api/v1/regexes/test_regex_id_1'
      json = JSON.parse(response.body)

      expect(response.status).to eq(200)

      expect(json['data']['id']).to eq('test_regex_id_1')
    end

    it 'id = test_regex_id_9' do
      get '/api/v1/regexes/test_regex_id_9'
      json = JSON.parse(response.body)

      expect(response.status).to eq(200)

      expect(json['data']['id']).to eq('test_regex_id_9')
    end
  end

  context "create" do
    it 'valid ok, id = test_regex_id_create_1' do
      valid_params = {
        id: 'test_regex_id_create_1',
        text: 'test_regex_text_create_1',
      }

      post '/api/v1/regexes', params: valid_params
      json = JSON.parse(response.body)

      expect(response.status).to eq(200)

      expect(json['data']['id']).to eq('test_regex_id_create_1')

      regex = Regex.new(:id => "test_regex_id_create_1")
      regex.delete
    end

    it 'valid ok, id is nothing' do
      valid_params = {
        text: 'test_regex_text_create_1',
      }

      post '/api/v1/regexes', params: valid_params
      json = JSON.parse(response.body)

      expect(response.status).to eq(200)

      regex = Regex.new(:id => json['data']['id'])
      regex.delete
    end

    it 'valid ng, text empty' do
      valid_params = {
        id: 'test_regex_id_create_1',
        text: '',
      }

      post '/api/v1/regexes', params: valid_params
      json = JSON.parse(response.body)

      expect(response.status).to eq(400)

      expect(json['data'][0]).to eq('比較テキストを入力してください')

      regex = Regex.new(:id => "test_regex_id_create_1")
      regex.delete
    end


    it 'valid ng, text is nothing' do
      valid_params = {
        id: 'test_regex_id_create_1',
      }

      post '/api/v1/regexes', params: valid_params
      json = JSON.parse(response.body)

      expect(response.status).to eq(400)

      expect(json['data'][0]).to eq('比較テキストを入力してください')

      regex = Regex.new(:id => "test_regex_id_create_1")
      regex.delete
    end
  end

  context "update" do
    it 'valid ok' do
      before_regex = Regex.find_row('test_regex_id_1')
      valid_params = {
        text: 'test_regex_text_update_1',
      }

      put '/api/v1/regexes/test_regex_id_1', params: valid_params
      json = JSON.parse(response.body)

      expect(response.status).to eq(200)

      expect(json['data']['id']).to eq('test_regex_id_1')
      expect(json['data']['text']).to eq('test_regex_text_update_1')

      after_regex = Regex.find_row('test_regex_id_1')
      expect(json['data']['text']).to eq(after_regex.text)
      expect(before_regex.text).not_to eq(after_regex.text)
      expect(before_regex.updated_at).not_to eq(after_regex.updated_at)
    end

    it 'valid ng, text is empty' do
      before_regex = Regex.find_row('test_regex_id_1')
      valid_params = {
        text: '',
      }

      put '/api/v1/regexes/test_regex_id_1', params: valid_params
      json = JSON.parse(response.body)

      expect(response.status).to eq(400)

      expect(json['data'][0]).to eq('比較テキストを入力してください')

      after_regex = Regex.find_row('test_regex_id_1')
      expect(before_regex.text).to eq(after_regex.text)
      expect(before_regex.updated_at).to eq(after_regex.updated_at)
    end
  end

  context "destroy" do
    it 'id = test_regex_id_1' do
      before_regex = Regex.find_row('test_regex_id_1')
      before_regexes = Regex.find_by

      delete '/api/v1/regexes/test_regex_id_1'

      expect(response.status).to eq(200)

      after_regex = Regex.find_row('test_regex_id_1')
      after_regexes = Regex.find_by

      expect(before_regex).to_not be_nil
      expect(after_regex).to be_nil
      expect(before_regexes.length).to eq(after_regexes.length + 1)
    end

    it 'id = test_regex_id_8' do
      before_regex = Regex.find_row('test_regex_id_8')
      before_regexes = Regex.find_by

      delete '/api/v1/regexes/test_regex_id_8'

      expect(response.status).to eq(200)

      after_regex = Regex.find_row('test_regex_id_8')
      after_regexes = Regex.find_by

      expect(before_regex).to_not be_nil
      expect(after_regex).to be_nil
      expect(before_regexes.length).to eq(after_regexes.length + 1)
    end
  end

  after do
    (1..10).each { |i|
      regex = Regex.new(:id => "test_regex_id_#{i}")
      regex.delete
    }
  end
end

まとめ

firestore に rails は冗長な気もしますが、rails の勉強も兼ねて今回、rails api + firestore 開発の雛形的なものを作成してみました。

個人開発をする場合、データ管理に firestore を使用すればほとんどコストをかけずにサービスの公開が可能です。

Discussion

Junichi ItoJunichi Ito

@chiku_dev さん、こんにちは。
FirestoreRecordクラスでやろうとしていることは、僕も同じようなアプローチでgemを作っています。
といっても、まだまだ発展途上のやつですが。

https://github.com/JunichiIto/act_as_fire_record_beta

日本語版READMEを読んでもらうと雰囲気がわかるかもしれません。

https://github.com/JunichiIto/act_as_fire_record_beta/blob/main/README-ja.md

もし興味があればこちらも覗いてみてください。

chiku_devchiku_dev

@jnchitoさん、こんばんは。
著書「プロを目指す人のためのRuby入門」拝読しています。
もう少ししてRubyに慣れた頃に、firestoreのgem化…というのも頭にあったのですが、蛇足みたいですね。
コード拝見して勉強させてもらいます。