Створення движка для блогу з допомогою Phoenix і Elixir / Частина 3. Додавання ролей



Від перекладача: «Elixir і Phoenix — прекрасний приклад того, куди рухається сучасна веб-розробка. Вже зараз ці інструменти надають якісний доступ до технологій реального часу для веб-додатків. Сайти з підвищеною інтерактивністю, багатокористувацькі браузерні ігри, микросервисы — ті напрямки, в яких дані технології послужать хорошу службу. Далі представлений переклад серії з 11 статей, докладно описують аспекти розробки на фреймворку Фенікс здавалося б такий тривіальної речі, як блоговый движок. Але не поспішайте кукситься, буде дійсно цікаво, особливо якщо статті спонукають вас звернути увагу на Еліксир або стати його послідовниками.

У цій частині ми додамо підтримку ролей і почнемо розмежовувати права доступу. Ключовий момент цієї серії статей — тут дуже багато уваги приділяється тестів, а це здорово!

Дочитайте до кінця, щоб дізнатися, навіщо потрібно підписуватися на Wunsh.ru і як виграти вкрай корисний приз
».

На даний момент наше додаток засноване на:
  • Elixir: v1.3.1
  • Phoenix: v1.2.0
  • Ecto: v2.0.2
  • Comeonin: v2.5.2
Де ми зупинилися
Ми розлучилися з вами в минулий раз на тому, що закінчили пов'язувати пости з користувачами і почали процес з обмеження доступу до постів для користувачів, які не є їх авторами. Нам також хотілося б забезпечити дотримання користувачами правил, не чекаючи розвитку подій, при якому якийсь негідник видалить всіх користувачів, або всі пости.

Для вирішення цієї проблеми ми скористаємося досить стандартним підходом: створимо ролі.

Створення ролей
Почнемо з запуску наступної команди в терміналі:

$ mix phoenix.gen.Role model roles name:string admin:boolean

Яка повинна вивести щось схоже:

* creating web/models/role.ex
* creating test/models/role_test.exs
* creating priv/repo/migrations/20160721151158_create_role.exs

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

Пропоную скористатися порадою скрипта і відразу ж запустити команду
mix ecto.migrate
. Виходячи з припущення, що наша база даних налаштовано належним чином, ми повинні побачити аналогічний висновок:

Compiling 21 files (.ex)

Generated pxblog app

11:12:04.736 [info] == Running Pxblog.Repo.Migrations.CreateRole.change/0 forward
11:12:04.736 [info] create table roles
11:12:04.742 [info] == Migrated in 0.0 s

Ми також проженемо тести, щоб переконатися, що додавання нової моделі нічого не поламало. Якщо всі вони зелені, то рушимо далі і зв'яжемо ролі з користувачами.

Додавання зв'язку між ролями та користувачами
Основний задум, якого я дотримувався при реалізації цієї можливості — у кожного користувача може бути тільки одна роль, при цьому кожна роль належить відразу декільком користувачам. Для цього змінимо файл
web/models/user.ex
у відповідності з написаним нижче.

Усередині секції схеми «users» додамо наступний рядок:

belongs_to :role, Pxblog.Role

У цьому випадку ми збираємося розмістити зовнішній ключ role_id у таблиці users, тобто ми говоримо, що користувач «належить» ролі. Також відкриємо файл
web/models/role.ex
і додамо в секцію схеми «roles» наступну рядок:

has_many :users, Pxblog.User

Потім знову запустимо тести, але на цей раз отримаємо купу помилок. Ми сказали Ecto, що наша таблиця користувачів має зв'язок з таблицею ролей, але ніде в базі даних не визначили це. Так що нам потрібно змінити таблицю користувачів, щоб зберігати посилання на роль у поле role_id. Викличемо команду:

$ mix ecto.gen.migration add_role_id_to_users

Висновок:

Compiling 5 files (.ex)

* creating priv/repo/migrations
* creating priv/repo/migrations/20160721184919_add_role_id_to_users.exs

Давайте відкриємо новостворений файл міграції. За замовчуванням він виглядає так:


defmodule Pxblog.Repo.Migrations.AddRoleIdToUsers do
use Ecto.Migration
def change do
end
end

