MogLog

日記 兼 備忘録

aws-sdk-s3 for Rubyを使ってみた

今更感があるが、Rubyaws-sdk-s3 gemを使ってS3と戯れた記録を残す。

事前準備

Gemとしては aws-sdk-s3 だけ使えれば良いので、これをGemfileに書く。

# ./Gemfile
source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

gem 'aws-sdk-s3'

bundle install を実行。Gemのセットアップはこれで完了。

$ bundle install --path vendor/bundle

次、sdkからAWSにアクセスするためにはアクセスキーを作成する必要があるので、マネジメントコンソールから作成する。作成方法は省略するが、下記のドキュメントを見て行った。 aws.amazon.com

入手したアクセスキーとシークレットアクセスキーをaws cliを使ってセットアップする。awscliはhomebrewを使ってインストールした。

$ brew install awscli

awscliをインストールしたら、aws configure で対話的に認証情報の設定ができる。

$ aws configure
AWS Access Key ID [None]: xxx(入手したアクセスキーを入力)
AWS Secret Access Key [None]: yyy(入手したシークレットアクセスキーを入力)
Default region name [None]:(Enter)
Default output format [None]:(Enter)

実行後、~/.aws/credentials~/.aws/config が作られているはず。これで事前準備は完了。 なお、認証情報の設定については以下のドキュメントが参考になった。 docs.aws.amazon.com

バケットの作成

まずはバケットの作成から。Aws::S3::Resource#create_bucket で作ることができる。
ここでは mogulla3 という名称のバケットを作った。

require 'aws-sdk-s3'

s3 = Aws::S3::Resource.new
bucket = s3.create_bucket(acl: 'private', bucket: 'mogulla3') 
# => #<Aws::S3::Bucket:0x00007fae70403be0 ..>

バケットの存在確認

バケットの存在は Aws::S3::Bucket#exists? で確認できる。

require 'aws-sdk-s3'

s3 = Aws::S3::Resource.new
bucket = s3.bucket('mogulla3')
bucket.exists?
# => true

バケットの一覧を取得する

Aws::S3::Resource#bucketsバケットの一覧(Aws::S3::Bucket::Collection)を取得できる。

require 'aws-sdk-s3'

s3 = Aws::S3::Resource.new
s3.buckets.each do |bucket|
  pp bucket
  # => #<Aws::S3::Bucket:0x00007fd06f0d3ee0 ..>
end

ローカルのファイルをパス指定でS3にアップロードする

ローカルPC上にあるファイルをS3上にアップロードしたいようなケースを想定。
Aws::S3::Object.upload_file と使うとアップロードできる。

require 'aws-sdk-s3'

file = File.new('./sample.txt') 
s3 = Aws::S3::Resource.new
obj = s3.bucket('mogulla3').object('sample.txt') # S3オブジェクトのキー名
obj.upload_file(file.path)

ローカルのファイルのコンテンツを読み込んでS3にアップロードする

今度は、ローカルPC上にあるファイルのコンテンツを直接渡して、S3上にアップロードしたいようなケースを想定。 Aws::S3::Bucket#put_object を使うとアップロードできる。

require 'aws-sdk-s3'

file = File.new('./sample.txt') 

s3 = Aws::S3::Resource.new
bucket = s3.bucket('mogulla3')
bucket.put_object(acl: 'private', body: file.read, key: 'sample2.txt')
# => #<Aws::S3::Object:0x00007fae6ecaa4f8 ..>

S3オブジェクトのダウンロード & 読み込み

S3上にあるファイルをローカルPC上にダウンロードして、そのコンテンツを読み込む。

require 'aws-sdk-s3'

s3 = Aws::S3::Resource.new
bucket = resource.bucket('mogulla3')
obj = bucket.object('sample.txt')
get_object_output = obj.get
io = get_object_output.body

puts io.read
# => ファイルの中身が出力される

Aws::S3::Object#getAws::S3::Types::GetObjectOutputインスタンスを取得し、そのインスタンスに対して #body を呼び出すと StringIO 形式でコンテンツを取得できる。

docs.ruby-lang.org

同じことは、Aws::S3::Client を使うと少しスッキリ書けそうだった。

require 'aws-sdk-s3'
 
