Создание движка для блога с помощью Phoenix и Elixir – Часть 1

в 5:11, , рубрики: Elixir, Erlang/OTP, phoenix, phoenix framework, ruby, ruby on rails, Разработка веб-сайтов, функциональное программирование

Создание движка для блога с помощью Phoenix и Elixir - Часть 1 - 1

От переводчика: «Elixir и Phoenix — прекрасный пример того, куда движется современная веб-разработка. Уже сейчас эти инструменты предоставляют качественный доступ к технологиям реального времени для веб-приложений. Сайты с повышенной интерактивностью, многопользовательские браузерные игры, микросервисы — те направления, в которых данные технологии сослужат хорошую службу. Далее представлен перевод серии из 11 статей, подробно описывающих аспекты разработки на фреймворке Феникс казалось бы такой тривиальной вещи, как блоговый движок. Но не спешите кукситься, будет действительно интересно, особенно если статьи побудят вас обратить внимание на Эликсир либо стать его последователями».

На данный момент наше приложение основано на:

  • Elixir: v1.3.1
  • Phoenix: v1.2.0
  • Ecto: v2.0.2
  • Comeonin: v2.5.2

Установка Phoenix

Лучшая инструкция по установке Phoenix находится на его официальном сайте.

Шаг 1. Добавление постов

Начнём с запуска mix-задачи для создания нового проекта под названием «pxblog». Для этого выполним команду `mix phoenix.new [project] [command]`. Отвечаем утвердительно на все вопросы, так как нам подойдут настройки по умолчанию.

mix phoenix.new pxblog

Вывод:

* creating pxblog/config/config.exs
...
Fetch and install dependencies? [Yn] y
* running mix deps.get
* running npm install && node node_modules/brunch/bin/brunch build

We are all set! Run your Phoenix application:
$ cd pxblog
$ mix phoenix.server

You can also run your app inside IEx (Interactive Elixir) as:
$ iex -S mix phoenix.server

Before moving on, configure your database in config/dev.exs and run:
$ mix ecto.create

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

Если у вас не создана база данных Postgres или приложение не настроено на работу с ней, команда `mix ecto.create` выкинет ошибку. Для её исправления откройте файл config/dev.exs и просто измените имя пользователя и пароль для роли, которая имеет права на создание базы данных:

# Configure your database
config :pxblog, Pxblog.Repo,
  adapter: Ecto.Adapters.Postgres,
  username: "postgres",
  password: "postgres",
  database: "pxblog_dev",
  hostname: "localhost",
  pool_size: 10

Когда всё заработает, давайте запустим сервер и убедимся, что всё хорошо.

$ iex -S mix phoenix.server

Для этого перейдём по адресу http://localhost:4000/ и увидим страницу Welcome to Phoenix!. Хорошая основа готова. Давайте добавим к ней главный скаффолд для работы с постами, так как всё же у нас блоговый движок.

Воспользуемся встроенным в Phoenix генератором для создания Ecto-модели, миграции и интерфейса обработки CRUD-операций модуля Post. Так как на данный момент это очень и очень простой движок, то ограничимся заголовком и сообщением. Заголовок будет строкой, а сообщение — текстом. Команда, которая создаст это всё за нас довольно простая:

$ mix phoenix.gen.html Post posts title:string body:text

Вывод:

* creating web/controllers/post_controller.ex
...
Add the resource to your browser scope in web/router.ex:
    resources "/posts", PostController

Remember to update your repository by running migrations:
    $ mix ecto.migrate

Получаем ошибку! Чтобы исправить её, а заодно сделать интерфейс постов доступным из браузера, давайте откроем файл web/router.ex, и добавим в root-скоуп следующую строчку:

resources "/posts", PostController

Теперь можем ещё раз выполнить команду и убедиться, что наша миграция прошла успешно.

$ mix ecto.migrate

Вывод:

Compiling 9 files (.ex)

Generated pxblog app

15:52:20.004 [info]  == Running Pxblog.Repo.Migrations.CreatePost.change/0 forward
15:52:20.004 [info]  create table posts
15:52:20.019 [info]  == Migrated in 0.0s

И, наконец, перезапускаем наш сервер заходим на страницу http://localhost:4000/posts, где мы должны увидеть заголовок Listing posts, а также список наших постов.

Немного повозившись мы получили возможность добавлять новые посты, редактировать их и удалять. Довольно круто для такой маленькой работы!

Шаг 1Б. Написание тестов для постов

