Ruby on Rails E-commerce API for Beginners. Part 3.

Hi, all! Our Ruby on Rails development kitchen is back. 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 remark for those who missed the first and the second parts of our tutorial: don’t forget to read them first :)
The third class 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 hassecurepassword 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:
- A password has to be entered while creating.
- The password length has to be equal to or less than 72 symbols.
- The password confirmation (the attribute password_confirmation is used for that).
For the correct work of hassecurepassword 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. 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. 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 createapp/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 error 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
.
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:
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.
Our Ruby on Rails development team wishes you a Merry Christmas and a Happy New Year! Enjoy your life, keep calm and code in Ruby :)
P.S. Your home assignment is to do tests for the code that is not covered with them in this part :)
Looking for a professional team of back-end developers?
At MLSDev, we have talented and skilled back-end developers who are ready to deal with the most challenging tasks and create great products. Contact us to discuss your project.