client = Aws::S3::Client.new
obj = client.get_object(bucket: 'mogulla3', key: 'sample.txt')
io = obj.body 
 
puts io.read
# => ファイルの中身が出力される

オブジェクトを削除する

オブジェクトの削除は Aws::S3::Bucket#delete_objects でやる。引数に渡すHashの構造が地味に面倒。

require 'aws-sdk-s3'
 
s3 = Aws::S3::Resource.new
bucket = resource.bucket('mogulla3')
res =  bucket.delete_objects(
  delete: {
    objects: [
      { key: 'sample.txt' },
    ]
  }
)

バケットを削除する

バケットの削除はAws::S3::Bucket#deleteAws::S3::Bucket#delete! を使うとできるが、#delete はオブジェクトが空じゃないと実行できない。ここでは delete! を使う。

require 'aws-sdk-s3'
 
s3 = Aws::S3::Resource.new
bucket = s3.bucket('mogulla3')

bucket.delete!
# => #<struct Aws::EmptyStructure>

終わりに

以上、使ってみた記録を残した。APIドキュメントとにらめっこしながら実行してみたログなので、適切な使い方かはちょっと自信がない。
Clientオブジェクトを使っても色々できそうだったが、今回は基本的に Resource からたどる形式でやってみた。

参考URL

Rails6で導入されたinsert_all、insert_all!、upsert_allを使ってみる

Rails6にて insert_all, insert_all!, upsert_all という一括作成・更新の機能が導入された。 railsguides.jp

これらのメソッドを、公式のAPIドキュメントを読みつつ手元の環境で実際に使ってみた記録を残す。

環境

検証用データベースとして、次のスキーマのusersテーブルがあるものとする

Column Type Nullable Default
id integer not null nextval('users_id_seq'::regclass)
name character varying not null
hobby character varying
created_at timestamp without time zone not null
updated_at timestamp without time zone not null

insert_all, insert_all!

まずかんたんな例として、3件のuserデータの一括作成を試みる。
APIドキュメント によると、引数はHashの配列を渡す必要があるとのこと。name キーだけ含んだHashの配列を与えて実行してみる。

users = [
  { name: 'Alice' },
  { name: 'Bob' },
  { name: 'Charles' },
]
User.insert_all(users)
# => ActiveRecord::NotNullViolation: PG::NotNullViolation: ERROR:  null value in column "created_at" violates not-null constraint

created_at のNOT NULL制約に引っかかって、ActiveRecord::NotNullViolation 例外が発生してしまった。 NOT NULL制約がついているカラムは明示的に指定しなければならないようだ。

users = [
  { name: 'Alice', created_at: Time.current, updated_at: Time.current }, 
  { name: 'Bob', created_at: Time.current, updated_at: Time.current }, 
  { name: 'Charles', created_at: Time.current, updated_at: Time.current },
]
res = User.insert_all(users)
# => #<ActiveRecord::Result:0x00007f92fc3c3728 @column_types={"id"=>#<ActiveModel::Type::Integer:0x00007f92fb5ca180 @limit=4, @precision=nil, @range=-2147483648...2147483648, @scale=nil>}, @columns=["id"], @hash_rows=nil, @rows=[[2], [3], [4]]>

User.count
# => 3

今度は成功する。発行されるSQLは以下の通り、1SQLだ。

INSERT INTO "users"(
  "name", "created_at", "updated_at"
) 
VALUES 
  ('Alice', '2019-12-01 14:06:12.151661', '2019-12-01 14:06:12.151710'), 
  ('Bob', '2019-12-01 14:06:12.151717', '2019-12-01 14:06:12.151721'), 
  ('Charles', '2019-12-01 14:06:12.151724', '2019-12-01 14:06:12.151745') 
ON CONFLICT DO NOTHING RETURNING "id"

返り値として ActiveRecord::Resultインスタンスが返ってきており、#to_aで作成した行を参照できるが、ここにはid情報しか含まれていない。

res.to_a
# => [{"id"=>2}, {"id"=>3}, {"id"=>4}]

作成処理を実行したあとにその行情報を参照して何かをしたいことよくあるので、このままだとやや使い勝手が悪いだろう。
そういったときは、#insert_allreturing オプションを使うことで返り値の情報を変更できる。 しかし、APIドキュメントによると returning オプションはPostgreSQL限定のようなので注意されたし。