Нам потрібно внести кілька корективів. Почнемо з зміни таблиці users. Додамо в неї посилання на ролі таким чином:


alter table(:users) do
add :role_id, references(:roles)
end

Нам також потрібно додати індекс на полі role_id:

create index(:users, [:role_id])

Нарешті, виконаємо команду
mix ecto.migrate
знову. Міграція повинна пройти успішно! Якщо ми запустимо тести тепер, всі вони знову будуть зеленими!

На жаль, наші тести не ідеальні. Передусім ми не змінювали їх разом з моделями Post/User. Тому не можемо бути впевненими в тому, що, наприклад, у поста обов'язково визначений користувач. Аналогічно, у нас не повинно бути можливості створювати користувачів без ролі. Змінимо функцію changeset у файлі
web/models/user.ex
наступним чином (зверніть увагу на додавання :role_id в двох місцях):


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


Створення хелперу для тестів
У результаті запуску тестів зараз можна отримати велику кількість помилок, але це нормально! Нам потрібно проробити багато роботи, щоб привести їх в порядок. І почнемо ми з додавання нікого тестового хелперу, який позбавить нас від написання одного й того ж коду знову і знову. Створимо новий файл
test/support/test_helper.ex
і заповнимо його наступним кодом:


defmodule Pxblog.TestHelper do
alias Pxblog.Repo
alias Pxblog.User
alias Pxblog.Role
alias Pxblog.Post

import Ecto, only: [build_assoc: 2]

def create_role(%{name: name, admin: admin}) do
Role.changeset(%Role{}, %{name: name, admin: admin})
|> Repo.insert
end

def create_user(role, %{email: email, username: username, password: password, password_confirmation: password_confirmation}) do
role
|> build_assoc(:users)
|> User.changeset(%{email: email, username: username, password: password, password_confirmation: password_confirmation})
|> Repo.insert
end

def create_post(user, %{title: title, body: body}) do
user
|> build_assoc(:posts)
|> Post.changeset(%{title: title, body: body})
|> Repo.insert
end
end

Перед тим, як рушимо правити тести далі, давайте поговоримо про те, що файл не робить. Перше, на що слід звернути увагу — це те, куди ми його поклали. А саме в директорію test/support, в яку ми так само можемо класти будь-які модулі, щоб зробити їх доступними нашим тестів в цілому. Нам як і раніше потрібно буде посилатися на цей хелпер з кожного тестового файлу, але так і повинно бути!

Отже, спочатку ми вказуємо аліаси для модулів Repo, User, Role та Post, щоб вкоротити синтаксис для їх виклику. Потім імпортуємо Ecto, щоб отримати доступ до функції build_assoc для створення асоціацій.

У функції create_role ми очікуємо отримати на вхід словник, що включає назву ролі і прапор адміністратора. Так як ми скористалися тут функцією
Repo.insert
, значить отримаємо на виході стандартний відповідь
{:ok, model}
при успішному додавання. Іншими словами це просто вставка ревізії Role.

Ми починаємо рухатися по ланцюжку вниз з отриманої на вхід ролі, яку передаємо далі для створення моделі користувача (т. до. ми визначили :users в якості асоціації), на основі якої створюємо ревізію User з згаданими раніше параметрами. Кінцевий результат передаємо в функцію
Repo.insert()
і все готово!

Хоч і будучи складним для пояснення, ми маємо справу з супер читаним і супер зрозумілим кодом. Отримуємо роль, створюємо пов'язаного з нею користувача, готуємо його для додавання в базу даних і потім безпосередньо додаємо!

У функції create_post ми робимо аналогічні речі, за винятком того, що замість користувача та ролі ми працюємо з постом і користувачем!

Виправляємо тести
Почнемо з редагування файлу
test/models/user_test.exs
. Спершу нам потрібно додати
alias Pxblog.TestHelper
в самий верх визначення модуля, що дозволить використовувати зручні хелпери, створені нами трохи раніше. Потім ми створимо блок setup перед тестами, щоб повторно використовувати роль.


setup do
{:ok, role} = TestHelper.create_role(%{name: "user", admin: false})
{:ok, role: role}
end

А потім у першому ж тесті за допомогою зіставлення із зразком, ми отримаємо роль з блоку setup. Давайте зекономимо собі ще трохи часу і напишемо функцію-хелпер для отримання валідних атрибутів разом з роллю:


