Статьи

API электронной коммерции Ruby on Rails для начинающих — часть 3

Добро пожаловать! Наша кухня разработки Ruby on Rails снова в этом. Мы надеемся, что вы готовы изучить следующую часть рецепта. Как обычно, вам не понадобятся блюда, ложки или ножи. Только ты и твой компьютер, готовый к программированию и созданию.  

Тем, кто пропустил  первую  и  вторую  части нашего урока, не забудьте прочитать их в первую очередь.

Третий класс посвящен  регистрации и авторизации . Они нужны каждой платформе электронной коммерции, поэтому мы не можем пропустить это важное требование. Безопасные покупки в Интернете очень важны и должны быть максимально частными и удобными.

Начнем с регистрации. Для начала создайте модель  User  с такими полями, как  name: stringemail: stringpassword_digest: string . Для этого выполните в консоли следующую команду:

$ rails generate model User name email password_digest  

Затем не забудьте запустить другую команду: 

$ rake db:migrate  

Перейдите в файл  app / models / user.rb  и добавьте строку кода:

class User < ActiveRecord::Base
  has_secure_password
end  

Давайте   подробно рассмотрим метод has_secure_password . Он используется для сохранения пароля, его проверки и шифрования (для него используется криптографическая хеш-функция BCrypt). Этот метод работает с полем  password_digest,  поэтому мы назвали его следующим. has_secure_password  автоматически соединяет 3 проверки:

  1. Пароль должен быть введен во время создания. 
  2. Длина пароля должна быть не более 72 символов.
  3. Подтверждение пароля  (для этого используется атрибут  password_confirmation ).

Чтобы  has_secure_password  работал правильно, вам необходимо подключить   гем bcrypt . Перейдите в Gemfile и добавьте туда следующее:

gem 'bcrypt'  

Не забудьте про команду: 

$ bundle install  

Давайте также добавим валидации к полям  name  и  email . Мы будем использовать  гем email_validator  для проверки электронной почты. Его описание можно найти здесь:  https://github.com/balexand/email_validator . Подключите его и напишите валидации в модели  User .

class User < ActiveRecord::Base
  has_secure_password

  validates :name, presence: true

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

Теперь мы должны покрыть только что созданную модель тестами. Для  него  будет использован драгоценный камень musta-matchers . Описание здесь:  https://github.com/thoughtbot/shoulda-matchers . Перейдите в файл  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  

Не забудьте запустить тесты:

$ rake  

Давайте создадим  синглтон-ресурс  api / user  с  действием  #create . Перейдите в  config / rout.rb  и код: 

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

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

Затем создайте контроллер  users_controller.rb  в каталоге  app / controllers / api  и напишите в нем следующее:

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  

Нам не нужно определять  метод действия  #create,  как мы уже создали и определили его в  ApplicationController  в  первой части  нашего урока.

def create
  build_resource

  resource.save!
end  

Вот почему этого будет достаточно, чтобы переопределить частные методы. После этого накройте контроллер тестами. Создайте файл  spec / controllers / api / users_controller_spec.rb  и напишите тест:

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  

В качестве следующего шага вам нужно создать  UserDecorator  и написать там метод  #as_json,  чтобы в ответе на этот запрос вернулись правильные данные.

Мы уже делали то же самое в  первой части  урока при создании продуктов.

class UserDecorator < Draper::Decorator
  delegate_all

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

Не забудьте прикрыть декоратор тестами:

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  

Затем создайте  app / view / application / create.json.erb  :

<%= sanitize resource.decorate.to_json %>  

После этого вам нужно подумать, как показать пользователю ошибки проверки. Перейдите в  ApplicationController,  и вы увидите, что метод  #save!  вызывается в   методе #create в строке: 

resource.save!  

Этот метод довольно своеобразен: если при сохранении ресурса произойдет сбой, он  вызовет  ошибку ActiveRecord :: RecordInvalid . Благодаря этому мы можем перехватить (спасти) эту ошибку в  ApplicationController :

class ApplicationController < ActionController::Base
...

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

...
end  

Это означает, что если вы не пройдете валидацию, будет отображен шаблон «Ошибки» со статусом 422 (необработанный объект).

Давайте создадим шаблон  app / view / application / errors.json.erb :

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

Последнее, что нам нужно сделать, это перейти к  ApplicationController  и добавить:

class ApplicationController < ActionController::Base
...

  skip_before_action :verify_authenticity_token, if: :json_request?

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

Это отключит защиту от  подделки межсайтовых запросов  для запросов в   формате json .

Превосходно! Запрос на регистрацию готов. Давайте проверим его производительность с помощью тестов:

$ rake  

Если ошибок не возникает, мы можем проверить их производительность с помощью   команды curl :

$ 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"  

Вы должны увидеть следующее на экране:

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

Попробуйте повторить тот же запрос, и вы увидите ошибку: 

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

Выполнено. Регистрация завершена, и мы можем перейти к следующему пункту нашей сегодняшней задачи и создать авторизацию для наших пользователей. Для этого мы будем использовать аутентификацию на основе токенов

Сначала нам нужно создать   модель AuthToken со значением поля  : строка  и с внешним ключом  user_id . Мы будем хранить пользовательские токены здесь. Запустите следующую команду в консоли:

$ rails generate model AuthToken  

Затем перейдите к файлу миграции  (отметка времени) _create_auth_tokens.rb  и напишите:

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  

После этого запустите команду:

$ rake db:migrate  

Откройте сгенерированную модель  AuthToken  и добавьте туда код:

class AuthToken < ActiveRecord::Base
  belongs_to :user

  validates :value, presence: true
