How to Build an eCommerce API With Ruby on Rails Part 3

This is the final part of our Ruby on Rails development kitchen. We hope you are eager to learn the next part of the recipe. As usual, you won’t need any dishes, spoons or knives. Just your computer and you, ready to code and create.  
 
A small reminder for those who missed the first and the second parts of our tutorial: don’t forget to read them first. 

The third part is devoted to registration and authorization. Every e-commerce platform needs them so we cannot skip this important requirement too. Safe online shopping is essential and has to be as private and convenient as possible. Let’s start with the registration. To begin with, create the model User with such fields as name:string, email:string, password_digest:string. To do this run the following command in the console:

  
$ rails generate model User name email password_digest
  

Then remember to run another command: 

  
$ rake db:migrate
  

Go to the file app/models/user.rb and add the code line:

  
class User < ActiveRecord::Base
  has_secure_password
end
  

Let’s analyze the has_secure_password method in detail. It is used for saving a password, its validation and encryption (a cryptographic hash-function BCrypt is used for it). This method works with the field password_digest that is why we named it so. has_secure_password automatically connects 3 validations:

  1. A password has to be entered while creating. 
  2. The password length has to be equal to or less than 72 symbols.
  3. The password confirmation (the attribute password_confirmation is used for that).

For the correct work of has_secure_password you need to connect the bcrypt gem. Go to Gemfile and add the following there:

  
gem 'bcrypt'
  

Don’t forget about the command: 

  
$ bundle install
  

Let’s also add validations to the fields name and email. We will use the email_validator gem for the email validation. Its description can be found here: https://github.com/balexand/email_validator. Connect it and write validations in the model User.

  
class User < ActiveRecord::Base
  has_secure_password

  validates :name, presence: true

  validates :email, presence: true, uniqueness: { case_sensitive: false }, email: true
end
  

Now, we should cover the fresh-created model with tests. The shoulda-matchers gem will be used for it. The description is here: https://github.com/thoughtbot/shoulda-matchers. Go to the file spec/models/user_spec.rb:

  
require 'rails_helper'

RSpec.describe User, type: :model do
  it { should have_secure_password }

  it { should validate_presence_of :name }

  it { should validate_presence_of :email }

  it { should validate_uniqueness_of(:email).case_insensitive }

  it { should_not allow_value('test').for(:email) }

  it { should allow_value('test@test.com').for(:email) }
end
  

Don’t forget to run the tests:

  
$ rake
  

Let’s create singleton resource api/user with action #create. Go to config/routes.rb and code: 

  
Rails.application.routes.draw do
  namespace :api do
    ...

    resource :user, only: [:create]
  end
end
  

Then create the controller users_controller.rb in the directory app/controllers/api and write there:

  
class Api::UsersController < ApplicationController
  private
  def build_resource
    @user = User.new resource_params
  end

  def resource
    @user
  end

  def resource_params
    params.require(:user).permit(:name, :email, :password, :password_confirmation)
  end
end
  

We don’t need to define the action method #create as we have already created and defined it in ApplicationController in the first part of our tutorial.

  
def create
  build_resource

  resource.save!
end
  

That is why it will be enough to redefine private methods. After that cover the controller with tests. Create the file spec/controllers/api/users_controller_spec.rb and write the test:

  
require 'rails_helper'

RSpec.describe Api::UsersController, type: :controller do
  it { should route(:post, 'api/user').to(action: :create) }

  describe '#create.json' do
    let(:params) do
      {
        name: 'Test name',
        email: 'test@test.com',
        password: '12345678',
        password_confirmation: '12345678'
      }
    end

    let(:user) { stub_model User }

    before { expect(User).to receive(:new).with(params).and_return(user) }

    before { expect(user).to receive(:save!) }

    before { post :create, user: params, format: :json }

    it { should render_template :create }
  end
end
  

As the next step you need to create UserDecorator and write the method #as_json there to make correct data return in the answer to this request. We already did the same in the first part of the tutorial when creating products.

  
class UserDecorator < Draper::Decorator
  delegate_all

  def as_json *args
    {
      name: name,
      email: email
    }
  end