defp valid_attrs(role) do
Map.put(@valid_attrs, :role_id, role.id)
end

test "changeset valid with attributes", %{role: role} do
changeset = User.changeset(%User{}, valid_attrs(role))
assert changeset.valid?
end

Підіб'ємо підсумок. Ми зіставляємо з зразком ключ role, що отримується з блоку setup, а потім змінюємо ключ valid_attrs, щоб включити валідність роль в наш хелпер! Як тільки ми змінимо цей тест і запустимо його знову, то відразу ж повернемося до зеленого станом файлу
test/models/user_test.exs
.

Тепер відкрийте файл
test/controllers/user_controller_test.exs
. Для проходження тестів з нього ми скористаємося тими ж самими уроками. У самий верх додамо інструкцію
alias Pxblog.Role
, а також
alias Pxblog.TestHelper
слідом. Після чого розташуємо блок setup, в якому створюється роль і повертається об'єкт conn:

setup do
{:ok, user_role} = TestHelper.create_role(%{name: "user", admin: false})
{:ok, admin_role} = TestHelper.create_role(%{name: "admin", admin: true})
{:ok, conn: build_conn(), user_role: user_role, admin_role: admin_role}
end

Додамо хелпер valid_create_attrs, що приймає роль в якості аргументу, і повертає новий словник валідних атрибутів з доданим role_id.


defp valid_create_attrs(role) do
Map.put(@valid_create_attrs, :role_id, role.id)
end

Нарешті зробимо так, щоб дії create та update використовували цей хелпер, а також зіставлення із зразком значення user_role з нашого словника.


test "creates resource and redirects when data is valid", %{conn: conn, user_role: user_role} do
conn = post conn, user_path(conn, :create), user: valid_create_attrs(user_role)
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, user_role: user_role} do
user = Repo.insert! %User{}
conn = put conn, user_path(conn, :update user), user: valid_create_attrs(user_role)
assert redirected_to(conn) == user_path(conn, :show user)
assert Repo.get_by(User, @valid_attrs)
end

Тепер всі тести контролера користувачів повинні проходити! Тим не менш, запуск
mix test
і раніше показує помилки.

Виправляємо тести контролера постів
Роботу з тестами PostController ми завершили на додавання купи хелперів, що полегшують створення постів з користувачами. Тому тепер нам потрібно додати в них концепцію ролей, щоб можна було створювати валідних користувачів. Почнемо з додавання посилання на Pxblog.Role в самий верх файлу
test/controllers/post_controller_test.exs
:


alias Pxblog.Role
alias Pxblog.TestHelper

Потім створимо блок setup, трохи відрізняється від того, що ми робили раніше.


setup do
{:ok, role} = TestHelper.create_role(%{name: "User Role", admin: false})
{:ok, user} = TestHelper.create_user(role, %{email: "test@test.com", username: "testuser", password: "test", password_confirmation: "test"})
{:ok, post} = TestHelper.create_post(user, %{title: "Test Post", body: "Test Body"})
conn = build_conn() |> login_user(user)
{:ok, conn: conn, user: user, role: role, post: post}
end

Перше, що ми зробили тут — створили стандартну роль без адміністраторських привілеїв. На наступній сходинці на основі цієї ролі ми створили користувача. Потім для цього користувача створили посаду. Ми вже обговорювали шматок зі входом, так що йдемо далі. Нарешті, ми повертаємо всі тільки що створені моделі, щоб кожен з тестів за допомогою зіставлення із зразком вибрав з них ті, що йому потрібні.

Нам потрібно змінити лише один тест, щоб все знову стало зеленим. Тест «redirects when trying to edit a post for a different user» падає тому, що намагається створити на льоту ще одного користувача, нічого не знаючи про ролі. Трохи поправимо його:


test "redirects when trying to edit a post for a different user", %{conn: conn, user: user, role: role, post: post} do
{:ok, other_user} = TestHelper.create_user(role, %{email: "test2@test.com", username: "test2", password: "test", password_confirmation: "test"})
conn = get conn, user_post_path(conn, :edit, other_user, post)
assert get_flash(conn, :error) == "You are not authorized to modify that post!"
assert redirected_to(conn) == page_path(conn, :index)
assert conn.halted
end

