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

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

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

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

В этой части мы добавим поддержку ролей и начнём разграничивать права доступа. Ключевой момент данной серии статей — здесь очень много внимания уделяется тестам, а это здорово!

Дочитайте до конца, чтобы узнать, зачем нужно подписываться на Wunsh.ru и как выиграть крайне полезный приз».

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

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

Где мы остановились

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

Для решения этой проблемы мы воспользуемся довольно стандартным подходом: создадим роли.

Создание ролей

Начнём с запуска следующей команды в терминале:

$ mix phoenix.gen.model Role 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.0s

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

Добавление связи между ролями и пользователями

Основной замысел, которому я следовал при реализации этой возможности — у каждого пользователя может быть только одна роль, при этом каждая роль принадлежит сразу нескольким пользователям. Для этого изменим файл 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 with valid 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 0.4 seconds
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. Победителем станет человек, чья статья наберёт наибольший рейтинг. Подробные условия читайте по ссылке.

Успехов в изучении, оставайтесь с нами!

Автор: jarosluv

Источник

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


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