end
  

Remember to cover the decorator with the tests:

  
require 'rails_helper'

RSpec.describe UserDecorator do
  describe '#as_json' do
    let(:user) { stub_model User, name: 'Test name', email: 'test@test.com' }

    subject { user.decorate.as_json }

    its([:name]) { should eq 'Test name' }

    its([:email]) { should eq 'test@test.com' }
  end
end
  

Then create app/view/application/create.json.erb :

  
<%= sanitize resource.decorate.to_json %>
  

After that you need to think how to show validation errors to a user. Go to ApplicationController and you will see that the method #save! is called in the #create method in the line: 

  
resource.save!
  

This method is quite peculiar: if there is a failure in saving a resource, it will call the ActiveRecord::RecordInvalid error. Due to this we can catch (rescue) this error in ApplicationController:

  
class ApplicationController < ActionController::Base
...

  rescue_from ActiveRecord::RecordInvalid do
    render :errors, status: :unprocessable_entity
  end

...
end
  

It means that if you don’t pass validation, the template “Errors” with status 422 (unprocessable entity) will be rendered.

Let’s create the template app/view/application/errors.json.erb:

  
<%= sanitize({ errors: resource.errors }.to_json) %>
  

The last thing we need to do is to go to ApplicationController and add:

  
class ApplicationController < ActionController::Base
...

  skip_before_action :verify_authenticity_token, if: :json_request?

  private
  def json_request?
    request.format.json?
  end
...
end
  

It will disconnect the protection from Cross-Site Request Forgery for requests in the json format. Excellent! The registration request is ready. Let’s check its performance with the tests:

  
$ rake
  

If no mistakes occur, we can test its performance with the curl command:

  
$ curl "http://localhost:3000/api/user" -H "Accept: application/json" -X POST -d "user[name]=test&user[email]=test@test.com&user[password]=test&user[password_confirmation]=test"
  

You should see the following on the screen:

  
{
  "email": "test@test.com",
  "name": "test"
}
  

Try to repeat the same request and you will see the error: 

  
{
  "errors": {
    "email": [
      "has already been taken"
    ]
  }
}
  

Done. The registration is completed and we can move to the next point of our today’s task and create authorization for our users. We will use Token-Based Authentication to do that (there is a lot of information about it online). 

At first we need to create the AuthToken model with the field value:string and with the external key user_id. We will store user tokens here. Run the following command in the console:

  
$ rails generate model AuthToken
  

Then go to the migration file (timestamp)_create_auth_tokens.rb and write:

  
class CreateAuthTokens < ActiveRecord::Migration
  def change
    create_table :auth_tokens do |t|
      t.string :value
      t.references :user, index: true, foreign_key: true

      t.timestamps null: false
    end
  end
end
  

After that run the command:

  
$ rake db:migrate
  

Open the generated model AuthToken and add the code there:

  
class AuthToken < ActiveRecord::Base
  belongs_to :user

  validates :value, presence: true
end
  

Add the following to the User model:

  
class User < ActiveRecord::Base
...

  has_one :auth_token, dependent: :destroy

...
end
  

It means that we have defined the one to one connection for the models User and AuthToken. The dependent option with the destroy value means that when deleting a user record from the database a connected record from the auth_tokens table will be deleted too.
 
Add validation for auth_token as well and then write tests for the AuthToken model:

  
require 'rails_helper'

RSpec.describe AuthToken, type: :model do
  it { should belong_to :user }

  it { should validate_presence_of :value }
end
  

Continue with adding the test to the User model:

  
it { should have_one(:auth_token).dependent(:destroy) }
  

As the next step, create the singleton resource api/session with the action #create #destroy. Go to config/routes.rb:

  
Rails.application.routes.draw do
  namespace :api do
    ...

    resource :session, only: [:create, :destroy]
  end
end
  

Before creating SessionsController, we need to create the class Session, where a token for a new version will be generated and the session validation will be performed. Place this class in the /lib directory. To do this, we need to write the way for file uploading in config/application.rb.

  
module Shop
  class Application < Rails::Application
    ...

    config.eager_load_paths << config.root.join('lib').to_s
  end
