Rails API + Firestore Local Emulator 開発の雛形
Rails API で DB に Firestore を使用する開発の雛形を作成しました。
docker-compose で簡単にセットアップができるような構成となっています。
作成したサンプルはこちら
Rails の API を下記記事で作成していますので、そちらを本記事で流用しています。
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 emulator
とFunctions emulator
とFirestore emulator
を有効にしました。
root@328d6705d4cf:/opt/workspace# firebase init
Firebase の設定のサービスアカウントの設定から『新しい秘密鍵の生成』を行い、config/firebase/service_account.json
に設置します。
docker-compose.yml
で GCP_PROJECT_ID
の編集を行います。
(編集後コンテナの再生成をする必要あります)
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
firestore.rb
で firestore
への接続を制御。
docker-compose
の FIRESTORE_EMULATOR_HOST
の設定をしなければ、config/firebase/service_account.json
の生成元の firebase の firestore に繋がります。
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: トランザクション制御を入れるなど改善したいポイントは多々あり)
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 に書く必要があり、改善したい)
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 のような使用感で使えているかと思います。
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 のような使用感で使えているかと思います。
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
@chiku_dev さん、こんにちは。
FirestoreRecordクラスでやろうとしていることは、僕も同じようなアプローチでgemを作っています。
といっても、まだまだ発展途上のやつですが。
日本語版READMEを読んでもらうと雰囲気がわかるかもしれません。
もし興味があればこちらも覗いてみてください。
@jnchitoさん、こんばんは。
著書「プロを目指す人のためのRuby入門」拝読しています。
もう少ししてRubyに慣れた頃に、firestoreのgem化…というのも頭にあったのですが、蛇足みたいですね。
コード拝見して勉強させてもらいます。
おお、どうもありがとうございます!嬉しいです😆
今回僕が作ったgemについてはQiitaにも紹介記事を書いておきました。
ActiveRecordっぽくFirestoreを操作できるgemを作りました(ActAsFireRecordBeta) - Qiita
まだまだ発展途上なので足りない機能等があればぜひプルリクを送ってやってくださいw