はじめに
この記事はpart3となります。もしも、part1, part2を見られていない方はそちらからご覧ください。(すごく長いです)
↓part1
https://qiita.com/yoshi_4/items/6c9f3ced0eb20131903d
↓part2
https://qiita.com/yoshi_4/items/963bd1f5397caf8d7d67
このpart3ではpart2で実装したuser認証を使って、createアクションなどの認証をしている場合のみに使えるアクションを実装していきます。今回のゴールはcreate, update, destroyアクションを実装する事です。では初めていきます。
createアクション
createエンドポイント追加
まずは、エンドポイントを追加していきます。そしてその前に一旦テストを書きます。
it 'should route articles create' do
expect(post '/articles').to route_to('articles#create')
end
createアクションははhttpリクエストがpostなので、getではなくpostで書いていきます。
$ bundle exec rspec spec/routing/articles_spec.rb
No route matches "/articles"
というふうに出るので、routingを追加していきます
エンドポイント実装
resources :articles, only: [:index, :show, :create]
$ bundle exec rspec spec/routing/articles_spec.rb
テストを実行して通ることを確認します。
そして、次はcontrollerのテストを書いていきます。
createアクション実装
describe '#create' do
subject { post :create }
end
end
この記述を末尾に追加します。
そして、part2で定義したforbidden_requestsを使って認証がうまくいかないときのテストも書いていきます
describe '#create' do
subject { post :create }
context 'when no code provided' do
it_behaves_like 'forbidden_requests'
end
context 'when invalid code provided' do
before { request.headers['authorization'] = 'Invalid token' }
it_behaves_like 'forbidden_requests'
end
context 'when invalid parameters provided' do
end
end
このforbidden_rquestsでは403が返ってくることを期待しているテストを実行します。
$ rspec spec/controllers/articles_controller_spec.rb
すると以下のようなメッセージが返って来ます
The action 'create' could not be found for ArticlesController
createアクションが見つからないというふうに言われているので、定義していきます。
def create
end
これでもう一度テストを実行して全て通ることを確認します。
テストが通ったということはきちんと認証が効いていることを表しています。
では、createアクションを実装するためにテストを書いていきます。
context 'when authorized' do
let(:access_token) { create :access_token }
before { request.headers['authorization'] = "Bearer #{access_token.token}" }
context 'when invalid parameters provided' do
let(:invalid_attributes) do
{
data: {
attributes: {
title: '',
content: '',
}
}
}
end
subject { post :create, params: invalid_attributes }
it 'should return 422 status code' do
subject
expect(response).to have_http_status(:unprocessable_entity)
end
it 'should return proper error json' do
subject
expect(json['errors']).to include(
{
"source" => { "pointer" => "/data/attributes/title" },
"detail" => "can't be blank"
},
{
"source" => {"pointer" => "/data/attributes/content"},
"detail" => "can't be blank"
},
{
"source" => {"pointer" => "/data/attributes/slug"},
"detail" => "can't be blank"
}
)
end
end
context 'when success request sent' do
end
end
テストを追加しました。いっぺんにたくさん追加しましたが、一つ一つは既にやって来たことと被っている部分も多いです。
追加したテストは、when authorized
なので、認証は成功した場合、をテストして来ます。テストしていく項目はそれぞれ、
when invalid parameters provided
should return 422 status code
should return proper error json
を追加しています。parameterが正しい場合は後で書きます。
parameterがからの場合、can't be blankが返ってくることを期待しています。
sourceのpointerはどこでエラーが出ているのかを示しています。今回は全てをからの文字列にしているので、全てからcan't be blankが返ってくることを期待しています。
テストを実行します。二つテストが失敗します。
expected the response to have status code :unprocessable_entity (422) but it was :no_content (204)
一つ目は、unprocessable(処理ができない)というレスポンスが返ってくることを期待していますが、no_contentが帰って来ています。no_contentはcreateaが正常に実行された時に返したいので、後で修正します。
unexpected token at ''
二つ目はJSON.parseはからの文字列ではエラーが出てしまうので、そのエラーです。
では、controllerに実装をしていき、エラーを解消していきます。
def create
article = Article.new(article_params)
if article.valid?
#we will figure that out
else
render json: article, adapter: :json_api,
serializer: ActiveModel::Serializer::ErrorSerializer,
status: :unprocessable_entity
end
end
private
def article_params
ActionController::Parameters.new
end
ActionController::Parametersのインスタンスを作成しているのは、これによって、StrongParameterが使えるからです。ActionController::Parametersのインスタンスメソッドである、permitや、requireが使えるようになります。permitやrequireを使えば、もしも形式的に期待しているものと違ったり、違うkeyで何かparameterが送られて来た時に、その不要な部分を切り捨てる事ができます。
renderにadapterを指定していますが、これは形式を指定しています。このadapterを指定しなかった場合は、defaultでattributesというものが指定されています。今回は、json_apiという方を使っています。以下はその違いを例で表示しています。Railsのactive_model_serializerについて学ぶ_100DaysOfCodeチャレンジ10日目(Day_10:#100DaysOfCode)からコピーさせてもらいました。
attributes
[
{
"id": 1,
"name": "中島 光",
"email": "rhianna_walsh@maggio.net",
"birthdate": "2016-05-02",
"birthday": "2016/05/02"
}
]
}
json_api
{
"data": [
{
"id": "1",
"type": "contacts",
"attributes": {
"name": "中島 光",
"email": "rhianna_walsh@maggio.net",
"birthdate": "2016-05-02",
"birthday": "2016/05/02"
}
}
]
}
今回はapiに適しているjson_apiを使います。
テストを実行し、通る事を確認します。
次にparameterが正しい場合のテストを書いていきます。
context 'when success request sent' do
let(:access_token) { create :access_token }
before { request.headers['authorization'] = "Bearer #{access_token.token}" }
let(:valid_attributes) do
{
'data' => {
'attributes' => {
'title' => 'Awesome article',
'content' => 'Super content',
'slug' => 'awesome-article',
}
}
}
end
subject { post :create, params: valid_attributes }
it 'should have 201 status code' do
subject
expect(response).to have_http_status(:created)
end
it 'should have proper json body' do
subject
expect(json_data['attributes']).to include(
valid_attributes['data']['attributes']
)
end
it 'should create article' do
expect { subject }.to change{ Article.count }.by(1)
end
end
正しいtokenと、正しいparameterを入れています。これでテストを実行します。
expected the response to have status code :created (201) but it was :unprocessable_entity (422)
undefined method `[]' for nil:NilClass
`Article.count` to have changed by 1, but was changed by 0
三つのテストがそれぞれこのように失敗すると思います。
これらは正しい失敗をしているので、実際にただしいparameterの場合のcontrollerの実装をしていきます。
def create
article = Article.new(article_params)
article.save!
render json: article, status: :created
rescue
render json: article, adapter: :json_api,
serializer: ActiveModel::Serializer::ErrorSerializer,
status: :unprocessable_entity
end
private
def article_params
params.requrie(:data).require(:attributes).
permit(:title, :content, :slug) ||
ActionController::Parameters.new
end
次にcreateをこのように編集していきます。
rescueを用いて、エラーが出た時に、renderでエラーを飛ばすようにしています。
article_paramsでは、:data
の中の:attributes
の中の:title,:content,:slug
しか取得しないというような条件を設けることで、この指定された形式以外では全て弾くようにしています。
これでテストを実行すると全て通ります。
さらに一つリファクタリングをします。
rescue
render json: article, adapter: :json_api,
serializer: ActiveModel::Serializer::ErrorSerializer,
status: :unprocessable_entity
end
このActiveModel::Serializer::ErrorSerializer,
が長いので、これを他の場所で違うクラスに継承して、短く記述できるようにします。
app/serializers/error_serializer.rb
を作成します
class ErrorSerializer < ActiveModel::Serializer::ErrorSerializer; end
このように継承させます。
rescue
render json: article, adapter: :json_api,
serializer: ErrorSerializer,
status: :unprocessable_entity
end
そして、先ほどの長い記述をすっきりさせる事ができます。
一応テストを実行して、失敗していないかを確認します。
これでarticleをcreateするアクションの実装は完了です。
updateアクション
updateエンドポイント追加
ではエンドポイントの追加から再びしていきます。まずはテストを書いていきます。
it 'should route articles show' do
expect(patch '/articles/1').to route_to('articles#update', id: '1')
end
毎回のようにエンドポイントのテストを書いていきます。showアクションはhttpリクエストが、patchもしくはputなので、そのどちらかを使います。
テストを実行して、正しくエラーが出ることを確認します。
resources :articles, only: [:index, :show, :create, :update]
updateを追加して、テストが通ることを確認します。
updateアクション追加
では次にcontroller#updateアクションのテストを書いていきます。
describe '#update' do
let(:article) { create :article }
subject { patch :update, params: { id: article.id } }
context 'when no code provided' do
it_behaves_like 'forbidden_requests'
end
context 'when invalid code provided' do
before { request.headers['authorization'] = 'Invalid token' }
it_behaves_like 'forbidden_requests'
end
context 'when authorized' do
let(:access_token) { create :access_token }
before { request.headers['authorization'] = "Bearer #{access_token.token}" }
context 'when invalid parameters provided' do
let(:invalid_attributes) do
{
data: {
attributes: {
title: '',
content: '',
}
}
}
end
it 'should return 422 status code' do
subject
expect(response).to have_http_status(:unprocessable_entity)
end
it 'should return proper error json' do
subject
expect(json['errors']).to include(
{
"source" => { "pointer" => "/data/attributes/title" },
"detail" => "can't be blank"
},
{
"source" => {"pointer" => "/data/attributes/content"},
"detail" => "can't be blank"
},
{
"source" => {"pointer" => "/data/attributes/slug"},
"detail" => "can't be blank"
}
)
end
end
context 'when success request sent' do
let(:access_token) { create :access_token }
before { request.headers['authorization'] = "Bearer #{access_token.token}" }
let(:valid_attributes) do
{
'data' => {
'attributes' => {
'title' => 'Awesome article',
'content' => 'Super content',
'slug' => 'awesome-article',
}
}
}
end
subject { post :create, params: valid_attributes }
it 'should have 201 status code' do
subject
expect(response).to have_http_status(:created)
end
it 'should have proper json body' do
subject
expect(json_data['attributes']).to include(
valid_attributes['data']['attributes']
)
end
it 'should create article' do
expect { subject }.to change{ Article.count }.by(1)
end
end
end
end
updateアクションがcreateアクションと違う部分は、リクエストの種類と既にデータベースにupdateの
対象となるarticleがある、という状況のみなので、最初にarticleを作成しているところと、リクエストを定義している部分以外はcreateのテストをコピーして来ているだけです。
これでテストを実行します。
The action 'update' could not be found for ArticlesController
このようなエラーが出ると思います。なので、updateを実際に定義していきます。
def update
article = Article.find(params[:id])
article.update_attributes!(article_params)
render json: article, status: :ok
rescue
render json: article, adapter: :json_api,
serializer: ErrorSerializer,
status: :unprocessable_entity
end
もはや目新しいことはないので、説明は割愛します。
これでテストを実行して全て通ることを確認します。
createとupdateの違いさえわかっていればほとんど違いがないという事がわかると思います。そして、テストもほとんど同じものを使い回す事ができます。
しかし、ここで少しだけ問題があります。それは、リクエスト次第で、誰のarticleでもupdateできてしまいます。勝手にupdateされては困ります。なのでそこを修正していきます。
どのように修正していくかというと、現時点、userとarticleが関連性を持っていないために、起きている問題なので、userとarticleにassociationを追加していきます。
その前にassociationを設定して、期待する値が返ってくることをテストしていきます。
describe '#update' do
+ let(:user) { create :user }
let(:article) { create :article }
+ let(:access_token) { user.create_access_token }
subject { patch :update, params: { id: article.id } }
@ -140,8 +142,17 @@ describe ArticlesController do
it_behaves_like 'forbidden_requests'
end
+ context 'when trying to update not owned article' do
+ let(:other_user) { create :user }
+ let(:other_article) { create :article, user: other_user }
+
+ subject { patch :update, params: { id: other_article.id } }
+ before { request.headers['authorization'] = "Bearer #{access_token.token}" }
+
+ it_behaves_like 'forbidden_requests'
+ end
context 'when authorized' do
- let(:access_token) { create :access_token }
before { request.headers['authorization'] = "Bearer #{access_token.token}" }
context 'when invalid parameters provided' do
let(:invalid_attributes) do
このようにテストを追加しました。userと繋がったarticleを作り、認証までしています。
新しく追加したテスト項目で何をしているかというと、他のuserのarticleをupdateしようとした時にちゃんとforbidden_requestsが返ってくるかどうかを確認しています。
これでテストを実行すると
undefined method user=
というようなメッセージで失敗します。これはアソシエーションができていない証拠なので、次にアソシエーションを設定していきます。
belongs_to :user
has_many :articles, dependent: :destroy
そして、二つのモデルをつなげるためにはarticleモデルにuser_idを持たせる必要があるので、追加します。
$ rails g migration AddUserToArticles user:references
$ rails db:migrate
これでアソシエーション自体は実装する事ができました。なので、それを使って、controllerの記述を変更していきます。
def update
article = current_user.articles.find(params[:id])
article.update_attributes!(article_params)
render json: article, status: :ok
rescue ActiveRecord::RecordNotFound
authorization_error
rescue
render json: article, adapter: :json_api,
serializer: ErrorSerializer,
status: :unprocessable_entity
end
記述で変わったところはfindするuserをcurrent_userで呼び出しているところです。これにより、ログインしているユーザーのみからfindする事ができます。
そして、指定されたidがcurrent_userのarticleになかった場合ActiveRecord::RecordNotFound
がraiseされるので、その時ようにrescueして、認証専用のauthorization_errorを出すようにしています。
また、createでも、誰のarticleをcreateするというふうに記述し、user_idをarticle
に持たせたいので、少し変更を加えます。
def create
- article = Article.new(article_params)
+ article = current_user.articles.build(article_params)
そして、factorybotにもアソシエーションの記述を足していきます。
FactoryBot.define do
factory :article do
sequence(:title) { |n| "My article #{n}"}
sequence(:content) { |n| "The content of article #{n}"}
sequence(:slug) { |n| "article-#{n}"}
association :user
end
end
association :model_name
とすると、自動的にモデルのidを定義してくれます。
これでテストを実行すると通ると思います。
次はdestroyアクションに移っていきます。
destroyアクション
destroyエンドポイント追加
まずはエンドポイントを追加するためにテストを書いていきます。
it 'should route articles destroy' do
expect(delete '/articles/1').to route_to('articles#destroy', id: '1')
end
テストを実行すると以下のメッセージが出ます
No route matches "/articles/1"
なので、ルーティングを編集していきます。
resources :articles
onlyオプションで指定せずに全てを設定します。
これでルーティングのテストは通ります。
次にcontrollerのテストを追加します。
describe '#delete' do
let(:user) { create :user }
let(:article) { create :article, user_id: user.id }
let(:access_token) { user.create_access_token }
subject { delete :destroy, params: { id: article.id } }
context 'when no code provided' do
it_behaves_like 'forbidden_requests'
end
context 'when invalid code provided' do
before { request.headers['authorization'] = 'Invalid token' }
it_behaves_like 'forbidden_requests'
end
context 'when trying to remove not owned article' do
let(:other_user) { create :user }
let(:other_article) { create :article, user: other_user }
subject { delete :destroy, params: { id: other_article.id } }
before { request.headers['authorization'] = "Bearer #{access_token.token}" }
it_behaves_like 'forbidden_requests'
end
context 'when authorized' do
before { request.headers['authorization'] = "Bearer #{access_token.token}" }
it 'should have 204 status code' do
subject
expect(response).to have_http_status(:no_content)
end
it 'should have empty json body' do
subject
expect(response.body).to be_blank
end
it 'should destroy the article' do
article
expect{ subject }.to change{ user.articles.count }.by(-1)
end
end
end
このテストのコードはほとんどがupdateのテストをコピーして使いまわしています。
内容は特に新しいことはありません。テストを実行します。
The action 'destroy' could not be found for ArticlesController
destroyアクションはまだ定義していないので、このエラーが正しいです。ではcontroller
を実装していきます。
destroyアクション追加
def destroy
article = current_user.articles.find(params[:id])
article.destroy
head :no_content
rescue
authorization_error
end
単純にcurrent_userの中の指定されたarticleをdestroyをしています。
これで、テストを実行します。
これで通ったら、全てが終了です。長い間お付き合いいただきありがとうございました!