end  

Добавьте следующее в   модель User :

class User < ActiveRecord::Base
...

  has_one :auth_token, dependent: :destroy

...
end  

Это означает, что мы определили соединение один к одному для моделей  User  и  AuthTokenЗависимый  вариант с  уничтожить  средства значения , что при удалении записи пользователя из базы данных связная запись из  auth_tokens  таблицы будет удалены тоже.
 
Также добавьте проверку для  auth_token  и затем напишите тесты для  модели AuthToken :

require 'rails_helper'

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

  it { should validate_presence_of :value }
end  

Продолжите добавление теста в  модель User :

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

В качестве следующего шага создайте файл / сеанс синглтон-  ресурса  с действием  #create  #destroy . Перейдите в  config / rout.rb :

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

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

Перед созданием  SessionsController нам нужно создать класс  Session , в котором будет сгенерирован токен для новой версии и проведена проверка сеанса. Поместите этот класс в  каталог / lib . Для этого нам нужно написать способ загрузки файла в  config / application.rb :

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

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

Теперь мы можем начать писать класс  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  

Давайте проанализируем код. Конструктор ( метод initialize ) принимает хеш в качестве параметра и записывает в экземпляр такие переменные, как  @email@user  и  @password . Два метода get ( электронная почта  и  пароль ) также определены в строке:

  attr_reader :email, :password  

ActiveModel :: Validations  позволяет использовать валидации в этом классе. Следующие методы связаны:  ошибкиневерно? Действует? подтвердитьvalidates_with .

Поиск пользователя по электронной почте осуществляется в   методе #user . Если это успешно,  @user  записывается в экземпляр, если ничего не найдено, то это  ноль

Нам нужно проверить валидацию, чтобы убедиться, что есть такой пользователь с адресом электронной почты и правильно ли введен пароль.

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

Если пользователь недействителен,  #save!  Метод возвращает   ошибку ActiveModel :: StrictValidationFailed . В противном случае создается новый токен.

#Destroy!  Метод удаляет токен пользователя. Метод  #auth_token  возвращает значение токена.

Давайте определим   методы #as_json  и  #decorate вместо использования декоратора.

Нам нужно покрыть этот класс   тестами spec / lib / session_spec.rb .

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    

Перейдите в  ApplicationController  и добавьте ошибку  ActiveModel :: StrictValidationFailed  в  rescue_from :

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

Затем вы можете начать писать  app / controllers / session_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  

Не забывайте, что действия  #create  и  #destroy  определены в  ApplicationController .

В   методе  #resource current_user  является объектом   класса User текущего пользователя. О его создании мы расскажем позже.

Таким образом, пользователь нашего интернет-магазина теперь может создать новый сеанс. Но как мы можем проверить это в запросах, которые запрашивают авторизацию? Может помочь API RoR (  метод authenticate_or_request_with_http_token, если быть более точным). Этот метод вызывает два других метода внутри себя:  authenticate_with_http_token  и  request_http_token_authentication .

authenticate_with_http_token  анализирует заголовок http с именем  Authorization  и возвращает значение токена. После этого нам нужно выполнить поиск токена в базе данных внутри блока этого метода. Заголовок должен выглядеть следующим образом:

«Authorization: Token token =» value «» Authorization — имя заголовка. 

Токен  означает, что авторизация основана на токене.
token = «»  — это атрибут со значением токена. Request_http_token_authentication  метод визуализации Ошибка 401 с текстом «HTTP Токен: отказано в доступе.» если   метод authenticate_with_http_token возвращает  ноль .

Перейдите в  ApplicationController  и добавьте:

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 

Теперь все запросы API запрашивают авторизацию, т.е. передачу токена. Но, например, запрос на создание сеанса не должен требовать авторизации. Нам нужно это исправить. Перейдите в  SessionsController  и добавьте:

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

  ....
end  

Затем вам также нужно изменить  ProductsController  и  UsersController :

class Api::ProductsController < ApplicationController
  skip_before_action :authenticate

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

  ....
end  

Большой! Давайте попробуем сделать пару запросов через  curl  в командной строке. Например:

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

Если вы все сделали правильно, вы увидите сгенерированный токен на экране. Скопируйте, как нам понадобится для дальнейших запросов.

Попробуем сделать запрос, требующий авторизации, без передачи токена.

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

Вот что вы увидите на экране:

HTTP-токен: доступ запрещен.

Давайте сделаем тот же запрос на удаление сеанса, но с токеном на этот раз.

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

При успешной обработке этот запрос вернет пустое тело со статусом 200. Если вы попытаетесь повторить его, вы увидите текст «HTTP-токен: доступ запрещен». потому что токен был удален из базы данных.

Ну, пока об этом. Сегодняшние задачи были выполнены. Мы сделали регистрацию и авторизацию для интернет-магазина. 

Вы движетесь в правильном направлении, и ваш  проект электронной коммерции  растет. Не пропустите следующую часть урока. 

Мы продолжим наш урок в 2016 году! Команда разработчиков MLS желает вам счастливого Рождества и счастливого Нового года! Приятного отдыха, сохраняйте спокойствие и используйте Ruby!

Ваше домашнее задание — выполнить тесты для кода, который не рассматривается в этой части!

Ссылки:

API электронной коммерции Ruby on Rails на GitHub

Блог разработчиков MLS:

API электронной коммерции Ruby on Rails для начинающих — часть 1

API электронной коммерции Ruby on Rails для начинающих — часть 2

API электронной коммерции Ruby on Rails для начинающих — часть 3