[RUBY] I implemented Rails API with TDD by RSpec. part3-Action implementation with authentication-

Introduction

This article will be part3. If you haven't seen part1 and part2, please see there. (Very long)

↓part1 https://qiita.com/yoshi_4/items/6c9f3ced0eb20131903d ↓part2 https://qiita.com/yoshi_4/items/963bd1f5397caf8d7d67

In this part3, we will use the user authentication implemented in part2 to implement actions that can be used only when authentication such as create action is performed. The goal this time is to implement create, update and destroy actions. Let's go for the first time.

create action

create Add endpoint

First, we will add endpoints. And before that, write a test once.

spec/routing/articles_spec.rb


  it 'should route articles create' do
    expect(post '/articles').to route_to('articles#create')
  end

Since the http request is post for the create action, write it with post instead of get.

$ bundle exec rspec spec/routing/articles_spec.rb

No route matches "/articles"

Since it appears like this, I will add routing

Endpoint implementation

config/routes.rb


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

Run the test to make sure it passes.

And next, I will write a test for the controller.

create action implementation

spec/controllers/articles_controller_spec.rb


  describe '#create' do
    subject { post :create }
  end
end

Add this description to the end.

And I will write a test when authentication does not work using forbidden_requests defined in part2.

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

This forbidden_rquests runs a test that expects a 403 to be returned.

$ rspec spec/controllers/articles_controller_spec.rb

Then the following message will be returned The action 'create' could not be found for ArticlesController It is said that the create action cannot be found, so let's define it.

app/controllers/articles_controller.rb


  def create

  end

Now run the test again to make sure everything passes. If the test passes, it means that the certification is working properly.

Now let's write a test to implement the create action.

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

Added a test. I added a lot at once, but each one has many parts that have already arrived and are covered.

The added test is when authorized, so if the authentication is successful, we will test. Each item to be tested when invalid parameters provided should return 422 status code should return proper error json

Is being added. If the parameter is correct, I will write it later.

If parameter is from, I expect can't be blank to be returned. The source pointer shows where the error is occurring. This time, everything is a string from, so I expect that can't be blank will be returned from everything.

Run the test. Two tests fail. expected the response to have status code :unprocessable_entity (422) but it was :no_content (204)

The first is expecting a response that says unprocessable, but no_content is back. I want to return no_content when createa runs successfully, so I'll fix it later.

unexpected token at ''

The second is the error because JSON.parse gives an error with the string from.

Now, let's implement it on the controller and eliminate the error.

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

I'm creating an instance of ActionController :: Parameters because it allows me to use StrongParameter. You will be able to use permit and require, which are instance methods of ActionController :: Parameters. By using permit and require, if something is different from what you are formally expecting, or if some parameter is sent with a different key, you can truncate the unnecessary part.

I have specified adapter for render, which specifies the format. If you do not specify this adapter, attributes is specified by default. This time, I'm using a person called json_api. The following shows the difference as an example. I copied it from Learn about Rails active_model_serializer_100DaysOfCode Challenge Day 10 (Day_10: # 100DaysOfCode).

attributes

[
    {
        "id": 1,
        "name": "Nakajima Hikari",
        "email": "[email protected]",
        "birthdate": "2016-05-02",
        "birthday": "2016/05/02"
    }
  ]
}

json_api

{
    "data": [
        {
            "id": "1",
            "type": "contacts",
            "attributes": {
                "name": "Nakajima Hikari",
                "email": "[email protected]",
                "birthdate": "2016-05-02",
                "birthday": "2016/05/02"
            }
        }
   ]
}

This time I will use json_api which is suitable for api.

Run the test and make sure it passes.

Next, I will write a test when the parameter is correct.

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

You have entered the correct token and the correct parameters. Now run the test.

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

I think each of the three tests will fail this way. Since these are making the correct mistakes, we will implement the controller when the parameters are actually correct.

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

Next, edit create like this. When an error occurs by using rescue, render skips the error.

In article_params, by setting a condition that only : title ,: content,: slug in: attributes in : data is acquired, all other than this specified format will be played. I am.

Now when I run the test, everything goes through.

I'll do one more refactoring.

app/controllers/articles_controller.rb


  rescue
    render json: article, adapter: :json_api,
      serializer: ActiveModel::Serializer::ErrorSerializer,
      status: :unprocessable_entity
  end

Since this ʻActiveModel :: Serializer :: ErrorSerializer,` is long, I will inherit it to a different class elsewhere so that it can be written short.

ʻCreate app / serializers / error_serializer.rb`

app/serializers/error_serializer.rb


class ErrorSerializer < ActiveModel::Serializer::ErrorSerializer; end

Let it be inherited like this.

app/controllers/articles_controller.rb


  rescue
    render json: article, adapter: :json_api,
      serializer: ErrorSerializer,
      status: :unprocessable_entity
  end

And you can clean up the long description. Run a test to see if it fails.

This completes the implementation of the action to create an article.

