Створення движка для блогу з допомогою Phoenix і Elixir / Частина 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.0 s

І, нарешті, перезапускаємо наш сервер заходимо на сторінку 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 valid with 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 valid with 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.0 s

Нарешті, перезавантажуємо сервер і перевіряємо 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 valid with 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 in action [: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" З "users" AS u0 WHERE (u0."username" = $1) ["flibbity"] OK query=0.7 ms
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" З "users" AS u0 WHERE (u0."username" = $1) ["test"] OK query=0.8 ms
%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-повідомлення і перенаправлення.

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

Важливе висновок від перекладача
Мною була виконана величезна робота по переведенню як цієї статті, так і переведення всієї серії. Чому я продовжую займатися і зараз. Тому, якщо вам сподобалась сама стаття або починання в популяризації Еліксиру в рунеті, будь ласка, підтримайте статтю плюсами, коментарями та репостами. Це неймовірно важливо як для мене особисто, так і для всього співтовариства Еліксиру в цілому.

Про всі неточності, помилки, поганому перекладі, будь ласка, пишіть особистими повідомленнями, буду оперативно виправляти. Заздалегідь дякую всіх учасників.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.