Одной из прелестей работы со скаффолдами является то, что с самого начала мы получаем набор базовых тестов. Нам даже не нужно особо их изменять до тех пор, пока мы не начнём вносить серьёзные изменения в код самого приложения. Сейчас давайте разберём какие тесты были созданы, чтобы лучше понять как писать собственные.

Во-первых, откроем файл test/models/post_test.exs и взглянем на содержимое:

defmodule Pxblog.PostTest do
  use Pxblog.ModelCase
  
  alias Pxblog.Post
  
  @valid_attrs %{body: "some content", title: "some content"}
  @invalid_attrs %{}
  
  test "changeset with valid attributes" do
    changeset = Post.changeset(%Post{}, @valid_attrs)
    assert changeset.valid?
  end
  
  test "changeset with invalid attributes" do
    changeset = Post.changeset(%Post{}, @invalid_attrs)
    refute changeset.valid?
  end
end

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

defmodule Pxblog.PostTest do

Очевидно, что нам нужно определить тестовый модуль в пространстве имён нашего приложения.

use Pxblog.ModelCase

Далее, мы говорим этому модулю использовать функции и DSL, представленные в наборе макросов ModelCase.

alias Pxblog.Post

Теперь убеждаемся, что тест может обращаться к модели напрямую.

@valid_attrs %{body: “some content”, title: “some content”}

Настраиваем основные действительные атрибуты, которые позволят успешно создать ревизию (changeset). Это просто переменная уровня модуля, которую мы можем дёргать каждый раз, когда хотим создать валидную модель.

@invalid_attrs %{}

Как и выше, но создаём набор недействительных атрибутов.

test "changeset with valid attributes" do
  changeset = Post.changeset(%Post{}, @valid_attrs)
  assert changeset.valid?
end

Теперь, мы непосредственно создаём наш тест функцией test, дав ему строковое имя. Внутри тела нашей функции сначала мы создаём из модели Post ревизию (передавая пустую структуру и список действительных параметров). Затем с помощью функции assert проверяем валидность ревизии. Это как раз то, для чего нам нужна переменная @valid_attrs.

test "changeset with invalid attributes" do
  changeset = Post.changeset(%Post{}, @invalid_attrs)
  refute changeset.valid?
end

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

Это очень хороший пример того, как написать тест модели. Теперь давайте посмотрим на тест контроллера. Взглянув на него мы должны увидеть примерно следующее:

defmodule Pxblog.PostControllerTest do
  use Pxblog.ConnCase

  alias Pxblog.Post
  
  @valid_attrs %{body: "some content", title: "some content"}
  @invalid_attrs %{}
  ...
end

Здесь используется Pxblog.ConnCase для получения специального DSL уровня контроллера. Остальные строки должны быть уже знакомы.

Посмотрим на первый тест:

test "lists all entries on index", %{conn: conn} do
  conn = get conn, post_path(conn, :index)
  assert html_response(conn, 200) =~ "Listing posts"
end

Здесь мы захватываем переменную conn, которая должна быть отправлена через блок настройки в ConnCase. Я объясню это позже. Следущий шаг — воспользоваться одноименной подходящему HTTP-методу функцией, чтобы совершить запрос по нужному пути (GET-запрос к действию :index в нашем случае). Затем мы проверяем, что ответ данного действия возвращает HTML со статусом 200 («OK») и содержит фразу Listing posts.

test "renders form for new resources", %{conn: conn} do
  conn = get conn, post_path(conn, :new)
  assert html_response(conn, 200) =~ "New post"
end

Следующий тест по сути такой же, только уже проверяется действие new. Всё просто.

test "creates resource and redirects when data is valid", %{conn: conn} do
  conn = post conn, post_path(conn, :create), post: @valid_attrs
  assert redirected_to(conn) == post_path(conn, :index)
  assert Repo.get_by(Post, @valid_attrs)
end

А здесь мы делаем что-то новенькое. Во-первых, отправляем POST-запрос со списком действительных параметров по адресу post_path. Мы ожидаем получить перенаправление на список постов (действие :index). Функция redirected_to принимает в качестве аргумента объект соединения, так как нам нужно знать, куда произошло перенаправление.

Наконец, мы утверждаем, что объект, представленный этими действительными параметрами был успешно добавлен в базу данных. Эта проверка осуществляется через запрос к репозиторию Ecto Repo на поиск модели Post, соответствующей нашим параметрам @valid_attrs.

test "does not create resource and renders errors when data is invalid", %{conn: conn} do
  conn = post conn, post_path(conn, :create), post: @invalid_attrs
  assert html_response(conn, 200) =~ "New post"