update action

update Add endpoint

Let's start by adding endpoints again. First of all, I will write a test.

spec/routing/articles_spec.rb


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

I will write endpoint tests like every time. The show action uses either the patch or put because the http request is a patch or put.

Run the test to make sure you get the error correctly.

config/routes.rb


  resources :articles, only: [:index, :show, :create, :update]

Add an update to make sure the test passes.

update action added

Next, let's write a test for the controller # update action.

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

The difference between the update action and the create action is the type of request and the update already in the database. Since it is only the situation that there is an article to be targeted, I just copied the test of create except for the part where the article is created first and the part where the request is defined.

Now run the test.

The action 'update' could not be found for ArticlesController

I think you will get an error like this. So, let's actually define 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

It's nothing new, so I won't explain it.

Now run the test to make sure everything goes through. If you know the difference between create and update, you can see that there is almost no difference. And you can reuse almost the same test.

However, there is a slight problem here. It can be updated on anyone's article on request. I don't want it to be updated without permission. So I will fix it.

As for how to fix it, at the moment, it is a problem because user and article are not related, so we will add association to user and article.

Before that, set the association and test that the expected value is returned.

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

I added the test like this. We create articles that connect with users and even authenticate them.

What I'm doing with the newly added test item is to check if forbidden_requests is properly returned when trying to update the article of another user.

Now when you run the test

undefined method user=

It will fail with a message like this. This is proof that the association has not been established, so we will set up the association next.

app/models/article.rb


  belongs_to :user

app/models/user.rb


  has_many :articles, dependent: :destroy

And in order to connect the two models, it is necessary to give the article model a user_id, so add it.

$ rails g migration AddUserToArticles user:references

$ rails db:migrate

Now the association itself has been implemented. So, using that, we will change the description of the 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

What changed in the description is that the user to find is called with current_user. This allows you to find only from the logged-in user. And if the specified id is not in the article of current_user, ʻActiveRecord :: RecordNotFound` will be raised, so rescue it like that and issue authorization_error dedicated to authentication.

Also, even with create, describe who creates the article, and set the user_id to article. I want to have it, so I will make some changes.

app/controllers/articles_controller.rb


   def create
-    article = Article.new(article_params)
+    article = current_user.articles.build(article_params)

Then, add the description of the association to 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 Will automatically define the id of the model.

If you run the test with this, it will pass. Next, let's move on to the destroy action.

destroy action

destroy endpoint added

First, let's write a test to add an endpoint.

spec/routing/articles_spec.rb


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

When I run the test, I get the following message No route matches "/articles/1"

So, let's edit the routing.

config/routes.rb


  resources :articles

Set everything without specifying with the only option. This will pass the routing test.

Next, add a test for the 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

Most of the code for this test is a copy of the update test. The content is nothing new. Run the test.

The action 'destroy' could not be found for ArticlesController

This error is correct because we haven't defined the destroy action yet. Then controller Will be implemented.

Added destroy action

app/controllers/articles_controller.rb


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

It simply destroys the specified article in current_user.

Now run the test.

If you pass this, everything is done. Thank you for staying with us for a long time!

Recommended Posts

I implemented Rails API with TDD by RSpec. part3-Action implementation with authentication-
I implemented Rails API with TDD by RSpec. part1-Action implementation without authentication-
I implemented Rails API with TDD by RSpec. part2 -user authentication-
# 8 seed implementation to build bulletin board API with authentication authorization in Rails 6
I rewrote the Rails tutorial test with RSpec
Build a bulletin board API with authentication authorization in Rails 6 # 5 controller, routes implementation
# 7 update, destroy implementation to build bulletin board API with authentication authorization in Rails 6
Using PAY.JP API with Rails ~ Implementation Preparation ~ (payjp.js v2)
[Rails] Test with RSpec
Building a bulletin board API with authentication authorization with Rails 6 Validation and test implementation of # 4 post
Build a bulletin board API with authentication and authorization with Rails # 18 ・ Implementation of final user controller
Build a bulletin board API with authentication authorization with Rails 6 # 3 RSpec, FactoryBot introduced and post model
[Rails] I implemented the validation error message by asynchronous communication!
[Ruby on Rails] Implement login function by add_token_to_users with API
API creation with Rails + GraphQL
Login function implementation with rails
[Rails 6] Implementation of new registration function by SNS authentication (Facebook, Google)
What I was addicted to when implementing google authentication with rails
Implemented authentication function with Spring Security ③
Implemented mail sending function with rails
Implemented authentication function with Spring Security ①
Let's unit test with [rails] Rspec!
# 16 policy setting to build bulletin board API with authentication authorization in Rails 6
Build a bulletin board API with authentication and authorization with Rails 6 # 1 Environment construction
Build a bulletin board API with authentication authorization in Rails # 13 Add authentication header
Create a SPA with authentication function with Rails API mode + devise_token_auth + Vue.js 3 (Rails edition)