end
  

Now we can start writing the class Session:

  
class Session
  include ActiveModel::Validations

  attr_reader :email, :password, :user

  def initialize params
    params = params.try(:symbolize_keys) || {}

    @user = params[:user]

    @email = params[:email]

    @password = params[:password]
  end

  validate do |model|
    if user
      model.errors.add :password, 'is invalid' unless user.authenticate password
    else
      model.errors.add :email, 'not found'
    end
  end

  def save!
    raise ActiveModel::StrictValidationFailed unless valid?

    user.create_auth_token value: SecureRandom.uuid
  end

  def destroy!
    user.auth_token.destroy!
  end

  def auth_token
    user.try(:auth_token).try(:value)
  end

  def as_json *args
    { auth_token: auth_token }
  end

  def decorate
    self
  end

  private
  def user
    @user ||= User.find_by email: email
  end
end
  

Let’s analyze the code. The constructor (method initialize) accepts hash as a parameter and records to instance such variables as @email, @user and @password. Two getter methods (email and password) are also defined in the line:

  
  attr_reader :email, :password
  

The ActiveModel::Validations allows us to use validations in this class. The following methods are connected: errors, invalid?, valid?, validate, validates_with.

The user search by email is done in the #user method. If it is successful, @user is recorded in instance, if nothing is found, then it is nil. We need to check the validation to make sure if there is such a user with the email and if the entered password is correct.

  
validate do |model|
  if user
    model.errors.add :password, 'is invalid' unless user.authenticate password
  else
    model.errors.add :email, 'not found'
  end
end
  

If a user is not valid, the #save! method returns the ActiveModel::StrictValidationFailed error. Otherwise a new token is created. The #destroy! method deletes the user token. The #auth_token method returns the token value.

Let’s define the #as_json and #decorate methods instead of using the decorator. We need to cover this class with the spec/lib/session_spec.rb tests.

  
require 'rails_helper'

RSpec.describe Session, type: :lib do
  it { should be_a ActiveModel::Validations }

  let(:session) { Session.new email: 'test@test.com', password: '12345678' }

  let(:user) { stub_model User }

  subject { session }

  its(:email) { should eq 'test@test.com' }

  its(:password) { should eq '12345678' }

  its(:decorate) { should eq subject }

  describe '#user' do
    before { expect(User).to receive(:find_by).with(email: 'test@test.com') }

    it { expect { subject.send :user }.to_not raise_error }
  end

  context 'validations' do
    subject { session.errors }

    context do
      before { expect(session).to receive(:user) }

      before { session.valid? }

      its([:email]) { should eq ['not found'] }
    end

    context do
      before { expect(session).to receive(:user).twice.and_return(user) }

      before { expect(user).to receive(:authenticate).with('12345678').and_return(false) }

      before { session.valid? }

      its([:password]) { should eq ['is invalid'] }
    end
  end

  describe '#save!' do
    context do
      before { expect(subject).to receive(:valid?).and_return(false) }

      it { expect { subject.save! }.to raise_error(ActiveModel::StrictValidationFailed) }
    end

    context do
      before { expect(subject).to receive(:user).and_return(user) }

      before { expect(subject).to receive(:valid?).and_return(true) }

      before { expect(SecureRandom).to receive(:uuid).and_return('XXXX-YYYY-ZZZZ') }

      before { expect(user).to receive(:create_auth_token).with(value: 'XXXX-YYYY-ZZZZ') }

      it { expect { subject.save! }.to_not raise_error }
    end
  end

  describe '#destroy!' do
    before do
      #
      # subject.user.auth_token.destroy!
      #
      expect(subject).to receive(:user) do
        double.tap do |a|
          expect(a).to receive(:auth_token) do
            double.tap do |b|
              expect(b).to receive(:destroy!)
            end
          end
        end
      end
    end

    it { expect { subject.destroy! }.to_not raise_error }
  end

  describe '#auth_token' do
    context do
      before { expect(subject).to receive(:user) }

      its(:auth_token) { should eq nil }
    end

    context do
      let(:auth_token) { stub_model AuthToken, value: 'XXXX-YYYY-ZZZZ' }

      let(:user) { stub_model User, auth_token: auth_token }

      before { expect(subject).to receive(:user).and_return(user) }

      its(:auth_token) { should eq 'XXXX-YYYY-ZZZZ' }
    end
  end

  describe '#as_json' do
    before { expect(subject).to receive(:auth_token).and_return('XXXX-YYYY-ZZZZ') }

    its(:as_json) { should eq auth_token: 'XXXX-YYYY-ZZZZ' }
  end