end

Теперь снова попробуем создать пост, но уже с недействительным списком параметров @invalid_attrs, и проверим, что снова отобразится форма создания поста.

test "shows chosen resource", %{conn: conn} do
  post = Repo.insert! %Post{}
  conn = get conn, post_path(conn, :show, post)
  assert html_response(conn, 200) =~ "Show post"
end

Чтобы протестировать действие show, нам нужно создать модель Post, с которой и будем работать. Затем вызовем функцию get с хелпером post_path, и убедимся, что возвращается соответствующий ресурс.

Также мы можем попытаться запросить путь к ресурсу, которого не существует, следующим образом:

test "renders page not found when id is nonexistent", %{conn: conn} do
  assert_error_sent 404, fn ->
    get conn, post_path(conn, :show, -1)
  end
end

Здесь используется другой шаблон записи теста, который на самом деле довольно прост для понимания. Мы описываем ожидание того, что запрос несуществующего ресурса приведёт к ошибке 404. Туда же передаём анонимную функцию, содержащую код, который при выполнении должен вернуть эту самую ошибку. Всё просто!

Остальные тесты лишь повторяют вышеперечисленное для оставшихся путей. А вот на удалении остановимся подробнее:

test "deletes chosen resource", %{conn: conn} do
  post = Repo.insert! %Post{}
  conn = delete conn, post_path(conn, :delete, post)
  assert redirected_to(conn) == post_path(conn, :index)
  refute Repo.get(Post, post.id)
end

В целом, всё похоже, за исключением использования HTTP-метода delete. Мы утверждаем, что должны быть перенаправлены со страницы удаления обратно к списку постов. Ещё мы используем здесь новую фишку — «отвергаем» существование объекта Post с помощью функции refute.

Шаг 2. Добавляем пользователей

Для создания модели User, мы пройдём практически те же шаги, что и при создании модели постов, за исключением добавления других столбцов. Для начала выполним:

$ mix phoenix.gen.html User users username:string email:string password_digest:string

Вывод:

* creating web/controllers/user_controller.ex
...
Add the resource to your browser scope in web/router.ex:
    resources "/users", UserController

Remember to update your repository by running migrations:
    $ mix ecto.migrate

Далее откроем файл web/router.ex и добавим следующую строчку в тот же скоуп, что и ранее:

resources "/users", UserController

Синтаксис здесь определяет стандартный ресурсовый путь, где первым аргументом идёт URL, а вторым — имя класса контроллера. Затем выполним:

$ mix ecto.migrate

Вывод:

Compiling 11 files (.ex)
Generated pxblog app
16:02:03.987 [info]  == Running Pxblog.Repo.Migrations.CreateUser.change/0 forward
16:02:03.987 [info]  create table users
16:02:03.996 [info]  == Migrated in 0.0s

Наконец, перезагружаем сервер и проверяем http://localhost:4000/users. Теперь, помимо постов, мы имеем возможность добавлять и пользователей!

К сожалению, пока это не очень полезный блог. В конце концов, хоть мы и можем создавать пользователей (к сожалению, сейчас это сможет сделать любой), мы не можем даже войти. Кроме того, дайджест пароля не использует никаких алгоритмов шифрования. Мы тупо храним тот текст, что ввёл пользователь! Совсем не круто!

Давайте приведём этот экран в вид более похожий на регистрацию.

Наши тесты пользователей выглядят точно так же, как и то, что было автоматически сгенерировано для наших постов, так что пока мы оставим их в покое, до тех пор, пока не начнём модифицировать логику (а этим мы займёмся прямо сейчас!).

Шаг 3. Сохранение хеша пароля, вместо самого пароля

Открыв адрес /users/new, мы видим три поля: Username, Email и PasswordDigest. Но ведь когда вы регистрируетесь на других сайтах, вас просят ввести не дайджест пароля, а сам пароль вместе с его подтверждением! Как мы можем это исправить?

В файле web/templates/user/form.html.eex удалите следующие строки:

<div class=”form-group”>
  <%= label f, :password_digest, class: “control-label” %>
  <% = Text_input е,: password_digest, класс: "Форма-контроль"%>
  <% = Error_tag е,: password_digest%>
</div>

И добавьте на их место:

<div class="form-group">
  <%= label f, :password, "Password", class: "control-label" %>
  <%= password_input f, :password, class: "form-control" %>
  <%= error_tag f, :password %>
</div>