users = [
  { name: 'Alice', created_at: Time.current, updated_at: Time.current }, 
  { name: 'Bob', created_at: Time.current, updated_at: Time.current }, 
  { name: 'Charles', created_at: Time.current, updated_at: Time.current },
]
res = User.insert_all(users, returning: %i[id name hobby])

res.to_a
# => [
#   {"id"=>8, "name"=>"Alice", "hobby"=>nil},
#   {"id"=>9, "name"=>"Bob", "hobby"=>nil},
#   {"id"=>10, "name"=>"Charles", "hobby"=>nil}
# ]

次に、unique制約に引っかかるデータを同時に作成しようとしたときにどうなるかを見てみる。usersテーブルのnameカラムにはunique制約が付いているので、nameの値が同じレコードを同時に作成してみる。

# 2レコードともnameをAliceとする
users = [
  { name: 'Alice', created_at: Time.current, updated_at: Time.current }, 
  { name: 'Alice', created_at: Time.current, updated_at: Time.current },
]
res = User.insert_all(users)

User.count
# => 1

User.count の結果を見るとわかるように、insert_all の場合は重複した行は無視する。もし例外を起こしたい場合はbangつきの insert_all! を使えば良い。

# 2レコードともnameをAliceとする
users = [
  { name: 'Alice', created_at: Time.current, updated_at: Time.current }, 
  { name: 'Alice', created_at: Time.current, updated_at: Time.current },
]
res = User.insert_all(users)
# => ActiveRecord::RecordNotUnique: PG::UniqueViolation: ERROR:  duplicate key value violates unique constraint "index_users_on_name"

User.count
# => 0

ActiveRecord::RecordNotUnique 例外が発生し、userデータは1件も作成されない。

upsert_all

UPSERTの検証をしたいので、name="Alice"のレコードを事前に作っておく。

User.create!(name: 'Alice')

Aliceのhobbyを更新しつつ、name="Bob"のレコードをINSERTしてみよう。

users = [
  { name: 'Alice', hobby: 'cooking' }, 
  { name: 'Bob', created_at: Time.current, updated_at: Time.current },
]
res = User.upsert_all(users)
# => ArgumentError: All objects being inserted must have the same keys

おっと、ArgumentError が発生してしまった。どうやら配列内のHashの構造はすべて一致していなければならないようだ。

users = [ 
  { name: 'Alice', hobby: 'cooking', created_at: Time.current, updated_at: Time.current },
  { name: 'Bob', hobby: nil, created_at: Time.current, updated_at: Time.current },
]
res = User.upsert_all(users)
# => ActiveRecord::RecordNotUnique: PG::UniqueViolation: ERROR:  duplicate key value violates unique constraint "index_users_on_name"

今度は ActiveRecord::RecordNotUnique が発生してしまった。どうやらname="Alice"のHashが新規作成分として見なされてしまったようだ。
どのキーをUPSERTの判断軸とするかを unique_by オプションで明示的に指定するとこのエラーを回避できる。

users = [ 
  { name: 'Alice', hobby: 'cooking', created_at: Time.current, updated_at: Time.current },
  { name: 'Bob', hobby: nil, created_at: Time.current, updated_at: Time.current },
]
res = User.upsert_all(users, unique_by: 'name')
# => #<ActiveRecord::Result:0x00007f9301a1a838 @column_types={"id"=>#<ActiveModel::Type::Integer:0x00007f92fb5ca180 @limit=4, @precision=nil, @range=-2147483648...2147483648, @scale=nil>}, @columns=["id"], @hash_rows=nil, @rows=[[27], [29]]>

User.count
# => 2

しかし、unique_by オプションはPostgreSQLSQLiteでしか使えないようなので、これもまた注意されたし..。MySQLだとこの状況、どうすればいいんだろう。

その他のメモ

その他ドキュメントを読んでいてのメモ

  • insert_allupsert_all はモデルのインスタンスを作らないので、CallbackやValidationはスキップされる
  • bangつきの upsert_all! は存在しない

参考URL

【Ruby】rescueした例外のmessageを上書きしてre-raiseする

