初めに
この記事は
RSpecによるTDDでRailsAPIを実装してみた。part1
この記事のpart2です。もしよろしければpart1からご覧ください。
今回の目標はoctokitを使ってUser認証のログイン機能とログアウト機能を扱えるようになるまでです。
この記事は結構長いです。記事だけの断片的なコードだと理解しづらい部分は多いですので、適度に自分のコードを読んで、内容を理解していってください。また、わかりづらい表現等がありましたら、コメントください。
それでは初めて行きます。
GithubAPIとの通信
Githubに登録
まずはGithubのApiを使って通信をするためにgithubでアプリケーション登録をする必要がある。
https://github.com/settings/apps
このページに飛び、New Github Appから登録に行く。
登録事項は以下。
Application name:
-> 一意で自由にアプリケーションの名前をつける
Homepage URL:
-> http://localhost:3000
開発用のurlを登録します。
Application description:
-> 自由にわかりやすいように説明を入れる
Authorization callback URL:
-> http://localhost:3000/oauth/github/callback
リダイレクト用のURLの設定
入力が終わったら、Register Applicationを押します。
するとかのよような表示が返ってくる。
Owned by: @user_name
App ID: xxxxx
Client ID: Iv1.xxxxxxxxxxxxxxxxxxxxx
Client secret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
このClientIDとClientSecreteを使ってgitubAPIに接続します。どこかにコピ-しておく。
octokit
次にoctokitというgemを導入していく。
公式
https://github.com/octokit/octokit.rb
octokitを使うことで、より簡単にgithubとの連携をとることができるらしい。(中で何が起きているかはあまり知らない)
そして既に最初にoctokitのgemは追加してあるので、そのまま続けていく。
ターミナルに移る。
$ GITHUB_LOGIN='githubuser_name' GITHUB_PASSWORD='github_password' rails c
まず、二つの値を環境変数に入れておく。これは普段githubにログインする時に使うusernameとpassword。そしてconsoleが開くことを確認する。
一応
ENV['GITHUB_LOGIN']
などを打って中身が入っていることを確認しておく。
$ client = Octokit::Client.new(login: ENV['GITHUB_LOGIN'], password: ENV['GITHUB_PASSWORD'])
$ client.user
そしてoctokitに接続して、user情報がしっかりと取れていることを確認する。
これはただの演習です。今後、この仕組みを使って実装していく。
User.rb生成
では、Userモデルを作っていく。
$ rails g model login name url avatar_url provider
migrationファイルにデータベースレベルの制限をつけていく。
class CreateUsers < ActiveRecord::Migration[6.0]
def change
create_table :users do |t|
t.string :login, null: false
t.string :name
t.string :url
t.string :avatar_url
t.string :provider
t.timestamps
end
end
end
ファイルが生成されているので、login属性にnull: falseをつけておく。
$ rails db:migrate
バリデーションテスト
次にモデルレベルでの制限をつけていく。
validationをつけていきたいところですが、まずはテストから書いていく。
require 'rails_helper'
RSpec.describe User, type: :model do
describe '#validations' do
it 'should have valid factory' do
user = build :user
expect(user).to be_valid
end
it 'should validate presence of attributes' do
user = build :user, login: nil, provider: nil
expect(user).not_to be_valid
expect(user.errors.messages[:login]).to include("can't be blank")
expect(user.errors.messages[:provider]).to include("can't be blank")
end
it 'should validate uniqueness of login' do
user = create :user
other_user = build :user, login: user.login
expect(other_user).not_to be_valid
other_user.login = 'newlogin'
expect(other_user).to be_valid
end
end
end
最初のテストはfactorybotが起動しているかを確認するテスト
二つ目は、loginとproviderが入っているかを確認するテスト
三つ目は、loginがuniqueかどうかを確認するテスト
あと、factorybotが現時点だと、何度createしても同じuserを追加してしまうので、それを修正する。
FactoryBot.define do
factory :user do
sequence(:login) { |n| "a.levine #{n}" }
name { "Adam Levine" }
url { "http://example.com" }
avatar_url { "http://example.com/avatar" }
provider { "github" }
end
end
sequenceを使って解決する。これで毎回作ったuserのloginは一意になる。
これでテストを実行する。
$ rspec spec/models/user_spec.rb
ここで、typoがなく、正常にエラーが出ていることを確認する。最初のfactorybotが正常に動いているかを確認するテストは成功する。
validation実装
これからvalidationを実装していく。
class User < ApplicationRecord
validates :login, presence: true, uniqueness: true
validates :provider, presence: true
end
テストを実行し成功することを確認する。
次に、githubとやりとりをするためのコードを書いていく。
UserAuthenticator.rb作成
app/lib
ディレクトリを作成
その下に、app/lib/user_authenticator.rb
を作成する。
class UserAuthenticator
def initialize
end
end
本来先にテストコードを書くのがTDDだが、先にclassを定義してしまった方が、正しいエラーが吐き出されるので、ファイル作成とclassの定義は先にしてしまった方が早い。
codeが正しくない場合のテスト
それからテストを書いていく。
libディレクトリと、ファイルを作成する。
spec/lib/user_authenticator_spec.rb
require 'rails_helper'
describe UserAuthenticator do
describe '#perform' do
context 'when code is incorrenct' do
it 'should raise an error' do
authenticator = described_class.new('sample_code')
expect{ authenticator.perform }.to raise_error(
UserAuthenticator::AuthenticationError
)
expect(authenticator.user).to be_nil
end
end
end
end
今回はperformというインスタンスメソッドを使って、サインインやログインを実行していく
まずは、codeが、不適切なものだった時。
(ちなみにcodeは、githubが発行する一度きりのtokenのことで、今回はそのcodeを実際に受け取ることがないので、codeはただの文字列を使い、そのコードに対して、どうgithubが振舞うか、という部分をモックを使うことで、実際に発行されたcodeなしでテストを完結させるようにしている。codeはgithubuser一意のtokenと交換するために使う。)
described_class.newでインスタンスを作成、authenticator.performでメソッドを実行する。
UserAuthenticator::AuthenticationError
は独自のクラスで定義する。
テストを実行すると、.perform
がないと言われる。そして、さらに.user
が使えないと言われる。
なので、実際に書いていく。
user_authentiator#perform実装
class UserAuthenticator
class AuthenticationError < StandardError; end
attr_reader :user
def initialize(code)
end
def perform
raise AuthenticationError
end
end
attr_readerdでいつでもuserを読み込めるようにしておく。
そして、performも定義しておく。
StandardError
を継承したAuthenticationError
を定義し、UserAuthenticator
にネストさせておく。
performのなかでraiseさせているのはとりあえずテストを成功させるため。
これで、テストを実行すると成功する。
$ rspec spec/lib/user_authenticator_spec.rb
codeが正しい場合のテスト
そして次は、codeが正しい場合のテストを書く。しかしその前にshould raise an errorで使っている
authenticator = described_class.new('sample_code') authenticator.perform
この二つの部分を
describe '#perform' do
let(:authenticator) { described_class.new('sample_code') }
subject { authenticator.perform }
このように定義しておいて、これから書くwhen code is correctでも使っていく。
なので今の全体像は以下のようになる。
describe '#perform' do
let(:authenticator) { described_class.new('sample_code') }
subject { authenticator.perform }
context 'when code is incorrenct' do
it 'should raise an error' do
expect{ subject }.to raise_error(
UserAuthenticator::AuthenticationError
)
expect(authenticator.user).to be_nil
end
end
end
ではcodeがただしい時のテストも書く
context 'when code is correct' do
it 'should save the user when does not exists' do
expect{ subject }.to change{ User.count }.by(1)
end
end
userがあらかじめdatabaseに存在しないuserだった場合は、User.countが1増える。
これはuserの新規登録という事。
これで、テストを実行するがもちろん失敗する。それはperformアクションでは何があってもraise AuthenticationError
というふうに書いてあるから。
なので、performメソッドを実装していく。
実行部分の記述
def perform
client = Octokit::Client.new(
client_id: ENV['GITHUB_CILENT_ID'],
client_secret: ENV['GITHUB_CILENT_SECRET'],
)
res = client.exchange_code_for_token(code)
if res.error.present?
raise AuthenticationError
else
end
end
ここでやっていることは、まず、記事の最初にプロジェクトをgithubに認証させている。
この記事の最初にこのプロジェクトをgithubに登録した時にclient_idとclient_secretを表示されたその二つの値を、この環境変数の中に入れる。しかし今回、実際の値は使わない。とりあえず、いったんそこは後で説明する。
client.exchange_code_for_token(code)
この部分がそのままではあるが、codeをtokenと交換している。
tokenは上記したようにgithubAPIが生成した一時的なものでしかない。
そして、もしも、返ってきたresponseがエラーの場合はres.errorで取り出すことができるので、errorが入っていた場合にのみエラーをraiseする。
これでいったん、テストを実行する。
404 - Error: Not Found
おそらく404が吐き出される。これはGITHUB_CILENT_IDとGITHUB_CILENT_SECRETの中身がからだから。
しかし、これはテストなのでここで本当の値を入れるわけにはいかない。
できるだけ、テストはネットワーク環境などを排除して、テストのみで完結するようにするのが理想とされている。
mock実装
そこでテストがわでモックを使う。モックとはgithubの通信の代わりとなるものをこちら側で作成して、テストで完結させるためのもの。
context 'when code is incorrenct' do
before do
allow_any_instance_of(Octokit::Client).to receive(
:exchange_code_for_token).and_return(error)
end
そこでこのようにbeforeを使い、allow_any_instance_ofというメソッドを使う。
allow_any_instance_of(インスタンス名).to receive(:メソッド名).and_return(返り値)
このようにして使う。これを使って、指定したインスタンスの指定したメソッドが呼び出されたときの返り値を指定することができる。
Octokit::Clientのインスタンスからexchange_code_for_tokenメソッドを呼び出した時にerrorが返る。
その返り値のerrorを定義する。
context 'when code is incorrenct' do
let(:error) {
double("Sawyer::Resource", error: "bad_verification_code")
}
doubleはモックを生成する時のメソッド。
Sawyer::Resourceはクラス名で、そのクラスのメソッドとして、errorを使うことができる。
実際のエラーを忠実に再現することができる。
これでテストを実行すると一つめが成功するが、もう一つは失敗する。
404なので、先ほどと同じ。
二つ目のテストもさっきのモックと同じ要領で定義していく。
context 'when code is correct' do
before do
allow_any_instance_of(Octokit::Client).to receive(
:exchange_code_for_token).and_return('validaccesstoken')
end
しかし今度は、errorを出すのではなく、validaccesstokenを返す。実際に何か意味がある文字列ではないが、errorではないという意味でこの値でも、テストとしては十分有効なtokenとして機能する。
テストを実行。
undefined method `error' for "validaccesstoken":String
というメッセージが出る。
これは
if res.error.present?
この部分のことだが、resにerrorがない時にもerrorを読み込もうとしているのでエラーが出た。
なので、errorがない時はnilを返すように書く。
if res.try(:error).present?
これでテストを実行する。
expected User.count to have changed by 1, but was changed by 0
まだ保存する操作を書いていないので正常なメッセージだと言える。
なので、データを保存していく処理を書いていく。
#perform 保存処理実装
client = Octokit::Client.new(
client_id: ENV['GITHUB_CILENT_ID'],
client_secret: ENV['GITHUB_CILENT_SECRET'],
)
token = client.exchange_code_for_token(code)
if token.try(:error).present?
raise AuthenticationError
else
user_client = Octokit::Client.new(
access_token: token
)
user_data = user_client.user.to_h
slice(:login, :avatar_url, :url, :name)
User.create(user_data.merge(provider: 'github'))
end
このように書き換える。
codeと交換して返ってきたtokenを使って、githubuserのインスタンスを作る。
user_client = Octokit::Client.new(
access_token: token
)
上記のこの部分だが、loginとpasswordを使ってインスタンスを生成するのと同じことをしている。tokenを使ってもloginとpasswordを使ってもどちらも同じ結果が出力される。
// ただのサンプルなので実際に打たなくても良い
$ client = Octokit::Client.new(login: ENV['GITHUB_LOGIN'], password: ENV['GITHUB_PASSWORD'])
$ client.user
この記事の最初の方で、このようなコマンドをコンソールで打ったが、これと全く同じことをしている。実際にclient.userをするとgithubuserのデータを取得できる。しかし、形式が、Sawyer::Resourceというもので、非常に扱いづらい。なので、一度to_hでハッシュに変換してからsliceメソッドで中身を取り出している。そしてそのままcreateメソッドを使ってdatabaseに保存している。providerをmergeしているのは、providerは取り出したデータの中にはないので、自分でつける必要がある。もしつけなかったらvalidationに引っかかる。
ついでにだが、resをtokenに変更しておいた。実際にロジック的にどういう意味を持つかを変数名にする方が好ましいから。
そしてテストを実行する。
401 - Bad credentials
次はこのようなメッセージがかえる。
401はログインなどができなかったりする場合に返ってくるエラーのよう
しかし今回はただのモックで作ったインスタンスなので、実際に認証をするができている必要はない。
user_data = user_client.user.to_h.
slice(:login, :avatar_url, :url, :name)
現在このuser_client.userの部分でエラーが起きている。
なので、user_client.userをした時にどう返すかというものをモックで再現する。
allow_any_instance_of(Octokit::Client).to receive(
:exchange_code_for_token).and_return('validaccesstoken')
allow_any_instance_of(Octokit::Client).to receive(
:user).and_return(user_data)
end
したの:userの方を追加する。そして、変数のuser_dataを追加する。
context 'when code is correct' do
let(:user_data) do
{
login: 'a.levine 1',
url: 'http://example.com',
avatar_url: 'http://example.com/avatar',
name: 'Adam Levine'
}
end
これで、テストを実行して成功する。
ついでに、保存されている値が正しいかも確認しておく。
allow_any_instance_of(Octokit::Client).to receive(
:exchange_code_for_token).and_return('validaccesstoken')
allow_any_instance_of(Octokit::Client).to receive(
:user).and_return(user_data)
end
it 'should save the user when does not exists' do
expect{ subject }.to change{ User.count }.by(1)
expect(User.last.name).to eq('Adam Levine')
end
一番下の行を追加しておく。
これでテストを実行して通ることを確認する。
しかし、毎回新しいuserを生成しているが、一度createしたuserは使いまわしたい。当たり前だが、毎回新規登録をするようなものなので、効率が悪い。
なので使いまわせるようにコードを記述していく。
一度保存したuserを使いまわす
まずはテストから書いていく。
it 'should reuse already registerd user' do
user = create :user, user_data
expect{ subject }.not_to change{ User.count }
expect(authenticator.user).to eq(user)
end
一度userを作って、それと同じuser_dataを使って、authenticator.performを行う。
そして、そのauthenticator.performをして作ったuserとfactorybotで作ったuserが同じものかを確認する。
テストを実行して、失敗することを確認する。今はまだ使い回すのではなく、毎回createをしている。
なので、使いまわせるように記述していく。
- User.create(user_data.merge(provider: 'github'))
+ @user = if User.exists?(login: user_data[:login])
+ User.find_by(login: user_data[:login])
+ else
+ User.create(user_data.merge(provider: 'github'))
+ end
このように書き換える。もし同じuserが存在している時はfind_byを使うという分岐を作る。
テストを実行すると成功する。
リファクタリング
しかし現時点だと、performメソッドの記述量が多すぎることと、performメソッドの責任が曖昧になっている。performメソッドはいわゆる実行、という意味を持つので、実行するためだけのメソッドであることが好ましい。なので、値を生成したり、整えたりしているロジックを別のメソッドに書き出す。
def perform
- client = Octokit::Client.new(
- client_id: ENV['GITHUB_CILENT_ID'],
- client_secret: ENV['GITHUB_CILENT_SECRET'],
- )
- token = client.exchange_code_for_token(code)
if token.try(:error).present?
raise AuthenticationError
else
- user_client = Octokit::Client.new(
- access_token: token
- )
- user_data = user_client.user.to_h.
- slice(:login, :avatar_url, :url, :name)
- @user = if User.exists?(login: user_data[:login])
- User.find_by(login: user_data[:login])
- else
- User.create(user_data.merge(provider: 'github'))
- end
+ prepare_user
end
この部分をざくりと削除して他の場所に移していく。移す場所はprivateメソッドで定義する。理由は別に外部のクラスから呼び出す必要のない値を定義するから。
private
+ def client
+ @client ||= Octokit::Client.new(
+ client_id: ENV['GITHUB_CILENT_ID'],
+ client_secret: ENV['GITHUB_CILENT_SECRET'],
+ )
+ end
+
+ def token
+ @token ||= client.exchange_code_for_token(code)
+ end
+
+ def user_data
+ @user_data ||= Octokit::Client.new(
+ access_token: token
+ ).user.to_h.slice(:login, :avatar_url, :url, :name)
+ end
+
+ def prepare_user
+ @user = if User.exists?(login: user_data[:login])
+ User.find_by(login: user_data[:login])
+ else
+ User.create(user_data.merge(provider: 'github'))
+ end
+ end
attr_reader :code
end
こんな感じで書きだす。下のメソッドが上のメソッドを呼び出すという構造になっていて、きれいに責任を分離している。
これで、テストを実行して失敗しないことを確認する。
これでいったんリファクタリングは終わり。
次にいく。
User認証用のtoken生成
次はこの今作っているrailsapi専用のaccess_tokenを作っていく。
exchange_code_for_tokenメソッドを使って手に入るtokenはあくまでもgithubAPIにアクセスしてuser情報を取得するためのtokenなのでそれを僕たちが作っているrailsAPIのリクエストを認証するために使うことはできない。
今からは今作っているrailsAPIのリクエスト認証をするためのtokenを作っていく。このtokenが必要になるのは、createアクションをするときや、deleteアクションをする時に必要になる。逆に、indexアクションやshowアクションをする時はtokenがなくてもリクエストを受け付けるようにする。
しかしそれはそれぞれアプリケーション次第ではある。
token生成のテスト
ではそのtokenを作っていくのだが、まずはテストから書いていく。
it "should create and set user's access token" do
expect{ subject }.to change{ AccessToken.count }.by(1)
expect(authenticator.access_token).to be_present
end
末尾のこのテストを追加。
そして、その後に、performメソッドを編集していく。
else
prepare_user
+ @access_token = if user.access_token.present?
+ user.access_token
+ else
+ user.create_access_token
+ end
end
このように、tokenをインスタンスのattributeとしておく。
attr_reader :user, :access_token
さらにaccess_tokenを呼び出せるようにしておく。
とりあえず説明はのちに詳しくする。
AccessTokenモデル生成
$ rails g model access_token token user:references
とりあえず、access_tokenモデルを作成していく。
これにより、belongs_to :userを持ったaccess_tokenモデルが作成される。
userモデルの方にもアソシエーションを設定する。
class User < ApplicationRecord
validates :login, presence: true, uniqueness: true
validates :provider, presence: true
has_one :access_token, dependent: :destroy # 追加
end
class CreateAccessTokens < ActiveRecord::Migration[6.0]
def change
create_table :access_tokens do |t|
t.string :token, null: false
t.references :user, null: false, foreign_key: true
t.timestamps
end
end
end
migrationファイルも確認しておく、tokenにはnill: falseをつけておく。
rails db:migrate
を実行。
次にaccesstokenのテストも準備しておく。
require 'rails_helper'
RSpec.describe AccessToken, type: :model do
describe '#validations' do
it 'should have valid factory' do
end
it 'should validate token' do
end
end
end
諸々準備ができたのでテストを実行する。
$ rspec spec/lib/user_authenticator_spec.rb
SQLite3::ConstraintException: NOT NULL constraint failed: access_tokens.token
するとこのようなメッセージが吐かれる。
このエラーは、databaseレベルでのnull: falseをつけているのに、nullだった場合に起こるようだ。
ではnullにならないようにtokenを生成するロジックを書く。その前にテストを書く。
describe '#new' do
it 'should have a token present after initialize' do
expect(AccessToken.new.token).to be_present
end
it 'should generate uniq token' do
user = create :user
expect{ user.create_access_token }.to change{ AccessToken.count }.by(1)
expect(user.build_access_token).to be_valid
end
end
このコードを末尾に追加する。
一つ目は、AccessTokenをnewした時に、ちゃんと、tokenが入っているかどうか
後で記述するが、newした時に自動的にtokenが入るように後で書く。
二つ目は、AccessTokenのcountが1増えるかどうかと、
validationにひっからないかどうか。
validationにひっからないかどうかだが、いつもならモデルをcreateして、二つ目に一つ目の値を使って、buildをして、validationにちゃんとひっかるかどうかを確認するが、今回は少し特殊でnewした時にtokenが自動生成されるので、そのテストはできない。なぜならAccessToken.new(old_token)のように引数を指定することができないから。AccessToken.newとすれば、tokenは自動ではいる。
token生成ロジック実装
ではtokenを生成するロジックを書いていく。
class AccessToken < ApplicationRecord
belongs_to :user
after_initialize :generate_token
private
def generate_token
loop do
break if token.present? && !AccessToken.exists?(token: token)
self.token = SecureRandom.hex(10)
end
end
end
after_inializeで指定したメソッドは、モデルが作成される時に実行される。
loopで回しているのはbreak ifで指定した条件に当てはまらない限り何度でもtokenを作成したいから。
SecureRandomクラスを使ってtokenを生成する。
値はランダムで作成されるので、全く同じ値が生成されないとは限りません。なのでloopさせる。
breakの条件はtokenに値が入っている。かつ、databaseに同じ値が存在していない。
それが当てはまらない限りは何度でもloopする。大抵は一度回ればbreakされる。
テストを実行。
$ rspec spec/models/access_token_spec.rb
$ rspec spec/lib/user_authenticator_spec.rb
このテストが通ることを確認する。
ちなみに、user_authenticator.rbでのuser.create_access_token
このメソッドはどこかで定義したわけではなく、railsが自動生成してくれるもの。意味はそのままだが、わかりやすく置き換えると、
AccessToken.create(user_id: user.id)
これと同じ意味になる。
では、token生成のロジックが終わったので、次に行く。
ログイン機能
次はログイン機能の全体像を実装していく。今はtokenを生成する仕組みはできているが、まだそのtokenを利用したログイン機能を実装はできていない。なのでそのあたりを実装していく。
エンドポイントのテスト
しかしまずはテストから書く。今はroutingがまだできていないので、routingのテストから書いていく。
記述するファイルはないので作成する。
require 'rails_helper'
describe 'access tokens routes' do
it 'should route to access_tokens create action' do
expect(post '/login').to route_to('access_tokens#create')
end
end
記述の説明は割愛。
テストを実行すると、no route match /loginと出るので、routes.rbを編集する。
Rails.application.routes.draw do
+ post 'login', to: 'access_tokens#create'
resources :articles, only: [:index, :show]
end
テスト実行。
A route matches "/login", but references missing controller: AccessTokensController
controllerがないと言われているので、作っていく。
access_tokens_controller 生成
$ rails g controller access_tokens
create app/controllers/access_tokens_controller.rb
invoke rspec
create spec/requests/access_tokens_request_spec.rb
再度テスト実行。テストが通る。これでログインのエンドポイントの設置は終了。
access_tokens_controllerのテスト
ではcontrollerのテストをしていく。次のファイルを作成して記述する。
require 'rails_helper'
RSpec.describe AccessTokensController, type: :controller do
describe '#create' do
context 'when invalid request' do
it 'should return 401 status code' do
post :create
expect(response).to have_http_status(401)
end
end
context 'when success request' do
end
end
end
認証をせずに401が返ってくることを期待する。401はunauthorized(不許可)、だが、意味的にはunauthenticated(未認証)であるので、認証がされていない時のレスポンスとして用いることが多い。
今更ではあるが、rails g controllerをするとrequests/access_tokens_request_spec.rbのようなファイルが自動生成されている。これはcontrollerのテストの後継となるものだが、controller_specと書き方が少し変わってくるので、今回はわざわざ自分でファイルを作成して記述していいる。本来はrequest_specで書く方が推奨されている。
テストを実行する。
AbstractController::ActionNotFound: The action 'create' could not be found for AccessTokensController
createアクションが定義されていないので、記述する。
class AccessTokensController < ApplicationController
def create
end
end
テストを実行。401を期待しているが204が返って来ている。
204は:no_contentのこと。
なのでとりあえず、テストを通すために、controllerに記述していく。
create実装
class AccessTokensController < ApplicationController
def create
render json: {}, status: 401
end
end
テストを実行して通ることを確認。
さらにテストを追記していく。
context 'when invalid request' do
+ let(:error) do
+ {
+ "status" => "401",
+ "source" => { "pointer" => "/code" },
+ "title" => "Authentication code is invalid",
+ "detail" => "You must privide valid code in order to exchange it for token."
+ }
+ end
it 'should return 401 status code' do
post :create
expect(response).to have_http_status(401)
end
+ it 'should return proper error body' do
+ post :create
+ expect(json['errors']).to include(error)
+ end
end
401の場合に正しいerrorのresが返ってくることを期待する。
error文は以下のサイトからコピーして来たものを編集して使っている。
https://jsonapi.org/examples/
そして、テストを実行。
expected: {"detail"=>"You must privide valid code in order to exchange it for token.", "source"=>{"pointer"=>"/code"}, "status"=>"401", "title"=>"Authentication code is invalid"}
got: nil
nilが返って来ているので、controlle側できちんとerrorを返す処理を書く。
class AccessTokensController < ApplicationController
def create
error = {
"status" => "401",
"source" => { "pointer" => "/code" },
"title" => "Authentication code is invalid",
"detail" => "You must privide valid code in order to exchange it for token."
}
render json: { "errors": [ error ] }, status: 401
end
end
これでテストが通ることを確認する。
現在はcreateアクションが呼び出され時に全てにおいてerrorを出しているが、それを修正する。
class AccessTokensController < ApplicationController
rescue_from UserAuthenticator::AuthenticationError, with: :authentication_error
def create
authenticator = UserAuthenticator.new(params[:code])
authenticator.perform
end
private
def authentication_error
error = {
"status" => "401",
"source" => { "pointer" => "/code" },
"title" => "Authentication code is invalid",
"detail" => "You must privide valid code in order to exchange it for token."
}
end
リファクタリングもかねて、コードを編集している。
ここでやっとUserAuthenticator.new(params[:code])
を書くことになる。
これまでずっと書いていた、codeとtokenを交換して、userを作成するロジックがUserAuthenticatorには書かれているが、それをここで呼び出す。
そして、performで実行する。
401エラーの本体はメソッドに書き出している。
現時点で想定される返ってくるerrorはUserAuthenticator::AuthenticationError
なので、rescue_fromによって、rescueする。メソッドに書き出しているので、rescue_fromで呼び出す操作が可能。
後、UserAuthenticator::AuthenticationErrorではcodeがblankの時も同じようにエラーを出したい。
ついでに、リファクタリングが必要なのでしていく。
リファクタリングと修正
def perform
raise AuthenticationError if code.blank? || token.try(:error).present?
prepare_user
@access_token = if user.access_token.present?
user.access_token
else
user.create_access_token
end
end
これでcodeがblankの時はerrorを出すことができる。
もう一度おさらいしておくとcodeはフロントエンドから送られてくるtokenのこと。フロントエンドがgithubからtokenを取得して来てくれてそれをapiに送ってくる。それがcode(github_access_code)。
APIはそのcodeを受け取ってGitHubと通信を行いcodeをtokenと交換してもらう(exchange_code_for_tokenメソッドによって)。そのtokenによって、githubuserの情報をgithubAPIから取得することができる。
それを踏まえた上で、codeは十分にblankである可能性は考えられるので、errorを用意しておく。
テストを実行して通ることを確認。
さらにリファクタリングをする。
class AccessTokensController < ApplicationController
- rescue_from UserAuthenticator::AuthenticationError, with: :authentication_error
def create
authenticator = UserAuthenticator.new(params[:code])
authenticator.perform
end
- private
-
- def authentication_error
- error = {
- "status" => "401",
- "source" => { "pointer" => "/code" },
- "title" => "Authentication code is invalid",
- "detail" => "You must privide valid code in order to exchange it for token."
- }
- render json: { "errors": [ error ] }, status: 401
- end
end
class ApplicationController < ActionController::API
+ rescue_from UserAuthenticator::AuthenticationError, with: :authentication_error
+ private
+ def authentication_error
+ error = {
+ "status" => "401",
+ "source" => { "pointer" => "/code" },
+ "title" => "Authentication code is invalid",
+ "detail" => "You must privide valid code in order to exchange it for token."
+ }
+ render json: { "errors": [ error ] }, status: 401
+ end
end
完全にauthentication_errorはapplication_controllerに任せてしまい、全てのコントローラーでこのエラーを拾って来れるようにする。理由は認証エラーというのはどのコントローラーでも起きる可能性があるから。
テストを実行して、挙動が何も変わっていないことを確認する。
そして、この実装はテストでも同じように使いまわせるとなお良い。
説明が長くなってしまうので、コードはひとまず全ての変更を貼り付ける
RSpec.describe AccessTokensController, type: :controller do
describe '#create' do
- context 'when invalid request' do
+ shared_examples_for "unauthorized_requests" do
let(:error) do
{
"status" => "401",
@ -11,17 +11,34 @@ RSpec.describe AccessTokensController, type: :controller do
"detail" => "You must privide valid code in order to exchange it for token."
}
end
it 'should return 401 status code' do
- post :create
+ subject
expect(response).to have_http_status(401)
end
it 'should return proper error body' do
- post :create
+ subject
expect(json['errors']).to include(error)
end
end
+ context 'when no code privided' do
+ subject { post :create }
+ it_behaves_like "unauthorized_requests"
+ end
+ context 'when invalid code privided' do
+ let(:github_error) {
+ double("Sawyer::Resource", error: "bad_verification_code")
+ }
+ before do
+ allow_any_instance_of(Octokit::Client).to receive(
+ :exchange_code_for_token).and_return(github_error)
+ end
+ subject { post :create, params: { code: 'invalid_code' } }
+ it_behaves_like "unauthorized_requests"
+ end
context 'when success request' do
end
何をしているかはコードをじっくりと読んで欲しいのだが、ここでは二つのテストをshared_examples_forによって使いまわしている。
should return 401 status code
should return proper error body
この二つのテストは今後も使い回すことが多い。また、shared_examples_forを呼び出すには、it_behaves_likeを使って呼び出すことができる。
subjectを使って、DRYにすることで、subjectにはcontextごとに自由に値を入れることができる。
let(:github_error) {
double("Sawyer::Resource", error: "bad_verification_code")
}
before do
allow_any_instance_of(Octokit::Client).to receive(
:exchange_code_for_token).and_return(github_error)
end
また、この部分だが、この記述は以前もテストで使ったもので、githubAPIに直接接続することなく、mockで再現している。これにより、githubに実際に接続しなくともgithubAPIを再現することができる。
次はcodeが正しい時のテストを書いていく。
context 'when success request' do
let(:user_data) do
{
login: 'a.levine 1',
url: 'http://example.com',
avatar_url: 'http://example.com/avatar',
name: 'Adam Levine'
}
end
before do
allow_any_instance_of(Octokit::Client).to receive(
:exchange_code_for_token).and_return('validaccesstoken')
allow_any_instance_of(Octokit::Client).to receive(
:user).and_return(user_data)
end
subject { post :create, params: { code: 'valid_code' } }
it 'should return 201 status code' do
subject
expect(response).to have_http_status(:created)
end
end
これは単純にmockでcodeが正しいか正しくないかを操作している。
単純にcodeが正しい場合は201が返ってくることを期待している。
テストを実行。
expected the response to have status code :created (201) but it was :no_content (204)
このメッセージが表示される
なので、responseで201が返ってくるようにcontrollerを編集する。
def create
authenticator = UserAuthenticator.new(params[:code])
authenticator.perform
render json: {}, status: :created
end
renderを追加し、createdを返す。
これで再びテストを実行し、通ることを確認。
次にしっかりとresponseを返すように実装したい。なので、テストから書いていく。
it 'should return proper json body' do
expect{ subject }.to change{ User.count }.by(1)
user = User.find_by(login: 'a.levine 1')
expect(json_data['attributes']).to eq(
{ 'token' => user.access_token.token }
)
end
このテストを末尾に追加。
テストの内容は、articleの時と同じように、json_data['attributes']で値を受け取り中身が正しいかを確認する。User.find_byで取り出しているuserは前に記述したuser_dataを使ったmockによって記述されているので、その値と、responseとして返ってくる値は同じである、というテスト。
しかしテストを実行しても、json_dataでは取り出せていない、理由はserializerを使っていないから、json.dataが存在しないから。なので、きちんと整った形式でのresponseをするためにserializerを導入していく。
serializer生成
$ rails g serializer access_token
これにより、作られるファイルに記述していく。
class AccessTokenSerializer < ActiveModel::Serializer
attributes :id, :token
end
tokenの記述を足しておく。これにより、responseにtokenを含めることができる。
そしてcontrollerにもrenderで返す値を指定しておく。
- render json: {}, status: :created
+ render json: authenticator.access_token, status: :created
end
これにより、からのハッシュではなく、しっかりと整形されたresponseが返せるようになる。
テストを実行。するとメッセージが出る。
expected: {"token"=>"6c7c4213cb78c782f6f6"}
got: {"token"=>"2e4c724d374019f3fb26"}
どこかで、tokenが再度作られてしまい、値が切り替わっている。
これはリロードをすると、tokenがその度に作られてしまっているというバグ。
なので、そのバグ修正のためのテストを書いていく。
it 'should generate token once' do
user = create :user
access_token = user.create_access_token
expect(access_token.token).to eq(access_token.reload.token)
end
まずは、バグが再現できているかどうかを確認するために、テストを実行する。
expected: "3afe2f824789a229014c" got: "c5e04c73aa7ff89fd0a1"
きちんと再現できているので、メッセージが出た。
では改善していく。まず、バグが起きているgenerate_tokenメソッドを見てみる。
def generate_token
loop do
break if token.present? && !AccessToken.exists?(token: token)
self.token = SecureRandom.hex(10)
end
end
ここでおかしいところがある、問題はbreakの条件がいけなかった。
break if token.present? && !AccessToken.exists?(token: token)
この条件は、tokenにしっかりと値が入っている。かつそのtokenはデータベースに存在しない。という条件になる。しかしそれだと、少し矛盾したことになってしまう。tokenが存在しているということはデータベースに保存しているということなので、この条件式は満たされることがない。
なので、指定したtoken以外のtokenで同じtokenを持っているものが存在しないという条件にしていく。
- break if token.present? && !AccessToken.exists?(token: token)
+ break if token.present? && !AccessToken.where.not(id: id).exists?(token: token)
このように今指定のtoken以外のtokenという条件を作ることができる。
これで、テストを実行して通ることを確認する。
ログアウト機能
エンドポイントの追加テスト
それでは、ログアウト機能を実装していく。
it 'should route to acces_tokens destroy action' do
expect(delete '/logout').to route_to('access_tokens#destroy')
end
routingのテストを書く。
Rails.application.routes.draw do
post 'login', to: 'access_tokens#create'
delete 'logout', to: 'access_tokens#destroy'
resources :articles, only: [:index, :show]
end
logoutの行を追加。
テストが通る。
実装
次にコントローラーのテストを書いていく。
@@ -1,9 +1,9 @@
require 'rails_helper'
RSpec.describe AccessTokensController, type: :controller do
- describe '#create' do
+ describe 'POST #create' do
shared_examples_for "unauthorized_requests" do
- let(:error) do
+ let(:authentication_error) do
{
"status" => "401",
"source" => { "pointer" => "/code" },
@ -19,7 +19,7 @@ RSpec.describe AccessTokensController, type: :controller do
it 'should return proper error body' do
subject
- expect(json['errors']).to include(error)
+ expect(json['errors']).to include(authentication_error)
end
end
@ -74,4 +74,33 @@ RSpec.describe AccessTokensController, type: :controller do
end
end
end
+ describe 'DELETE #destroy' do
+ context 'when invalid request' do
+ let(:authorization_error) do
+ {
+ "status" => "403",
+ "source" => { "pointer" => "/headers/authorization" },
+ "title" => "Not authorized",
+ "detail" => "You have no right to access this resource."
+ }
+ end
+
+ subject { delete :destroy }
+
+ it 'should return 403 status code' do
+ subject
+ expect(response).to have_http_status(:forbidden)
+ end
+
+ it 'should return proper error json' do
+ subject
+ expect(json['errors']).to include(authorization_error)
+ end
+ end
+
+ context 'when valid request' do
+
+ end
+ end
end
元々errorとして扱っていた403エラーだが、役割をはっきりさせるために、命名を変更。
そして、destroy専用のテストを丸々書いていく。
内容は読んだまま。
@@の表記は何行の記述かを表しているコード、実際に書く必要はない。
そして、controllerを実装していく。
def destroy
raise AuthorizationError
end
destroyメソッドを定義する。まずはエラーのレスポンスのテストを通すために、AuthorizationErrorをraiseして、application_controllerに実際にそのエラーの実態を定義していく。
class ApplicationController < ActionController::API
+ class AuthorizationError < StandardError; end
rescue_from UserAuthenticator::AuthenticationError, with: :authentication_error
+ rescue_from AuthorizationError, with: :authorization_error
private
@ -12,4 +14,14 @@ class ApplicationController < ActionController::API
}
render json: { "errors": [ error ] }, status: 401
end
+ def authorization_error
+ error = {
+ "status" => "403",
+ "source" => { "pointer" => "/headers/authorization" },
+ "title" => "Not authorized",
+ "detail" => "You have no right to access this resource."
+ }
+ render json: { "errors": [ error ] }, status: 403
+ end
end
エラーの内容はテストに書いたものと同じ。
これでテストを実行して、通ることを確認する。
しかし少し重複している記述があるのでDRYにしていく。
describe 'DELETE #destroy' do
shared_examples_for 'forbidden_requests' do
end
まず、describeの下にshared_examples_forを使って、記述をまとめていく。
shared_examples_forの中に、入れるのは以下の記述。
shared_examples_for 'forbidden_requests' do
let(:authorization_error) do
{
"status" => "403",
"source" => { "pointer" => "/headers/authorization" },
"title" => "Not authorized",
"detail" => "You have no right to access this resource."
}
end
it 'should return 403 status code' do
subject
expect(response).to have_http_status(:forbidden)
end
it 'should return proper error json' do
subject
expect(json['errors']).to include(authorization_error)
end
end
今まで記述していたテストを一つにまとめる。
context 'when invalid request' do
subject { delete :destroy }
it_behaves_like 'forbidden_requests'
end
そしてshared_expample_forを呼び出すのはit_behaves_likesという記述なので、これで文字列でさっき指定したforbidden_requestsを呼び出す。
これでさっきと同じ環境を作り出す事ができたので再度実行して、テストが通ることを確認する。
次にこれらのshared_example_forを使いまわせるようにさらに一つのファイルにまとめていく。今のaccess_tokens_controller_spec.rb
にはshared_example_forが二つ存在しているのでその二つを同じファイルにまとめていく。
spec/support/shared/json_errors.rb
を作成
中にshared_example_forの記述を入れていく。
require 'rails_helper'
shared_examples_for 'forbidden_requests' do
let(:authorization_error) do
{
"status" => "403",
"source" => { "pointer" => "/headers/authorization" },
"title" => "Not authorized",
"detail" => "You have no right to access this resource."
}
end
it 'should return 403 status code' do
subject
expect(response).to have_http_status(:forbidden)
end
it 'should return proper error json' do
subject
expect(json['errors']).to include(authorization_error)
end
end
shared_examples_for "unauthorized_requests" do
let(:authentication_error) do
{
"status" => "401",
"source" => { "pointer" => "/code" },
"title" => "Authentication code is invalid",
"detail" => "You must privide valid code in order to exchange it for token."
}
end
it 'should return 401 status code' do
subject
expect(response).to have_http_status(401)
end
it 'should return proper error body' do
subject
expect(json['errors']).to include(authentication_error)
end
end
そして、切り取り元の記述は全て消しておく。
describe 'DELETE #destroy' do
subject { delete :destroy }
subject定義のネストを一段上げておく。
そして、テストを二つ追加する。
describe 'DELETE #destroy' do
subject { delete :destroy }
context 'when no authorization header provided' do
it_behaves_like 'forbidden_requests'
end
context 'when invalid authorization header provided' do
before { request.headers['authorization'] = 'Invalid token' }
it_behaves_like 'forbidden_requests'
end
context 'when valid request' do
end
end
このテストは、subjectを書かないのは、既にshared_example_forにsubject書いてあるので、自動でsubject { delete :destroy }
が呼び出されるようになっている。
そして、beforeを使えば、requestの中身を編集する事ができる。
今回はtokenをInvalid_tokenを入れておく事で、認証ができていないuserを作り上げる。
もちろん認証エラーが出るので、それを期待するテスト。
これでテストを実行して、成功することを確認する。
context 'when valid request' do
let(:user) { create :user }
let(:access_token) { user.create_access_token }
before { request.headers['authorization'] = "Bearer #{access_token.token}" }
it 'should return 204 status code' do
subject
expect(response).to have_http_status(:no_content)
end
it 'should remove the proper access token' do
expect{ subject }.to change{ AccessToken.count }.by(-1)
end
end
次に、when valid requestのテストを書いていく。
正しいリクエストを送るためにはまず、headers['authorization']
にトークンを入れて、アクセス権限を渡す必要がある。
Bearerとは無記名認証のことで、今回はこれを使う。
テストではAccessTokenモデルがデータベースから一つ減っていることを期待している。
では正しくテストが失敗することを確認する。
ここで、正しく失敗することを確認するとtypoが見つかる事が多い。
expected the response to have status code :no_content (204) but it was :forbidden (403)
テストを実行するとこのようなメッセージが表示される。
forbiddenが返って来ているのは、destroyアクションで常にエラーを返すように記述をしているから。
なので実際にdestroyアクションを実装していく。
def destroy
raise AuthorizationError
end
まずこのdestroyでしたいことはrequestを送って来たuserのaccess_tokenをdestroyすること。なので以下のように記述する。
def destroy
raise AuthorizationError unless current_user
current_user.access_token.destroy
end
current_userは現在ログインしているuserのことを指す。
current_userをどのようにして、持ってくるかを考える。
current_userはrequestから一気に取得する事ができない。しかし、request.authorizationとすると、さっきテストで送ったBearer xxxxxxxxxxxxxxxxxxxxx
というようなtokenを取得する事ができる。
なのでそのtokenを使って、current_userを取得していく。
def destroy
provided_token = request.authorization&.gsub(/\ABearer\s/, '')
access_token = AccessToken.find_by(token: provided_token)
current_user = access_token&.user
raise AuthorizationError unless current_user
current_user.access_token.destroy
end
まず、request.authorizationでtokenを取得し、データベースでそのtokenを検索するために、gsubメソッドを使って、正規表現で切り取りをしている。tokenの数字の部分だけが取り出せたら、それでAccessToken.find_byで検索をかけて、取り出している。
そして、そのaccess_token.userとすれば、requestを送って来たuserを取り出す事ができる。そして、そのtokenをdestroy
すればログアウトが完了する。
&.
の記述はボッチ演算子と言って、nilが帰って来て、undifind methodというふうになるかもしれない事があらかじめわかっているメソッドに対してつけておくと、nilの場合にエラーが出ずにそのままnilを返り値として返してくれるので、エラーが出ない。というもの。今回は、requestでInvalid_tokenが混ざっている場合があるので、その場合はnilが返ってしまうので、ボッチ演算子を使わないとエラーが出る。
これでテストを実行して、テストが全て通ることを確認する。
次にこのコードをリファクタリングしていく。
def destroy
- provided_token = request.authorization&.gsub(/\ABearer\s/, '')
- access_token = AccessToken.find_by(token: provided_token)
- current_user = access_token&.user
-
- raise AuthorizationError unless current_user
current_user.access_token.destroy
end
まず、このように記述を切り取る。そして、その記述をapplication_controller.rbに移していく。なぜうつすかというと、まさにこのrequestを受けとり、current_userを生成するロジックはどのコントローラーでも使いたい記述だから。
private
def authorize!
raise AuthorizationError unless current_user
end
def access_token
provided_token = request.authorization&.gsub(/\ABearer\s/, '')
@access_token = AccessToken.find_by(token: provided_token)
end
def current_user
@current_user = access_token&.user
end
そして、privateしたにこのようにメソッドを記述していく。
authorize!メソッドはcurrent_userが入っていない時に401エラーを出す。
access_tokenメソッドで正しいaccess_tokenを取り出し、
current_userメソッドで、そのtokenのuserを取り出している。
ここでaccess_tokenとcurrent_userを分けているのは、それぞれの役割をはっきりとさせ責任の分離を行うため。
そして、最後にその定義したauthorize!メソッドを常に呼び出せるように記述していく。
class AccessTokensController < ApplicationController
before_action :authorize!, only: :destroy
before_actionで常に呼び出している状況にしている。destroyのみの指定にしているのは、createアクションの時に呼び出してしまうと、呼び出し不可能なメソッドになってしまうため。
これらのアプローチは一般的だが、before_actionを書き忘れたり、もしくは記述があまりにも多くなってしまう。なので、skip_before_actionを使い、逆にskipするメソッドを指定しておく。基本的にauthorize!メソッドに限って言えばcreateさえskipしてしまえば良さそう。
before_action :authorize!
private
privateの上に常に呼び出す記述を追記。
class AccessTokensController < ApplicationController
skip_before_action :authorize!, only: :create
before_actionとメソッドを変更しておく。
class ArticlesController < ApplicationController
skip_before_action :authorize!, only: [:index, :show]
そして、article_controllerも忘れずにskipさせておく。
indexとshowは認証をしていなくても行いたいから。
これでテストを実行して、リファクタリング前と同じ結果が得られるかを確認する。
$ bundle exec rspec
全てのテストを実行して、全てが緑になることを確認する。
最後に
お疲れ様でした。これで最初に目標としていたuser認証機能を実装する事ができました。これらはdeviseというgemを使えば代用してしまえるかもしれませんが、その仕組みを知っているかどうかでuser認証周りの問題への対応が変わって来ますし、理解度が全く違うと思います。token周りは非常に想像しづらい部分ですし、oauthを使う場合は、やはり、gemで全てが代用されてしまうものもありますので、仕組みがブラックボックス化されてしまいがちです。なので今回はこのようにuser認証を行いました。