end  
  

Go to ApplicationController and add the mistake ActiveModel::StrictValidationFailed in rescue_from:

  
  rescue_from ActiveRecord::RecordInvalid, ActiveModel::StrictValidationFailed do
    render :errors, status: :unprocessable_entity
  end
  

Then you can start writing app/controllers/sessions_controller.rb:

  
class Api::SessionsController < ApplicationController
  private
  def build_resource
    @session = Session.new resource_params
  end

  def resource
    @session ||= Session.new user: current_user
  end

  def resource_params
    params.require(:session).permit(:email, :password)
  end
end
  

Don’t forget that the actions #create and #destroy are defined in ApplicationController. In the #resource method, current_user is the object of the User class of a current user. We will tell you about its creation later.

So, a user of our online-shop can create a new session now. But how can we check it in the requests that ask for authorization? API RoR can help (the authenticate_or_request_with_http_token method if to be more precise). This method calls two other methods inside of itself: authenticate_with_http_token and request_http_token_authentication. You'll authenticate_with_http_token parses http header with the name Authorization and returns the token value. After that we need to do the token search in the database inside of the block of this method. The header should look the following way:

"Authorization: Token token="value"" Authorization - header name. 

Token means that the authorization is Token-Based.
token="" is an attribute with the token value.
The request_http_token_authentication method will render Error 401 with the text “HTTP Token: Access denied.” if the authenticate_with_http_token method returns nil.
Go to ApplicationController and add:

  
class ApplicationController < ActionController::Base
....

  before_action :authenticate

  attr_reader :current_user

  private
  def authenticate
    authenticate_or_request_with_http_token do |token, options|
      @current_user = User.joins(:auth_token).find_by(auth_tokens: { value: token })
    end
  end

....
end
  

Now all API requests ask for authorization i.e. token transferring.  But, for example, a request for a session creation shouldn’t require authorization. We need to correct it. Go to SessionsController and add:

  
class Api::SessionsController < ApplicationController
  skip_before_action :authenticate, only: [:create]

  ....
end
  

Then you also need to change ProductsController and UsersController:

  
class Api::ProductsController < ApplicationController
  skip_before_action :authenticate

  ....
end
  
  
class Api::UsersController < ApplicationController
  skip_before_action :authenticate

  ....
end
  

Great! Let’s try to do a couple of requests via curl in the command line. For example:

  
$ curl "http://localhost:3000/api/session" -H "Accept: application/json" -X POST -d "session[email]=test@test.com&session[password]=test"
  

If you have done everything right you will see a generated token on the screen. Copy it as we will need it for further requests. Let’s try to make a request that requires authorization without transferring a token.

  
$ curl "http://localhost:3000/api/session" -H "Accept: application/json" -X DELETE
  

This is what you will see on the screen:

HTTP Token: Access denied.

Let’s make the same request to delete the session, but with a token this time.

  
$ curl "http://localhost:3000/api/session" -H "Accept: application/json" -H "Authorization: Token token="your token"" -X DELETE
  

If processed successfully, this request will return an empty body with the status 200. If you try to repeat it you will see the text "HTTP Token: Access denied." because the token has been deleted from the database.

Seems like this is it for now. The tasks have been accomplished. We have done the registration and authorization for the online-shop. You are moving in the right direction and your e-commerce project is getting bigger. 

This part of the series has been republished with permission from MLSDev. The original article can be seen here.

Yuriy Blokhin I am a Ruby on Rails Developer at MLSDev Inc. - one of the Top European IT companies specializing in mobile and web apps development, UI/UX and consulting.

Comments