rescueした例外のmessageを上書きしてre-raiseしたい場合、Exception#exception を使うとスッキリ書ける。

https://docs.ruby-lang.org/ja/latest/method/Exception/i/exception.html

引数を指定しない場合は self を返します。
引数を指定した場合 自身のコピー を生成し Exception#message 属性を error_message にして返します。

# exception_test.rb
def override_exception_message_and_reraise
  begin
    raise RuntimeError.new('abc')
  rescue => e
    raise e.exception('xyz')
  end
end

次のテストはpassする。

# spec/exception_test_spec.rb
require_relative '../exception_test.rb'

RSpec.describe do
  it do
    expect { override_exception_message_and_reraise }.to raise_error(RuntimeError, 'xyz')
  end
end

『雰囲気で使わずきちんと理解する!整理してOAuth2.0を使うためのチュートリアルガイド』がわかりやすくてオススメ

最近読んで良かった技術本『雰囲気で使わずきちんと理解する!整理してOAuth2.0を使うためのチュートリアルガイド』の紹介をしたい。

読み始めた背景

OAuth2.0、Webアプリケーションの開発をしたことがある人なら一度は触ったことがあるんじゃないかと思う。 自分は仕事でも使っているし、趣味の個人開発でも使ったことがある。

しかし、なんとなく自信が無く、ちゃんとわかった気がしないもやっとした状態が続いていた。
OAuthに詳しくなくても、ライブラリが裏で良い感じにやってくれて、ライブラリのREADMEを読んでとりあえず試してみたら大体やりたいことができてしまってそのままになる...みたいなパターンも良くなかったのかもしれない。

...というような感じで理解が曖昧な状態だったので「いつかOAuthをちゃんと理解して使えるようになりたい」と思っていたときにこの本の存在を知り、分量も多くなさそうだったので読み始めてみたのだった。

所感

まず、ありきたりな感想ではあるが、非常にわかりやすかった。
読んでいてつまづいたり立ち止まることがほとんど無く、すっと頭に入ってきてくれる。多分、章の構成がうまいんじゃないかと思っている。

チュートリアルガイドという名の通り、後半の半分近くはGoogleのOAuthサービスを使って、curlとブラウザで典型的なグラントタイプを試すことができるようになっているが、自分は前半部分のOAuthの仕様・用語についての方がタメになった。

対象読者としては、この本の「はじめに」にも書かれている内容だが、以下の質問にスッと答えられない場合、一読の価値があると思う。

  • スコープとはなんですか?
  • 認可コードは何が行われた証ですか?
  • サーバサイドのアプリケーションの場合、どのグラントタイプを使うべきですか?

恥ずかしながら、自分は読み始めた時点でまともに答えられたのは1つだけだった。が、今は全てちゃんと答えられるようになった。
せっかくなので、自分の回答を載せておく。 ※ ちゃんと読んだらみんな答えられる

スコープとはなんですか?

→ アクセストークンにひも付いている、アクセス権をコントロールするための仕組み。誰のどのリソースにどのような操作を行ってよいかを示す。

認可コードは何が行われた証ですか?

→ リソースオーナーがクライアントへの権限委譲に同意した証(認可サーバが発行し、クライアントがそれを受け取って、アクセストークンを取得するときに使われる)

サーバサイドのアプリケーションの場合、どのグラントタイプを使うべきですか?

→ 認可コードグラント。クライアントクレデンシャルグラントも条件を満たしさえすれば使っても問題ない認識。

ここには回答しか書いていないので、理由が知りたい方や、回答内に知らない用語が出てきたぞという方は本書をどうぞ!

終わりに

とてもタメになる本だったので、勢いで紹介したくなってこのエントリを書いた。 自分は読むスピードは速い方では無いのだが、サクサク読めて休日1日でほとんど読みきれて、かなりコスパの良い1日になったと思う。著者のAuth屋さんには感謝です。

最後に、Twitterで感想を少しつぶやいたらAuth屋さんが拾ってくれたので記念に貼っておく。続編が出るようなので期待して待っています 😃😃😃

現場で使えるRSpecパターン集 for Rails App

はじめに

RSpecは慣れるととても手に馴染むテスティングツールだが、割と癖があってRSpecでテストを書くのに苦労している人も多いのではないだろうか。