<div class="form-group">
  <%= label f, :password_confirmation, "Password Confirmation", class: "control-label" %>
  <%= password_input f, :password_confirmation, class: "form-control" %>
  <%= error_tag f, :password_confirmation %>
</div>

После обновления страницы (должно происходить автоматически) введите данные пользователя и нажмите кнопку Submit.

Упс, ошибка:

Oops, something went wrong! Please check the errors below.

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

Начнём с изменения схемы. В файле web/models/user.ex добавим пару строк:

schema "users" do
  field :username, :string
  field :email, :string
  field :password_digest, :string
  
  timestamps

  # Virtual Fields
  field :password, :string, virtual: true
  field :password_confirmation, :string, virtual: true  
end

Обратите внимание на добавление двух полей :password и :password_confirmation. Мы объявили их в качестве виртуальных полей, так как на самом деле их не существует в нашей базе данных, но они должны существовать как свойства в структуре User. Это также позволяет применять преобразования в нашей функции changeset.

Затем мы добавим :password и :password_confirmation в список обязательных полей:

def changeset(struct, params \ %{}) do
  struct
  |> cast(params, [:username, :email, :password, :password_confirmation])
  |> validate_required([:username, :email, :password, :password_confirmation])
end

Если вы сейчас попробуете запустить тесты из файла test/models/user_test.exs, то тест “changeset with valid attributes” упадёт. Это происходит потому, что мы добавили :password и :password_confirmation к обязательным параметрам, но не обновили @valid_attrs. Давайте изменим эту строчку:

@valid_attrs %{email: "[email protected]", password: "test1234", password_confirmation: "test1234", username: "testuser"}

Наши тесты моделей должны снова проходить! Теперь нужно починить тесты контроллеров. Внесём некоторые изменения в файл test/controllers/user_controller_test.exs. Вначале выделим валидные атрибуты для создания объекта в отдельную переменную:

@valid_create_attrs %{email: "[email protected]", password: "test1234", password_confirmation: "test1234", username: "testuser"}
@valid_attrs %{email: "[email protected]", username: "testuser"}

Затем изменим наш тест на создание пользователя:

test "creates resource and redirects when data is valid", %{conn: conn} do
  conn = post conn, user_path(conn, :create), user: @valid_create_attrs
  assert redirected_to(conn) == user_path(conn, :index)
  assert Repo.get_by(User, @valid_attrs)
end

И тест на обновление пользователя:

test "updates chosen resource and redirects when data is valid", %{conn: conn} do
  user = Repo.insert! %User{}
  conn = put conn, user_path(conn, :update, user), user: @valid_create_attrs
  assert redirected_to(conn) == user_path(conn, :show, user)
  assert Repo.get_by(User, @valid_attrs)
end

После того, как наши тесты снова стали зелёными, нам нужно добавить в changeset функцию, преобразующую пароль в дайджест:

def changeset(struct, params \ %{}) do
  struct
  |> cast(params, [:username, :email, :password, :password_confirmation])
  |> validate_required([:username, :email, :password, :password_confirmation])
  |> hash_password
end

defp hash_password(changeset) do
  changeset
  |> put_change(:password_digest, "ABCDE")
end

Пока мы просто стабим поведение нашей хеширующей функции. Первым делом давайте убедимся, что изменение ревизии происходит корректно. Вернёмся в браузер на страницу http://localhost:4000/users, кликнем на ссылку New user и создадим нового пользователя с любыми данными. Теперь в списке пользователей нас ожидает новая строка, дайджест пароля в которой равен ABCDE.

Снова запустим тесты этого файла. Они проходят, но не хватает теста на проверку работы функции hash_password. Давайте добавлять:

test "password_digest value gets set to a hash" do
  changeset = User.changeset(%User{}, @valid_attrs)
  assert get_change(changeset, :password_digest) == "ABCDE"
end

Это большой шаг вперёд для приложения, но не такой большой для безопасности! Нужно скорее исправить хеширование пароля на настоящее с использованием BCrypt, любезно предоставленной библиотекой Comeonin.

Для этого откройте файл mix.exs и добавьте :comeonin в список applications:

def application do
    [mod: {Pxblog, []},
     applications: [:phoenix, :phoenix_pubsub, :phoenix_html, :cowboy, :logger, :gettext, :phoenix_ecto, :postgrex, :comeonin]]
end

Также нам нужно изменить наши зависимости. Обратите внимание на {:comeonin, “~> 2.3”}:

defp deps do
  [{:phoenix, "~> 1.2.0"},
   {:phoenix_pubsub, "~> 1.0"},
   {:phoenix_ecto, "~> 3.0"},
   {:postgrex, ">= 0.0.0"},
   {:phoenix_html, "~> 2.6"},
   {:phoenix_live_reload, "~> 1.0", only: :dev},
   {:gettext, "~> 0.11"},
   {:cowboy, "~> 1.0"},
   {:comeonin, "~> 2.3"}]
end

Теперь отключим запущенный сервер и выполним команду `mix deps.get`. Если все пройдет хорошо (а оно должно!), то командой `iex -S mix phoenix.server` вы снова сможете запустить сервер.

Наш старый метод hash_password неплох, но вообще-то нам нужно, чтобы пароль хешировался на самом деле. Так как мы добавили библиотеку Comeonin, которая предоставляет нам прекрасный модуль Bcrypt с методом hashpwsalt, который мы импортируем в нашу модель User. В файле web/models/user.ex добавьте приведённую ниже строчку сразу следом за use Pxblog.Web, :model:

import Comeonin.Bcrypt, only: [hashpwsalt: 1]

Что мы сейчас сделали? Мы вытянули модуль Bcrypt из пространства имён Comeonin и импортировали метод hashpwsalt с арностью 1. А следующим кодом мы заставим функцию hash_password работать:

defp hash_password(changeset) do
  if password = get_change(changeset, :password) do
    changeset
    |> put_change(:password_digest, hashpwsalt(password))
  else
    changeset
  end
end

Предлагаю попробовать создать пользователя ещё раз! На этот раз, после регистрации мы должны увидеть шифрованный дайджест в поле password_digest!

Теперь давайте немного доработаем функцию hash_password. Во-первых, чтобы шифрование пароля не тормозило тестирование, необходимо внести изменения в настройки тестового окружения. Для этого откройте файл config/test.exs и добавьте следующую строчку в самый низ:

config :comeonin, bcrypt_log_rounds: 4

Это скажет библиотеке Comeonin не слишком сильно шифровать пароль во время выполнения тестов, поскольку в тестах нам важнее скорость, чем безопасность! А в продакшене (файл config/prod.exs) нам наоборот нужно усилить защиту:

config :comeonin, bcrypt_log_rounds: 14

Давайте напишем тест для вызова Comeonin. Мы сделаем его менее подробным, так как хотим лишь убедиться в работе шифрования. В файле test/models/user_test.exs:

test "password_digest value gets set to a hash" do
  changeset = User.changeset(%User{}, @valid_attrs)
  assert Comeonin.Bcrypt.checkpw(@valid_attrs.password, Ecto.Changeset.get_change(changeset, :password_digest))
end

