LoginSignup
1
1

More than 3 years have passed since last update.

RSpecによるTDDでRailsAPIを実装してみた。part3 -認証ありのアクション実装-

Last updated at Posted at 2020-06-04

はじめに

この記事は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エンドポイント追加

まずは、エンドポイントを追加していきます。そしてその前に一旦テストを書きます。

spec/routing/articles_spec.rb
  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を追加していきます

エンドポイント実装

config/routes.rb
  resources :articles, only: [:index, :show, :create]
$ bundle exec rspec spec/routing/articles_spec.rb

テストを実行して通ることを確認します。

そして、次はcontrollerのテストを書いていきます。

createアクション実装

spec/controllers/articles_controller_spec.rb
  describe '#create' do
    subject { post :create }
  end
end

この記述を末尾に追加します。

そして、part2で定義したforbidden_requestsを使って認証がうまくいかないときのテストも書いていきます

spec/controllers/articles_controller_spec.rb
  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アクションが見つからないというふうに言われているので、定義していきます。

app/controllers/articles_controller.rb
  def create

  end

これでもう一度テストを実行して全て通ることを確認します。
テストが通ったということはきちんと認証が効いていることを表しています。

では、createアクションを実装するためにテストを書いていきます。

spec/controllers/articles_controller_spec.rb
    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に実装をしていき、エラーを解消していきます。

app/controllers/articles_controller.rb
  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が正しい場合のテストを書いていきます。

spec/controllers/articles_controller_spec.rb
      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の実装をしていきます。

app/controllers/articles_controller.rb
  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しか取得しないというような条件を設けることで、この指定された形式以外では全て弾くようにしています。

これでテストを実行すると全て通ります。

さらに一つリファクタリングをします。

app/controllers/articles_controller.rb
  rescue
    render json: article, adapter: :json_api,
      serializer: ActiveModel::Serializer::ErrorSerializer,
      status: :unprocessable_entity
  end

このActiveModel::Serializer::ErrorSerializer,が長いので、これを他の場所で違うクラスに継承して、短く記述できるようにします。

app/serializers/error_serializer.rbを作成します

app/serializers/error_serializer.rb
class ErrorSerializer < ActiveModel::Serializer::ErrorSerializer; end

このように継承させます。

app/controllers/articles_controller.rb
  rescue
    render json: article, adapter: :json_api,
      serializer: ErrorSerializer,
      status: :unprocessable_entity
  end

そして、先ほどの長い記述をすっきりさせる事ができます。
一応テストを実行して、失敗していないかを確認します。

これでarticleをcreateするアクションの実装は完了です。

updateアクション

updateエンドポイント追加

ではエンドポイントの追加から再びしていきます。まずはテストを書いていきます。

spec/routing/articles_spec.rb
  it 'should route articles show' do
    expect(patch '/articles/1').to route_to('articles#update', id: '1')
  end

毎回のようにエンドポイントのテストを書いていきます。showアクションはhttpリクエストが、patchもしくはputなので、そのどちらかを使います。

テストを実行して、正しくエラーが出ることを確認します。

config/routes.rb
  resources :articles, only: [:index, :show, :create, :update]

updateを追加して、テストが通ることを確認します。

updateアクション追加

では次にcontroller#updateアクションのテストを書いていきます。

spec/controllers/articles_controller_spec.rb
  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を実際に定義していきます。

app/controllers/articles_controller.rb
  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を設定して、期待する値が返ってくることをテストしていきます。

spec/controllers/articles_controller_spec.rb
   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=

というようなメッセージで失敗します。これはアソシエーションができていない証拠なので、次にアソシエーションを設定していきます。

app/models/article.rb
  belongs_to :user
app/models/user.rb
  has_many :articles, dependent: :destroy

そして、二つのモデルをつなげるためにはarticleモデルにuser_idを持たせる必要があるので、追加します。

$ rails g migration AddUserToArticles user:references

$ rails db:migrate

これでアソシエーション自体は実装する事ができました。なので、それを使って、controllerの記述を変更していきます。

app/controllers/articles_controller.rb
  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
に持たせたいので、少し変更を加えます。

app/controllers/articles_controller.rb
   def create
-    article = Article.new(article_params)
+    article = current_user.articles.build(article_params)

そして、factorybotにもアソシエーションの記述を足していきます。

spec/factories/articles.rb
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エンドポイント追加

まずはエンドポイントを追加するためにテストを書いていきます。

spec/routing/articles_spec.rb
  it 'should route articles destroy' do
    expect(delete '/articles/1').to route_to('articles#destroy', id: '1')
  end

テストを実行すると以下のメッセージが出ます
No route matches "/articles/1"

なので、ルーティングを編集していきます。

config/routes.rb
  resources :articles

onlyオプションで指定せずに全てを設定します。
これでルーティングのテストは通ります。

次にcontrollerのテストを追加します。

spec/controllers/articles_controller_spec.rb
  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アクション追加

app/controllers/articles_controller.rb
  def destroy
    article = current_user.articles.find(params[:id])
    article.destroy
    head :no_content
  rescue
    authorization_error
  end

単純にcurrent_userの中の指定されたarticleをdestroyをしています。

これで、テストを実行します。

これで通ったら、全てが終了です。長い間お付き合いいただきありがとうございました!

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1