自分はまさにそうで、書きたいテストは決まっていてもそれをどう書けばよいか、というところで当初は時間がかかっていたように思う。

実際に実務で何年かRSpecを使ってきて、よく使うパターン(型)のようなものができてきたので、それらをここにまとめてみようと思う。同じように「どう書けばいいか」で躓いている人や書き方をド忘れしてしまった人の助けになれば幸いである。

前提として、Railsアプリケーションを想定した内容になっている。

検証環境、ライブラリ

基本方針

便宜上、以下の方針でパターンを書く。

  • subject でテストの主体を明示する
  • it の引数は省略する
  • FactoryBotwebmock といったライブラリは使わない

Request spec

ステータスコードが xxx であること

# ステータスコード200を期待する場合
subject { get xxx_path }

it do
  is_expected.to eq 200
end

または

subject { get xxx_path }

it do
  expect(response).to have_http_status(200)
end

レスポンスボディに xxx が含まれていること

subject { get xxx_path }

it do
  subject
  expect(response.body).to include('xxx')
end

データが新規作成されていること

Fooモデルの件数が1増えていることを確認

subject { post xxx_path }

it do
  expect { subject }.to change(Foo, :count).by(1)
end

データが更新されていること

fooのvalue属性が "from" から "to" になることを確認

# 実際にはパラメータを渡して、fooが更新対象となるようにするだろう
subject { put xxx_path } 

let(:foo) { Foo.find(1) } # 更新対象のインスタンス

it do
  expect { subject }.to change(foo, :value).from('from').to('to')
end

データが削除されていること

Fooモデルの件数が1減っていることを確認

subject { delete xxx_path }

it do 
  expect { subject }.to change(Foo, :count).by(-1)
end

リダイレクトされること

subject { get xxx_path }

# yyy_pathにリダイレクトされることをテスト
it do
  expect { subject }.to redirect_to(yyy_path)
end

ファイルのダウンロードができること

レスポンスヘッダの Content-Disposition から確認する

subject { get xxx_path }

# 通常、ファイルダウンロード時はContent-Dispositionヘッダが次のような形式になることから
# Content-Disposition: attachment; filename="YOUR_FILENAME.pdf"
it do
  subject
  expect(response.headers['Content-Disposition']).to include('attachment')
  expect(response.headers['Content-Disposition']).to include('YOUR_FILENAME.pdf')
end

Model spec

次のようなFooモデルクラスがあるとする。

class Foo < ApplicationRecord
  validates :value, presence: true
end

バリデーションにパスすること

subject { foo.valid? }

let(:foo) { Foo.new(value: 'abc') }

it do
  is_expected.to be true
end

バリデーションエラーとなること

subject { foo.valid? }

let(:foo) { Foo.new(value: nil) }

it do
  is_expected.to be false
end

バリデーションエラーとなり、期待するエラーが発生していること

subject { foo.valid? }

let(:foo) { Foo.new(value: nil) }

# 指定した属性でエラーが起きていることをテスト
it do
  subject
  expect(foo.errros).to include(:value)
end

# 指定した属性でエラーが起きており、かつメッセージも期待通りであることをテスト
it do
  subject
  expect(foo.errros.full_messages_for(:value)).to include("can't be blank")
end

Job spec

次のようなSampleJobクラスがあり、sample_job という名称でキューが登録されるものとする。

# app/jobs/sample_job.rb
class SampleJob < Application
  queues_as :sample_job

  def perform(name)
    puts "Hello, #{name}!"
  end
end

ジョブがエンキューされること

subject { SampleJob.perform_later('Bob') }

# subjectを実行することでエンキューされることをテスト
it do
  expect { subject }.to have_enqueued_job(SampleJob).with('Bob').on_queue('sample_job')
end

ジョブが実行され、期待する動作をすること

#perform_enqueued_job によりジョブが同期的に実行されるため、その後に期待する状態をテストすればOK。
※ 参考 : ActiveJob::TestHelper

subject { SampleJob.perform_later('Bob') }

it do
  perform_enqueued_jobs { subject }

  # ジョブ実行後に期待する振る舞いを以下に書く
end

Mailer spec

次のようなSampleMailerクラスがあり、#send_mail でメールが送信されるものとする。