Для улучшения покрытия тестами, давайте рассмотрим случай, когда строчка `if the password = get_change() не является истиной:

test "password_digest value does not get set if password is nil" do
  changeset = User.changeset(%User{}, %{email: "[email protected]", password: nil, password_confirmation: nil, username: "test"})
  refute Ecto.Changeset.get_change(changeset, :password_digest)
end

В данном случае поле password_digest должно оставаться пустым, что и происходит! Мы проделываем хорошую работу, покрывая наш код тестами!

Шаг 4. Давайте войдём!

Добавим новый контроллер SessionController и сопутствующее представление SessionView. Начнём с простого, а со временем придём к более правильной реализации.

Создайте файл web/controllers/session_controller.ex:

defmodule Pxblog.SessionController do
  use Pxblog.Web, :controller

  def new(conn, _params) do
    render conn, "new.html"
  end
end

А также web/views/session_view.ex:

defmodule Pxblog.SessionView do
  use Pxblog.Web, :view
end

И напоследок web/templates/session/new.html.eex:

<h2>Login</h2>

Следующую строку добавьте в скоуп "/":

resources "/sessions", SessionController, only: [:new]

Тем самым мы включаем наш новый контроллер в маршрутизатор. Единственный путь, который нам сейчас понадобится, это new, что мы явно и указываем. Опять же, нам нужно получить наиболее устойчивый фундамент наиболее простыми методами.

Перейдя по адресу http://localhost:4000/sessions/new, мы должны увидеть под заголовком Phoenix framework подзаголовок Login.

Добавим сюда настоящую форму. Для этого создадим файл web/templates/session/form.html.eex:

<%= form_for @changeset, @action, fn f -> %>
  <%= if f.errors != [] do %>
    <div class="alert alert-danger">
      <p>Oops, something went wrong! Please check the errors below:</p>
      <ul>
        <%= for {attr, message} <- f.errors do %>
          <li><%= humanize(attr) %> <%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="form-group">
    <label>Username</label>
    <%= text_input f, :username, class: "form-control" %>
  </div>

  <div class="form-group">
    <label>Password</label>
    <%= password_input f, :password, class: "form-control" %>
  </div>

  <div class="form-group">
    <%= submit "Submit", class: "btn btn-primary" %>
  </div>
<% end %>

И вызовем только что созданию форму в файле web/templates/session/new.html.eex с помощью всего лишь одной строчки:

<%= render "form.html", changeset: @changeset, action: session_path(@conn, :create) %>

Благодаря автоматической перезагрузке кода, на странице отобразится ошибка, так как мы ещё не определили переменную @changeset, которая как можно догадаться должна быть ревизией. Раз мы работаем с объектом, у которого есть поля :name и :password, давайте их и использовать!

В файле web/controllers/session_controller.ex нам необходимо добавить алиас модели User, чтобы мы спокойно могли к ней обращаться дальше. В верхней части нашего класса, прямо под строкой use Pxblog.Web, :controller добавьте следующее:

alias Pxblog.User

И в функции new измените вызов рендера, как показано ниже:

render conn, "new.html", changeset: User.changeset(%User{})

Мы должны передать сюда объект соединения, шаблон который мы хотим отрендерить (без расширения eex) и список дополнительных переменных, которые будут использоваться внутри шаблона. В данном случае нам нужно указать changeset: и передать ему ревизию Ecto для User с пустой структурой пользователя.

Обновим страничку. Теперь мы должны увидеть другую ошибку, выглядящую следующим образом:

No helper clause for Pxblog.Router.Helpers.session_path/2 defined for action :create.
The following session_path actions are defined under your router:    
*:new

В нашей форме мы ссылаемся на путь, которого пока не существует. Мы используем хелпер session_path, передавая ему объект @conn, но затем указываем путь :create, который ещё только предстоит создать.

Половина пути пройдена. Теперь давайте реализуем возможность реального входа с использованием сессии. Для этого изменим наши пути.

В файле web/router.ex включим :create в описание SessionController:

resources "/sessions", SessionController, only: [:new, :create]

В файле web/controllers/session_controller.ex импортируем функцию checkpw из модуля Bcrypt библиотеки Comeonin:

import Comeonin.Bcrypt, only: [checkpw: 2]

В этой строке говорится “Импортируй из модуля Comeonin.Bcrypt только функцию checkpw с арностью 2".

А затем подключим плаг scrub_params, который будет работать с пользовательскими данными. Добавим перед нашими функциями:

plug :scrub_params, "user" when action in [:create]

scrub_params — это специальная функция, которая очищает пользовательский ввод. В случае, когда, например, какой-либо атрибут передаётся как пустая строка, scrub_params сконвертирует её в значение nil, чтобы избежать создание в базе данных записей с пустыми строками.

Следом добавим функцию для обработки действия create. Расположим её внизу модуля SessionController. Здесь будет много кода, так что давайте разберём его по частям.

В файле web/controllers/session_controller.ex:

def create(conn, %{"user" => user_params}) do
  Repo.get_by(User, username: user_params["username"])
  |> sign_in(user_params["password"], conn)
end

Первый кусочек кода Repo.get_by(User, username: user_params[“username”]) вытаскивает подходящего пользователя User из нашего репозитория Ecto Repo, если username совпадает либо возвращает nil.

Вот небольшой кусочек вывода, чтобы проверить это поведение:

iex(3)> Repo.get_by(User, username: "flibbity")
[debug] SELECT u0."id", u0."username", u0."email", u0."password_digest", u0."inserted_at", u0."updated_at" FROM "users" AS u0 WHERE (u0."username" = $1) ["flibbity"] OK query=0.7ms
nil

iex(4)> Repo.get_by(User, username: "test")
[debug] SELECT u0."id", u0."username", u0."email", u0."password_digest", u0."inserted_at", u0."updated_at" FROM "users" AS u0 WHERE (u0."username" = $1) ["test"] OK query=0.8ms
%Pxblog.User{__meta__: %Ecto.Schema.Metadata{source: "users", state: :loaded},
 email: "test", id: 15,
 inserted_at: %Ecto.DateTime{day: 24, hour: 19, min: 6, month: 6, sec: 14,
  usec: 0, year: 2015}, password: nil, password_confirmation: nil,
 password_digest: "$2b$12$RRkTZiUoPVuIHMCJd7yZUOnAptSFyM9Hw3Aa88ik4erEsXTZQmwu2",
 updated_at: %Ecto.DateTime{day: 24, hour: 19, min: 6, month: 6, sec: 14,
  usec: 0, year: 2015}, username: "test"}

Затем мы берем пользователя, и передаём его по цепочке в функцию sign_in. Мы до сих пор её не написали, так что давайте этим и займемся!

defp sign_in(user, password, conn) when is_nil(user) do
  conn
  |> put_flash(:error, "Invalid username/password combination!")
  |> redirect(to: page_path(conn, :index))
end

defp sign_in(user, password, conn) do
  if checkpw(password, user.password_digest) do
    conn
    |> put_session(:current_user, %{id: user.id, username: user.username})
    |> put_flash(:info, "Sign in successful!")
    |> redirect(to: page_path(conn, :index))
  else
    conn
    |> put_session(:current_user, nil)
    |> put_flash(:error, "Invalid username/password combination!")
    |> redirect(to: page_path(conn, :index))
  end
end

Главное, на что нужно обратить внимание — это порядок, в котором определены эти функции. Первая из них имеет охранное условие, следовательно этот метод будет выполняться только в случае истинности этого условия. Так что, если мы не нашли пользователя, то осуществим перенаправление обратно к root_path с соответствующим сообщением.

Вторая функция будет обрабатывать все остальные сценарии (когда охранное условие ложно). Мы проверяем пароль функцией checkpw. Если он правильный, то записываем пользователя в переменную сессии current_user и осуществляем перенаправление с сообщением об успешном входе. В противном случае, мы очищаем текущий сеанс пользователя, устанавливаем сообщение об ошибке, и перенаправляем к корню.

Мы можем зайти на страницу http://localhost:4000/sessions/new и проверить как оно работает. С правильными данными мы войдём внутрь, а с неправильными получим ошибку.

Нам также нужно написать тесты на этот контроллер. Создадим файл test/controllers/session_controller_test.exs и заполним его следующим кодом:

defmodule Pxblog.SessionControllerTest do
  use Pxblog.ConnCase
  alias Pxblog.User

  setup do
    User.changeset(%User{}, %{username: "test", password: "test", password_confirmation: "test", email: "[email protected]"})
    |> Repo.insert
    {:ok, conn: build_conn()}
  end

  test "shows the login form", %{conn: conn} do
    conn = get conn, session_path(conn, :new)
    assert html_response(conn, 200) =~ "Login"
  end

  test "creates a new user session for a valid user", %{conn: conn} do
    conn = post conn, session_path(conn, :create), user: %{username: "test", password: "test"}
    assert get_session(conn, :current_user)
    assert get_flash(conn, :info) == "Sign in successful!"
    assert redirected_to(conn) == page_path(conn, :index)
  end

  test "does not create a session with a bad login", %{conn: conn} do
    conn = post conn, session_path(conn, :create), user: %{username: "test", password: "wrong"}
    refute get_session(conn, :current_user)
    assert get_flash(conn, :error) == "Invalid username/password combination!"
    assert redirected_to(conn) == page_path(conn, :index)
  end

  test "does not create a session if user does not exist", %{conn: conn} do    
    conn = post conn, session_path(conn, :create), user: %{username: "foo", password: "wrong"}
    assert get_flash(conn, :error) == "Invalid username/password combination!"
    assert redirected_to(conn) == page_path(conn, :index)
  end
end

Начинаем со стандартного блока setup и довольно обычной проверки GET-запроса. Тест на создание выглядит более интересным:

test "creates a new user session for a valid user", %{conn: conn} do
  conn = post conn, session_path(conn, :create), user: %{username: "test", password: "test"}
  assert get_session(conn, :current_user)
  assert get_flash(conn, :info) == "Sign in successful!"
  assert redirected_to(conn) == page_path(conn, :index)
end

Первая строчка — это отправка POST-запроса на путь создания сессии. Затем идут проверки, установилась ли переменная сессии current_user, появилось сообщение о входе, и, наконец, осуществилось ли перенаправление. В остальных тестах мы точно так же проверяем другие пути, куда может попасть функция sign_in. Опять же всё очень просто!

Шаг 5. Улучшаем нашего current_user

Давайте изменим основной шаблон так, чтобы он отображал либо имя пользователя, либо ссылку на вход, в зависимости от того, вошёл пользователь или нет.

Для этого в файл web/views/layout_view.ex добавим хелпер, который облегчит получение информации о текущем пользователе:

def current_user(conn) do
  Plug.Conn.get_session(conn, :current_user)
end

Теперь откроем файл web/templates/layout/app.html.eex и вместо ссылки Get Started добавим следующее:

<li>
    <%= if user = current_user(@conn) do %>
      Logged in as
      <strong><%= user.username %></strong>
      <br>
      <%= link "Log out", to: session_path(@conn, :delete, user.id), method: :delete %>
    <% else %>
      <%= link "Log in", to: session_path(@conn, :new) %>
    <% end %>
</li>

Давайте снова разбирать код по шагам. Одной из первых вещей, которую нам нужно сделать — это выяснить кто текущий пользователь, предполагая, что он уже вошёл в систему. Сначала сделаем решение в лоб, а рефакторингом займёмся после. Установим пользователя из сессии прямо в нашем шаблоне. Функция get_session — это часть объекта Conn.

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

Нам также нужно вывести имя текущего пользователя. Мы храним структуру пользователя в сессионной переменной current_user, так что у нас есть возможность получить username через user.username.

Если мы не смогли найти пользователя, то просто показываем ссылку на вход. Здесь мы снова рассматриваем сессию как ресурс, так что new предоставит правильный путь для создания новой сессии.

Вы наверное заметили, что после обновления страницы мы получаем ещё одно сообщение с ошибкой об отсутствующей функции. Давайте подключим требуемый путь, чтобы Phoenix был счастлив!

В файле web/router.ex добавляем к маршрутам сессии также и :delete:

resources "/sessions", SessionController, only: [:new, :create, :delete]

Ещё нужно изменить контроллер. В файл web/controllers/session_controller.ex добавьте следующее:

def delete(conn, _params) do
  conn
  |> delete_session(:current_user)
  |> put_flash(:info, "Signed out successfully!")
  |> redirect(to: page_path(conn, :index))
end

Так как мы только что удалили ключ current_user, нам не важно какие параметры приходят, поэтому помечаем их знаком подчёркивания вначале как неиспользуемые. Также мы установили сообщение об успешном выходе и осуществили перенаправление в список постов.

Теперь мы можем войти, выйти и проверить неудачный вход. Всё идёт к лучшему! Но прежде нам нужно написать несколько тестов. Мы начнём с тестов для нашего LayoutView. Первое, что мы собираемся сделать, это прописать алиасы для модулей LayoutView и User, чтобы сократить код. Далее в блоке настройки мы создаём пользователя и добавляем его в базу данных. А затем возвращаем стандартный кортеж {:ok, conn: build_conn()}.

defmodule Pxblog.LayoutViewTest do
  use Pxblog.ConnCase, async: true
  alias Pxblog.LayoutView
  alias Pxblog.User

  setup do
    User.changeset(%User{}, %{username: "test", password: "test", password_confirmation: "test", email: "[email protected]"})
    |> Repo.insert
    {:ok, conn: build_conn()}
  end

  test "current user returns the user in the session", %{conn: conn} do
    conn = post conn, session_path(conn, :create), user: %{username: "test", password: "test"}
    assert LayoutView.current_user(conn)
  end

  test "current user returns nothing if there is no user in the session", %{conn: conn} do
    user = Repo.get_by(User, %{username: "test"})
    conn = delete conn, session_path(conn, :delete, user)
    refute LayoutView.current_user(conn)
  end
end

Теперь рассмотрим сами тесты. В первом из них мы создаём сессию и утверждаем, что функция LayoutView.current_user должна вернуть определённые данные. Во втором же рассмотрим обратную ситуацию. Мы явно удаляем сессию и опровергаем, что функция current_user возвращает пользователя.

Также мы добавили действие delete в SessionController, следовательно на это тоже нужно написать тест:

test "deletes the user session", %{conn: conn} do
  user = Repo.get_by(User, %{username: "test"})
  conn = delete conn, session_path(conn, :delete, user)
  refute get_session(conn, :current_user)
  assert get_flash(conn, :info) == "Signed out successfully!"
  assert redirected_to(conn) == page_path(conn, :index)
end

Здесь мы убеждаемся, что current_user из сессии является пустым, а также проверяем возвращаемое flash-сообщение и перенаправление.

На этом первая часть подошла к концу.

Важное заключение от переводчика

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

Обо всех неточностях, ошибках, плохом переводе, пожалуйста, пишите личными сообщениями, буду оперативно исправлять. Заранее благодарю всех участвующих.

Автор: jarosluv

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js