Отже, ми додали отримання ролі через зіставлення із зразком у визначенні тесту, а потім трохи змінили створення other_user, щоб і тут використовувався TestHelper разом з отриманої роллю.

У нас з'явилася можливість для рефакторінгу завдяки тому, що ми додали об'єкт post з TestHelper в якості одного із значень, які можна отримати за допомогою зіставлення із зразком. Тому ми можемо змінити всі виклики build_post об'єкт post, отриманий таким чином. Після всіх змін файл повинен виглядати наступним чином:


defmodule Pxblog.PostControllerTest do
use Pxblog.ConnCase

alias Pxblog.Post
alias Pxblog.TestHelper

@valid_attrs %{body: "some content", title: "some content"}
@invalid_attrs %{}

setup do
{:ok, role} = TestHelper.create_role(%{name: "User Role", admin: false})
{:ok, user} = TestHelper.create_user(role, %{email: "test@test.com", username: "testuser", password: "test", password_confirmation: "test"})
{:ok, post} = TestHelper.create_post(user, %{title: "Test Post", body: "Test Body"})
conn = build_conn() |> login_user(user)
{:ok, conn: conn, user: user, role: role, post: post}
end

defp login_user(conn, user) do
post conn, session_path(conn, :create), user: %{username: user.username, password: user.password}
end

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

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

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

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

test "shows chosen resource", %{conn: conn, user: user, post: post} do
conn = get conn, user_post_path(conn, :show user, post)
assert html_response(conn, 200) =~ "Show post"
end

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

test "renders form for editing chosen resource", %{conn: conn, user: user, post: post} do
conn = get conn, user_post_path(conn, :edit user, post)
assert html_response(conn, 200) =~ "Edit post"
end

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

test "does not update chosen resource and renders errors when data is invalid", %{conn: conn, user: user, post: post} do
conn = put conn, user_post_path(conn, update, user, post), post: %{"body" => nil}
assert html_response(conn, 200) =~ "Edit post"
end

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

test "redirects when the specified user does not exist", %{conn: conn} do
conn = get conn, user_post_path(conn, :index, -1)
assert get_flash(conn, :error) == "Invalid user!"
assert redirected_to(conn) == page_path(conn, :index)
assert conn.halted
end

test "redirects when trying to edit a post for a different user", %{conn: conn, role: role, post: post} do
{:ok, other_user} = TestHelper.create_user(role, %{email: "test2@test.com", username: "test2", password: "test", password_confirmation: "test"})
conn = get conn, user_post_path(conn, :edit, other_user, post)
assert get_flash(conn, :error) == "You are not authorized to modify that post!"
assert redirected_to(conn) == page_path(conn, :index)
assert conn.halted
end
end


Исравляем тести для Session Controller
Деякі тести з файлу
test/controllers/session_controller_test.exs
не проходять з-за того, що ми не сказали їм почати використовувати наш TestHelper. Як і раніше додамо аліаси наверх файлу і змінимо блок setup:


defmodule Pxblog.SessionControllerTest do
use Pxblog.ConnCase

alias Pxblog.User
alias Pxblog.TestHelper

setup do
{:ok, role} = TestHelper.create_role(%{name: "user", admin: false})
{:ok, _user} = TestHelper.create_user(role, %{username: "test", password: "test", password_confirmation: "test", email: "test@test.com"})
{:ok, conn: build_conn()}
end

Цього повинно бути достатньо, щоб тести почали проходити! Ура!

Виправляємо залишилися тести
У нас є два зламаних тесту. Так зробимо ж їх зеленими!

1) test current user returns the user in the session (Pxblog.LayoutViewTest)
test/views/layout_view_test.exs:13
Expected truthy, got nil
code: LayoutView.current_user(conn)
stacktrace:
test/views/layout_view_test.exs:15

2) test current user returns nothing if there is no user in the session (Pxblog.LayoutViewTest)
test/views/layout_view_test.exs:18
** (ArgumentError) cannot convert nil to param
stacktrace:
(phoenix) lib/phoenix/param.ex:67: Phoenix.Param.Atom.to_param/1
(pxblog) web/router.ex:1: Pxblog.Router.Helpers.session_path/4
test/views/layout_view_test.exs:20

