This article I implemented Rails API with TDD by RSpec. part1 Part 2 of this article. Please see from part1 if you like. The goal this time is to be able to handle the login function and logout function of User authentication using octokit. This article is quite long. There are many parts that are difficult to understand if it is a fragmentary code only for articles, so please read your own code appropriately and understand the contents. Also, if you have any expressions that are difficult to understand, please comment. Then I will go for the first time.
First of all, you need to register the application on github in order to communicate using Api on Github. https://github.com/settings/apps Jump to this page and go to register from the New Github App.
The registration items are as follows.
Application name: -> Unique and freely name your application
Homepage URL: -> http://localhost:3000 Register the url for development.
Application description: -> Enter explanations for easy understanding
Authorization callback URL: -> http://localhost:3000/oauth/github/callback Setting URL for redirect
Press Register Application when you are done. Then a display like that is returned.
Owned by: @user_name
App ID: xxxxx
Client ID: Iv1.xxxxxxxxxxxxxxxxxxxxx
Client secret: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Use this ClientID and ClientSecrete to connect to the gitub API. Copy it somewhere.
octokit
Next, we will introduce a gem called octokit.
official https://github.com/octokit/octokit.rb
By using octokit, it seems that it is easier to link with github. (I don't really know what's going on inside)
And since I have already added the octokit gem at the beginning, I will continue as it is.
Move to the terminal.
$ GITHUB_LOGIN='githubuser_name' GITHUB_PASSWORD='github_password' rails c
First, put the two values in the environment variables. This is the username and password you normally use to log in to github. Then make sure the console opens.
For the time being
ENV['GITHUB_LOGIN']
Make sure that the contents are included by hitting.
$ client = Octokit::Client.new(login: ENV['GITHUB_LOGIN'], password: ENV['GITHUB_PASSWORD'])
$ client.user
Then connect to octokit and make sure that the user information is properly collected.
This is just an exercise. In the future, we will implement it using this mechanism.
Now, let's create a User model.
$ rails g model login name url avatar_url provider
Add database-level restrictions to migration files.
xxxxxxxxx_create_users.rb
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
Since the file has been generated, add null: false to the login attribute.
$ rails db:migrate
Next, we will add restrictions at the model level. I would like to add validation, but first I will write from the test.
spec/models/user_spec.rb
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
The first test is a test to see if the factorybot is running The second is a test to check if login and provider are included. The third is a test to see if login is unique
Also, if factorybot is at the moment, the same user will be added no matter how many times it is created, so fix that.
spec/factories/user.rb
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
Solve using sequence. This makes the login of the user created each time unique.
Now run the test.
$ rspec spec/models/user_spec.rb
At this point, check that there is no typo and the error is occurring normally. The test to see if the first factorybot is working properly is successful.
We will implement validation from now on.
models/user.rb
class User < ApplicationRecord
validates :login, presence: true, uniqueness: true
validates :provider, presence: true
end
Run the test to make sure it succeeds.
Next, write the code to interact with github.
ʻCreate app / lib directory Under that, create ʻapp / lib / user_authenticator.rb
.
app/lib/user_authenticator.rb
class UserAuthenticator
def initialize
end
end
Originally, TDD is the one to write the test code first, but if you define the class first, the correct error will be thrown out, so it is faster to create the file and define the class first.
Then write the test.
Create a lib directory and files.
spec/lib/user_authenticator_spec.rb
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
This time, we will use an instance method called perform to sign in and log in.
First, when the code is inappropriate. (By the way, code is a one-time token issued by github, and this time we will not actually receive that code, so code uses just a string and how github behaves for that code. By using a mock for the part, I try to complete the test without the code actually issued. The code is used to exchange for a token unique to github user.)
Create an instance with described_class.new and execute the method with authenticator.perform. ʻUserAuthenticator :: AuthenticationError` is defined in its own class.
When I run the test, it says that there is no .perform
. And it is said that .user
cannot be used.
So I will actually write it.
app/lib/user_authenticator.rb
class UserAuthenticator
class AuthenticationError < StandardError; end
attr_reader :user
def initialize(code)
end
def perform
raise AuthenticationError
end
end
Make it possible to read user at any time with attr_readerd.
And perform is also defined.
Define ʻAuthenticationErrorthat inherits
StandardError and nest it in ʻUserAuthenticator
.
The reason why I raise it in perform is to make the test successful for the time being.
Now when I run the test it succeeds.
$ rspec spec/lib/user_authenticator_spec.rb
And next, write a test when the code is correct. But before that, I used it in should raise an error
authenticator = described_class.new('sample_code') authenticator.perform
These two parts
spec/lib/user_authenticator_spec.rb
describe '#perform' do
let(:authenticator) { described_class.new('sample_code') }
subject { authenticator.perform }
Define it like this and use it in the when code is correct that I will write.
So the whole picture now is as follows.
spec/lib/user_authenticator_spec.rb
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
Then write a test when the code is correct
spec/lib/user_authenticator_spec.rb
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
If user is a user that does not exist in the database in advance, User.count is incremented by 1. This is a new registration of user.
Now I run the test but of course it fails. That's because the perform action says raise Authentication Error
no matter what.
So, we will implement the perform method.
app/lib/user_authenticator.rb
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
What we're doing here is to have github authenticate the project at the beginning of the article. Put the two values that client_id and client_secret were displayed when you registered this project on github at the beginning of this article in this environment variable. But this time, the actual value is not used. For the time being, I will explain it later.
client.exchange_code_for_token(code)
This part remains as it is, but the code is exchanged for token.
The token is only temporary generated by the github API as described above.
And if the returned response is an error, it can be retrieved with res.error, so the error is raised only when an error is included.
Now run the test once.
404 - Error: Not Found
Probably 404 is spit out. This is because the contents of GITHUB_CILENT_ID and GITHUB_CILENT_SECRET are empty. However, since this is a test, we cannot enter the true value here. Ideally, the test should be completed only by the test, eliminating the network environment as much as possible.
So I use a mock for testing. A mock is for creating an alternative to github communication on this side and completing it in a test.
spec/lib/user_authenticator_spec.rb
context 'when code is incorrenct' do
before do
allow_any_instance_of(Octokit::Client).to receive(
:exchange_code_for_token).and_return(error)
end
Therefore, before is used like this, and a method called allow_any_instance_of is used.
allow_any_instance_of(Instance name).to receive(:Method name).and_return(Return value)
Use it like this. You can use this to specify the return value when the specified method of the specified instance is called.
An error is returned when calling the exchange_code_for_token method from an instance of Octokit :: Client.
Define the error of the return value.
spec/lib/user_authenticator_spec.rb
context 'when code is incorrenct' do
let(:error) {
double("Sawyer::Resource", error: "bad_verification_code")
}
double is the method for creating a mock. Sawyer :: Resource is a class name and you can use error as a method of that class. The actual error can be faithfully reproduced.
Now when I run the test, the first succeeds, but the other fails. It's 404, so it's the same as before.
The second test is defined in the same way as the previous mock.
spec/lib/user_authenticator_spec.rb
context 'when code is correct' do
before do
allow_any_instance_of(Octokit::Client).to receive(
:exchange_code_for_token).and_return('validaccesstoken')
end
But this time, instead of issuing an error, it returns a valid access token. It's not really a meaningful string, but in the sense that it's not an error, this value still works as a good token for testing.
Run the test.
undefined method `error' for "validaccesstoken":String
Message appears.
this is
app/lib/user_authenticator.rb
if res.error.present?
Regarding this part, an error occurred because I was trying to read the error even when there was no error in res. So, if there is no error, write to return nil.
app/lib/user_authenticator.rb
if res.try(:error).present?
Now run the test.
expected User.count to have changed by 1, but was changed by 0
It can be said that it is a normal message because the operation to save has not been written yet. So, I will write the process of saving the data.
app/lib/user_authenticator.rb
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
Rewrite like this. Create an instance of github user using the token returned in exchange for code.
user_client = Octokit::Client.new(
access_token: token
)
This part of the above does the same thing as creating an instance using login and password. The same result is output regardless of whether token is used or login and password are used.
//It's just a sample so you don't have to actually hit it
$ client = Octokit::Client.new(login: ENV['GITHUB_LOGIN'], password: ENV['GITHUB_PASSWORD'])
$ client.user
Earlier in this article, I typed a command like this on the console, and it does exactly the same thing. You can get the data of github user by actually doing client.user. However, the format is Sawyer :: Resource, which is very difficult to handle. So, once converted to hash with to_h, the contents are taken out with the slice method. And it is saved in the database as it is using the create method. The provider is merged because the provider is not in the retrieved data, so you need to add it yourself. If you don't, you will get stuck in validation.
Incidentally, I changed res to token. It is preferable to use the variable name as what it actually means in terms of logic.
Then run the test.
401 - Bad credentials
Next, such a message changes. 401 seems to be an error returned when you can not log in etc. However, this time it's just an instance made with a mock, so you don't have to be able to actually authenticate.
app/lib/user_authenticator.rb
user_data = user_client.user.to_h.
slice(:login, :avatar_url, :url, :name)
Currently there is an error in this user_client.user part. So, how to return when user_client.user is done is reproduced by mock.
spec/lib/user_authenticator_spec.rb
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
I added: user. Then add the variable user_data.
spec/lib/user_authenticator_spec.rb
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
Now the test runs and succeeds.
Also, make sure that the stored values are correct.
spec/lib/user_authenticator_spec.rb
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
Add the bottom line.
Now run the test and make sure it passes.
However, although a new user is created every time, I want to reuse the user once created. Obviously, it's like making a new registration every time, so it's inefficient. So I will write the code so that it can be used.
First of all, I will write from the test.
spec/lib/user_authenticator_spec.rb
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
Create user once and use the same user_data to do authenticator.perform. Then, check if the user created by authenticator.perform and the user created by factorybot are the same.
Run the test to make sure it fails. Right now, I'm not reusing it yet, but creating it every time. So, I will describe it so that it can be used.
app/lib/user_authenticator.rb
- 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
Rewrite like this. If the same user exists, create a branch that uses find_by.
Running the test succeeds.
However, at this point, the amount of description of perform methods is too large, and the responsibility of perform methods is ambiguous. Since the perform method has the meaning of so-called execution, it is preferable that the method is only for executing. So, write the logic that generates and arranges the value to another method.
app/lib/user_authenticator.rb
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
Roughly delete this part and move it to another place. The location to move is defined by the private method. The reason is that it defines a value that does not need to be called from an external class.
app/lib/user_authenticator.rb
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
Write it like this. The structure is such that the lower method calls the upper method, and the responsibilities are separated neatly.
Now run the test to make sure it doesn't fail.
This is the end of refactoring.
Next.
Next, I will make an access_token for railsapi that I am making now. The token obtained using the exchange_code_for_token method is just a token for accessing the github API and getting user information, so it cannot be used to authenticate the rails API request we are making.
From now on, I will make a token for request authentication of the rails API that I am making now. This token is needed when performing a create action or a delete action. On the contrary, when performing index action or show action, accept the request even if there is no token. But that depends on the application.
Then I will make the token, but first I will write it from the test.
spec/lib/user_authenticator_spec.rb
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
Added this test at the end.
Then, after that, edit the perform method.
app/lib/user_authenticator.rb
else
prepare_user
+ @access_token = if user.access_token.present?
+ user.access_token
+ else
+ user.create_access_token
+ end
end
In this way, token is set as the attribute of the instance.
app/lib/user_authenticator.rb
attr_reader :user, :access_token
In addition, make it possible to call access_token. For the time being, the explanation will be explained in detail later.
$ rails g model access_token token user:references
For the time being, create an access_token model. This will create an access_token model with belongs_to: user.
Set the association for the user model as well.
app/models/user.rb
class User < ApplicationRecord
validates :login, presence: true, uniqueness: true
validates :provider, presence: true
has_one :access_token, dependent: :destroy #add to
end
db/migrate/xxxxxxxxx_create_access_tokne.rb
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
Also check the migration file, add nill: false to token.
Execute rails db: migrate
.
Next, prepare an access token test.
spec/models/access_token_spec.rb
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
Now that everything is ready, run the test.
$ rspec spec/lib/user_authenticator_spec.rb
SQLite3::ConstraintException: NOT NULL constraint failed: access_tokens.token
Then, such a message is spit out. This error seems to occur if you have null: false at the database level, but it is null.
Then write the logic to generate the token so that it will not be null. Write a test before that.
spec/models/access_token_spec.rb
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
Add this code to the end.
The first is whether the token is properly included when the AccessToken is renewed. I will write it later, but I will write it later so that the token will be automatically entered when new.
The second is whether the AccessToken count will increase by 1. Is it not caught in validation? Whether or not it does not hit validation, usually I create a model, use the first value for the second, build, and check if it hits validation properly, but this time a little Since the token is automatically generated when you make a special new, you cannot test it. Because you can't specify an argument like AccessToken.new (old_token). If you use AccessToken.new, the token is automatic.
Now let's write the logic to generate the token.
app/models/access_token.rb
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
The method specified by after_inialize is executed when the model is created.
I'm turning it in a loop because I want to create a token as many times as I want unless the conditions specified by break if are met. Generate a token using the SecureRandom class. The values are created randomly, so the exact same values may not be generated. So let's loop. The break condition has a value in token. And the same value does not exist in the database. Loop as many times as you like unless that is the case. Usually it breaks once it turns.
Run the test.
$ rspec spec/models/access_token_spec.rb
$ rspec spec/lib/user_authenticator_spec.rb
Make sure this test passes.
By the way, ʻuser.create_access_tokenin user_authenticator.rb This method is not defined somewhere, it is automatically generated by rails. The meaning remains the same, but if you replace it in an easy-to-understand manner,
AccessToken.create(user_id: user.id)`
It has the same meaning as this.
Now that the logic for token generation is over, let's move on.
Next, we will implement the overall picture of the login function. Currently, a mechanism to generate a token has been established, but a login function using that token has not yet been implemented. So I will implement that area.
But first write from the test. I haven't done routing yet, so I'll start with the routing test. There is no file to describe, so create it.
spec/routing/access_token_spec.rb
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
The explanation of the description is omitted.
When I run the test, it says no route match / login, so edit routes.rb.
config/routes.rb
Rails.application.routes.draw do
+ post 'login', to: 'access_tokens#create'
resources :articles, only: [:index, :show]
end
Test run.
A route matches "/login", but references missing controller: AccessTokensController
It is said that there is no controller, so I will make one.
$ rails g controller access_tokens
create app/controllers/access_tokens_controller.rb
invoke rspec
create spec/requests/access_tokens_request_spec.rb
Run the test again. The test passes. This completes the login endpoint installation.
Now let's test the controller. Create and describe the following file.
spec/controllers/access_tokens_controller_spec.rb
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
I expect 401 to come back without authentication. 401 is unauthorized, but it is semantically unauthenticated, so it is often used as a response when it is not authenticated.
Although it is still new, when rails g controller is used, files such as requests / access_tokens_request_spec.rb are automatically generated. This is the successor to the controller test, but the way it is written is slightly different from controller_spec, so this time I purposely created and described the file myself. Originally, it is recommended to write in request_spec.
Run the test.
AbstractController::ActionNotFound: The action 'create' could not be found for AccessTokensController
Since the create action is not defined, write it.
app/controllers/access_tokens_controller.rb
class AccessTokensController < ApplicationController
def create
end
end
Run the test. I'm expecting 401, but 204 is back. 204 means: no_content.
So for the time being, I will write it in the controller to pass the test.
app/controllers/access_tokens_controller.rb
class AccessTokensController < ApplicationController
def create
render json: {}, status: 401
end
end
Run the test to make sure it passes.
I will add more tests.
spec/controllers/access_token_controller_spec.rb
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
Expect the correct error res to be returned in the case of 401. The error statement is edited and used by copying it from the following site. https://jsonapi.org/examples/
Then run the test.
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
Since nil is returned, write a process that returns an error properly on the controlle side.
app/controllers/access_tokens_controller.rb
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
Make sure this passes the test.
Currently, when the create action is called, it gives an error in everything, but fix it.
app/controllers/access_tokens_controller.rb
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
I'm editing the code for refactoring as well. At this point, we finally write ʻUserAuthenticator.new (params [: code])`. The logic to create a user by exchanging code and token, which I have written all the time, is written in UserAuthenticator, but I call it here.
Then execute it with perform.
The body of the 401 error is written to the method. The error returned at this point is ʻUserAuthenticator :: AuthenticationError`, so rescue_from will be used to rescue. Since it is written to the method, it can be called with rescue_from.
After that, in UserAuthenticator :: AuthenticationError, I want to issue the same error even when the code is blank. By the way, I need to refactor.
app/lib/user_authenticator.rb
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
Now you can get an error when the code is blank.
To recap, code is the token sent from the front end. The front end gets the token from github and sends it to the api. That is code (github_access_code). The API receives the code and communicates with GitHub to exchange the code for token (by the exchange_code_for_token method). With that token, github user information can be obtained from the github API.
Based on that, it is possible that the code is sufficiently blank, so prepare an error.
Run the test to make sure it passes.
Further refactor.
app/controlers/access_token_controller.rb
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
app/controllers/application_controller.rb
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
Completely leave authentication_error to application_controller so that all controllers can pick up this error. The reason is that authentication errors can occur on any controller.
Run the test to make sure nothing has changed.
And it's even better to use this implementation in tests as well. The code will paste all the changes for now as the explanation will be lengthy
spec/controllers/access_token_controller_spec.rb
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
I'd like you to read the code carefully to see what you're doing, but here we're using two tests with shared_examples_for.
should return 401 status code
should return proper error body
These two tests will often be reused in the future. You can also call shared_examples_for using it_behaves_like. By using subject and making it DRY, you can freely enter a value for each context in subject.
spec/controllers/access_token_controller_spec.rb
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
Also, regarding this part, this description was used in the test before, and it is reproduced with mock without connecting directly to the github API. This allows you to reproduce the github API without actually connecting to github.
Next, I will write a test when the code is correct.
spec/controllers/access_token_controller_spec.rb
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
This is simply a mock manipulating whether the code is correct or incorrect. I simply expect 201 to be returned if the code is correct.
Run the test.
expected the response to have status code :created (201) but it was :no_content (204)
This message is displayed So, edit the controller so that 201 is returned in response.
app/controlers/access_token_controller.rb
def create
authenticator = UserAuthenticator.new(params[:code])
authenticator.perform
render json: {}, status: :created
end
Add render and return created.
Now run the test again and make sure it passes.
Next, I want to implement it so that it returns a response firmly. So I will write from the test.
spec/controllers/access_token_controller_spec.rb
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
Add this test to the end. As for the content of the test, as in the case of article, receive the value with json_data ['attributes'] and check if the content is correct. Since the user fetched by User.find_by is described by the mock using user_data described earlier, the test that the value and the value returned as response are the same.
However, even if I run the test, I can't retrieve it with json_data because I don't use serializer and json.data doesn't exist. So, I will introduce serializer to make a response in a neat format.
$ rails g serializer access_token
This will be described in the created file.
app/serializers/access_token_serializer.rb
class AccessTokenSerializer < ActiveModel::Serializer
attributes :id, :token
end
Add the description of token. This allows the response to include a token.
And also specify the value to be returned by render in controller.
access_tokens_controller.rb
- render json: {}, status: :created
+ render json: authenticator.access_token, status: :created
end
This allows you to return a well-formed response instead of a hash from.
Run the test. Then a message appears.
expected: {"token"=>"6c7c4213cb78c782f6f6"}
got: {"token"=>"2e4c724d374019f3fb26"}
Somewhere, the token has been recreated and the value has been switched. This is a bug that tokens are created every time you reload.
So, I will write a test to fix the bug.
spec/models/access_token_spec.rb
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
First, run a test to see if the bug has been reproduced.
expected: "3afe2f824789a229014c" got: "c5e04c73aa7ff89fd0a1"
I was able to reproduce it properly, so I got a message.
Let's improve. First, let's take a look at the buggy generate_token method.
app/models/access_token.rb
def generate_token
loop do
break if token.present? && !AccessToken.exists?(token: token)
self.token = SecureRandom.hex(10)
end
end
There is something wrong here, the problem is that the break condition was not good.
break if token.present? && !AccessToken.exists?(token: token)
This condition has a solid value in token. And the token does not exist in the database. It becomes a condition. But that would be a bit of a contradiction. Since the existence of the token means that it is stored in the database, this conditional expression cannot be satisfied.
Therefore, the condition is that there is no token other than the specified token that has the same token.
app/models/access_token.rb
- break if token.present? && !AccessToken.exists?(token: token)
+ break if token.present? && !AccessToken.where.not(id: id).exists?(token: token)
In this way, it is possible to create a condition called a token other than the currently specified token. Now run the test and make sure it passes.
Now, let's implement the logout function.
spec/routeing/access_token_spec.rb
it 'should route to acces_tokens destroy action' do
expect(delete '/logout').to route_to('access_tokens#destroy')
end
Write a routing test.
config/routes.rb
Rails.application.routes.draw do
post 'login', to: 'access_tokens#create'
delete 'logout', to: 'access_tokens#destroy'
resources :articles, only: [:index, :show]
end
Added logout line.
The test passes.
Next, I will write a controller test.
spec/controllers/access_token_controller.rb
@@ -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
Originally treated as an error 403 error, but renamed to clarify the role. Then, I will write a whole test dedicated to destroy. Keep reading the content.
The @@ notation is a code that indicates how many lines are written, and it is not necessary to actually write it.
Then, implement the controller.
app/controllers/access_tokens_controller.rb
def destroy
raise AuthorizationError
end
Define the destroy method. First, in order to pass the error response test, raise the AuthorizationError and actually define the actual state of the error in application_controller.
app/controllers/application_controller.rb
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
The content of the error is the same as what I wrote in the test.
Now run the test and make sure it passes.
However, since there is a slightly duplicated description, I will make it DRY.
spec/controllers/access_tokens_controller_spec.rb
describe 'DELETE #destroy' do
shared_examples_for 'forbidden_requests' do
end
First, use shared_examples_for under describe to summarize the description.
The following description is included in shared_examples_for.
spec/controllers/access_tokens_controller_spec.rb
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
Combine the tests you have written so far into one.
spec/controllers/access_tokens_controller_spec.rb
context 'when invalid request' do
subject { delete :destroy }
it_behaves_like 'forbidden_requests'
end
And since it is the description that it_behaves_likes calls shared_expample_for, it calls forbidden_requests specified earlier in the string.
Now that we have created the same environment as before, run it again and make sure the test passes.
Next, we will combine these shared_example_for into one file so that they can be used. There are two shared_example_for in the current ʻaccess_tokens_controller_spec.rb`, so put them together in the same file.
Create spec / support / shared / json_errors.rb
Put the description of shared_example_for in it.
spec/support/shared/json_errors.rb
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
Then, all the description of the cutting source is deleted.
spec/controllers/access_tokens_controller_spec.rb
describe 'DELETE #destroy' do
subject { delete :destroy }
Raise the nest of subject definition one step. Then add two tests.
spec/controllers/access_tokens_controller_spec.rb
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
In this test, the subject is not written because the subject is already written in shared_example_for, so subject {delete: destroy}
is automatically called.
And if you use before, you can edit the contents of the request.
This time, by putting Invalid_token in token, we will create a user who has not been authenticated.
Of course, an authentication error will occur, so a test that expects it.
Now run the test to make sure it succeeds.
spec/controllers/access_tokens_controller_spec.rb
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
Next, write a test for when valid request.
In order to send the correct request, you must first put the token in headers ['authorization']
and pass the permissions.
Bearer is bearer authentication, and this time we will use it.
Testing expects the AccessToken model to be reduced by one from the database.
Now make sure that the test fails correctly. Here, typo is often found if you confirm that it fails correctly.
expected the response to have status code :no_content (204) but it was :forbidden (403)
When I run the test, I get a message like this:
Forbidden is returned because it is described so that the destroy action always returns an error.
So we will actually implement the destroy action.
#### **`app/controllers/access_tokens_controller.rb`**
```rb
def destroy
raise AuthorizationError
end
First of all, what I want to do with this destroy is to destroy the access_token of the user who sent the request. So write as follows.
app/controllers/access_tokens_controller.rb
def destroy
raise AuthorizationError unless current_user
current_user.access_token.destroy
end
current_user refers to the user who is currently logged in. Think about how to bring current_user.
current_user cannot be obtained from request at once. However, if you use request.authorization, the Bearer xxxxxxxxxxxxxxxxxxxxx
that you sent in the test earlier
You can get a token like this. So use that token to get the current_user.
app/controllers/access_tokens_controller.rb
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
First, in order to get the token with request.authorization and search the token in the database, the gsub method is used to cut it with a regular expression. If you can retrieve only the number part of the token, search with AccessToken.find_by and retrieve it. And if you use that access_token.user, you can retrieve the user who sent the request. And destroy that token Then log out is completed.
The description of &.
is called a bocce operator, and if you add it to a method that you know in advance that nil may come back and become unfiind method, an error will occur in the case of nil. Does not appear and nil is returned as a return value as it is, so no error occurs. something like. This time, Invalid_token may be mixed in the request, so in that case nil will be returned, so an error will occur unless the Bocchi operator is used.
Now run the test and make sure it passes all the tests.
Next, we will refactor this code.
app/controllers/access_tokens_controller.rb
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
First, cut out the description like this. Then, move the description to application_controller.rb. The reason for this is that the logic that receives this request and generates the current_user is the description that any controller wants to use.
app/controllers/application_controller.rb
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
Then, write the method privately like this. The authorize! method gives a 401 error when current_user is not included. Get the correct access_token with the access_token method The current_user method retrieves the user for that token. The reason why access_token and current_user are separated here is to clarify their roles and separate responsibilities.
And finally, write so that the defined authorize! Method can always be called.
app/controllers/access_tokens_controller.rb
class AccessTokensController < ApplicationController
before_action :authorize!, only: :destroy
The situation is always called by before_action. The reason why only destroy is specified is that if it is called during the create action, it will be a method that cannot be called.
These approaches are common, but you forget to write before_action or write too much. So, use skip_before_action and specify the method to skip on the contrary. Basically, as far as the authorize! method is concerned, it seems good to skip even create.
app/controllers/application_controller.rb
before_action :authorize!
private
Added a description that is always called above private.
app/controllers/access_tokens_controller.rb
class AccessTokensController < ApplicationController
skip_before_action :authorize!, only: :create
Change before_action and method.
app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
skip_before_action :authorize!, only: [:index, :show]
And don't forget to skip article_controller. I want to do index and show without authentication.
Now run the test to see if you get the same results as before the refactoring.
$ bundle exec rspec
Run all the tests and make sure they are all green.
Thank you for your hard work. With this, we were able to implement the user authentication function that was our initial goal. These may be substituted by using a gem called devise, but depending on whether you know the mechanism or not, the response to problems around user authentication will change, and I think that the degree of understanding is completely different. .. The area around the token is very hard to imagine, and when using oauth, there are still some gems that substitute everything, so the mechanism tends to be black-boxed. So this time, I used user authentication like this.