# app/mailers/sample_mailer.rb
class SampleMailer < ApplicationMailer
  default from: 'from@example.com'

  def send_mail
    mail(to: 'to@example.com', subject: 'title', body: 'body')
  end
end

メール送信処理が実行すること

通常、テスト環境では実際にメールを送信せず、送信されたはずのメールは ActionMailer::Base.deliveries から参照できる。
※ 参考 : https://railsguides.jp/testing.html#メイラーをテストする

subject { SampleMailer.send_mail }

it do 
  expect { subject }.to change(ActionMailer::Base.deliveries, :count).by(1)
end

送り主(from)が期待通りであること

subject { SampleMailer.send_mail }

it do
  mail = subject
  expect(mail.from).to eq 'from@example.com'
end

宛先(to)が期待通りであること

subject { SampleMailer.send_mail }

it do
  mail = subject
  expect(mail.to).to eq 'to@example.com'
end

タイトルが期待通りであること

subject { SampleMailer.send_mail }

it do
  mail = subject
  expect(mail.subject).to eq 'title'
end

本文が期待通りであること

subject { SampleMailer.send_mail }

it do
  mail = subject
  expect(mail.body).to eq 'body'
end

モック

外部APIを呼び出すオブジェクトを使っている場合などに使う。

オブジェクトのメソッド呼び出しをモックする

let(:my_obj) { instance_double('MyObj') }
 
before do 
  allow(my_obj).to receive(:my_method).and_return('Hello, world!')
  allow(MyObj).to receive(:new).and_return(my_obj)
end

it do
  my_obj = MyObj.new
  expect(my_obj.my_method).to eq 'Hello, world!'
end

期待するメソッドが呼び出されていること

subject { ... }

let(:my_obj) { instance_double('MyObj') }
 
before do 
  allow(my_obj).to receive(:my_method).and_return('Hello, world!')
  allow(MyObj).to receive(:new).and_return(my_obj)
end

it do
  subject
  expect(my_obj).to have_received(:my_method).once
end

共通

その他、Specの種類によらず共通でよく使うパターンを少しだけ。

例外を投げること

subject { 1 / 0 }

it do
  expect { subject }.to raise_error(ZeroDivisionError)
end

期待するメッセージとともに例外を投げること

subject { 1 / 0 }

it do
  expect { subject }.to raise_error(ZeroDivisionError).with('divided by 0')
end

オブジェクトが期待する属性を持っていること

subject { OpenStruct.new(name: 'Bob') }

it do
  expect(subject).to have_attributes(name: 'Bob')
end

配列が期待通りの構造であること

subject { [1, 10, 'Hello, world!'] }

it do
   expect(subject).to match [
     1, 10, 'Hello, world!'
   ]
end

Composing Mathcer を使えばより柔軟なテストができる。

subject { [1, 10, 'Hello, world!'] }

it do
   expect(subject).to match [
     eq(1),
     a_kind_of(Integer),
     match(/^Hello/),
   ]
end

Hashが期待するキーを持つこと

subject { { key: 'value' } }

it do
  expect(subject).to include(:key)
end

Hashが期待するキーバリューを持つこと

subject { { key: 'value' } }

it do
  expect(subject).to include(key: 'value')
end

Hashの配列が全て期待するキーを持つこと

subject do
  [
    { key: 'value1' },
    { key: 'value2' },
    { key: 'value3' },
  ]
end

it do
  expect(subject).to all(include(:key))
end

正規表現にマッチすること

subject { '123' }

it do
  expect(subject).to match(/^[0-9]+$/)
end

nilであること

subject { nil }

it do
  expect(subject).to be_nil
end

be_nil に限らず、nil? のような ? で終わるメソッドは be_xxx として使うことができる。
※ 参考 : Predicate matchers - Built in matchers - RSpec Expectations - RSpec - Relish

あるクラスのサブクラスのインスタンスであること

subject { 'Hello, world!' }

it do
  expect(subject).to be_a(String) # => success
  expect(subject).to be_a(Object) # => success
end

あるクラスの直接のインスタンスであること

subject { 'Hello, world!' }

it do
  expect(subject).to be_an_instance_of(String)  # => success
  expect(subject).to be_an_instance_of(Object) # => fail
end