У самому верху файлу
test/views/layout_view_test.exs
можна побачити, як створюється користувач без ролі! У блоці setup ми також не повертаємо цього користувача, що і призводить до таких сумних наслідків! Жах! Так що давайте швидше отрефакторим весь файл:


defmodule Pxblog.LayoutViewTest do
use Pxblog.ConnCase, async: true

alias Pxblog.LayoutView
alias Pxblog.TestHelper

setup do
{:ok, role} = TestHelper.create_role(%{name: "User Role", admin: false})
{:ok, user} = TestHelper.create_user(role, %{email: "test@test.com", username: "testuser", password: "test", password_confirmation: "test"})
{:ok, conn: build_conn(), user: user}
end

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

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

Тут ми додаємо аліас для моделі Role, створюємо валідність роль, створюємо валідного користувача з цією роллю і потім повертаємо отриманого користувача з об'єктом conn. І в кінці кінців в обох тестових функціях ми отримуємо користувача за допомогою зіставлення із зразком. Тепер запускаємо
mix test

Всі тести зелені! Але ми отримали кілька попереджень (т. к. занадто перестаралися з турботою про чистому коді).

test/controllers/post_controller_test.exs:20: warning: function create_user/0 is unused
test/views/layout_view_test.exs:6: warning: unused alias Role
test/views/layout_view_test.exs:5: warning: unused alias User
test/controllers/user_controller_test.exs:5: warning: unused alias Role
test/controllers/post_controller_test.exs:102: warning: variable user is unused
test/controllers/post_controller_test.exs:6: warning: unused alias Role


Для виправлення просто зайдіть в кожен з цих файлів і видалити проблемні аліаси і функції, т. к. вони нам більше не потрібні!

$ mix test

Висновок:

.........................................
Finished in seconds 0.4
41 tests, 0 failures
Randomized with seed 588307


Створюємо початкові дані адміністратора
Зрештою нам потрібно дозволити створювати нових користувачів лише адміністратору. Але тим самим це означає, що виникне ситуація, при якій спочатку ми не зможемо створити ні користувачів, ні адміністраторів. Вилікуємо цю недугу за допомогою додавання початкових даних (seed data), для адміністратора за замовчуванням. Для цього відкриємо файл
priv/repo/seeds.exs
і вставимо в нього наступний код:


alias Pxblog.Repo
alias Pxblog.Role
alias Pxblog.User

role = %Role{}
|> Role.changeset(%{name: "Admin Role", admin: true})
|> Repo.insert!
admin = %User{}
|> User.changeset(%{username: "admin", email: "admin@test.com", password: "test", password_confirmation: "test", role_id: role.id})
|> Repo.insert!

І потім завантажимо наші сіди з допомогою виконання наступної команди:

$ mix run priv/repo/seeds.exs

далі
Тепер, коли наші моделі налаштовані та готові взаємодіяти з ролями, а всі тести знову зелені, нам потрібно почати додавати контролерам функціонал для обмеження певних операцій, якщо у користувача немає прав для їх виконання. В наступному пості ми розглянемо як найкраще реалізувати цей функціонал, як додати модуль-хелпер, і звичайно ж, як утримати тести зеленими.

Висновок від Вуншей
У нас відразу дві відмінні новини! По-перше, нас стає більше, і завдяки цьому всі передплатники вже завтра отримають на пошту нову статтю (яка не відноситься до даного циклу) про те, чому Еліксир притягує розробників і завдяки чому їх утримує. Так що, якщо ви ще не підписалися, то не втрачайте часу і швидше робіть це!

По-друге, ми вирішили розіграти супер-корисний подарунок — книгу Dave Thomas «Programming Elixir». Чудове введення в мову (і далеко не тільки для новачків) від гуру навчальної літератури з програмування Дейва Томаса. Для того, щоб дістати її, вам необхідно опублікувати пост на Хабрахабре на тему мови Elixir і вказати, що стаття опублікована спеціально для конкурсу від Wunsh.ru. Переможцем стане людина, чия стаття набере найбільший рейтинг. Детальні умови читайте за посиланням.

Успіхів у вивченні, залишайтеся з нами!
Джерело: Хабрахабр

0 коментарів

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