От переводчика: «Elixir и Phoenix — прекрасный пример того, куда движется современная веб-разработка. Уже сейчас эти инструменты предоставляют качественный доступ к технологиям реального времени для веб-приложений. Сайты с повышенной интерактивностью, многопользовательские браузерные игры, микросервисы — те направления, в которых данные технологии сослужат хорошую службу. Далее представлен перевод серии из 11 статей, подробно описывающих аспекты разработки на фреймворке Феникс казалось бы такой тривиальной вещи, как блоговый движок. Но не спешите кукситься, будет действительно интересно, особенно если статьи побудят вас обратить внимание на Эликсир либо стать его последователями.
В этой части мы закончим рутинную работу над комментариями, чтобы затем перейти к более интересным вещам.
На данный момент наше приложение основано на:
- Elixir: v1.3.1
- Phoenix: v1.2.0
- Ecto: v2.0.2
На чём мы остановились
Предыдущая часть закончилась созданием небольшого милого интерфейса комментариев. С его помощью можно просматривать список комментариев к постам и добавлять новые. Но ему всё ещё не хватает некоторых возможностей.
- Во-первых, следует скрывать кнопки «Approve/Delete» для гостей, а также не показывать их у комментариев от автора поста или администратора.
- Во-вторых, необходимо в принципе заставить кнопки «Approve/Delete» выполнять свои функции.
- И, в-третьих, нужно перестать показывать неодобренные комментарии гостям.
Начнём с настройки модели авторизации, как это предписывают 1 и 3 пункты.
Показываем комментарии с учётом флага авторизации
Откройте файл web/controllers/post_controller.ex. Опустившись вниз, вы увидите кучу авторизационной логики, которая у нас уже есть. Вместо добавления новой, давайте-ка немного зарефакторим этот код, тем самым подготовив его для повторного использования.
Сначала перенесём проверку авторизации в приватную функцию:
defp is_authorized_user?(conn) do
user = get_session(conn, :current_user)
(user && (Integer.to_string(user.id) == conn.params["user_id"] || Pxblog.RoleChecker.is_admin?(user)))
end
А затем обратимся к ней из функции authorize_user:
defp authorize_user(conn, _opts) do
if is_authorized_user?(conn) do
conn
else
conn
|> put_flash(:error, "You are not authorized to modify that post!")
|> redirect(to: page_path(conn, :index))
|> halt
end
end
Запустим тесты и убедимся, что ничего не поломали.
$ mix test
Compiled web/controllers/post_controller.ex
.......................................................................
Finished in 1.3 seconds (0.5s on load, 0.7s on tests)
71 tests, 0 failures
Randomized with seed 501973
Чудесно! Затем нужно добавить новый плаг для установки флага авторизации, который мы будем использовать в шаблоне. Для этого возьмём только что написанную функцию:
# Add to the top of the controller with the other plug declarations
plug :set_authorization_flag
# Add to the bottom with the other plug definitions
defp set_authorization_flag(conn, _opts) do
assign(conn, :author_or_admin, is_authorized_user?(conn))
end
Мы можем проверить работоспособность двумя путями:
- Закинуть весь код в шаблоны и посмотреть что получится.
- Начать писать тесты.
Вместо проверки всего вручную, давайте всё-таки напишем тесты (эй, тесты нужно писать в любом случае)!
Написание тестов для проверки авторизации
Избавимся от первоначального теста «Shows chosen resource», взамен которого добавим четыре новых: когда функция is_authorized_user? возвращает true для автора или администратора, и false для гостя или пользователя, вошедшего с ошибкой. Нам нужно изменить блок setup, добавить функцию выхода и написать несколько новых тестов. Для этого в файле test/controllers/post_controller_test.exs напишем:
setup do
role = insert(:role)
user = insert(:user, role: role)
other_user = insert(:user, role: role)
post = insert(:post, user: user)
admin_role = insert(:role, admin: true)
admin = insert(:user, role: admin_role)
conn = build_conn() |> login_user(user)
{:ok, conn: conn, user: user, other_user: other_user, role: role, post: post, admin: admin}
end
defp login_user(conn, user) do
post conn, session_path(conn, :create), user: %{username: user.username, password: user.password}
end
defp logout_user(conn, user) do
delete conn, session_path(conn, :delete, user)
end
А затем добавим несколько тестов на новую логику:
test "when logged in as the author, shows chosen resource with author flag set to true", %{conn: conn, user: user, post: post} do
conn = login_user(conn, user) |> get(user_post_path(conn, :show, user, post))
assert html_response(conn, 200) =~ "Show post"
assert conn.assigns[:author_or_admin]
end
test "when logged in as an admin, shows chosen resource with author flag set to true", %{conn: conn, user: user, admin: admin, post: post} do
conn = login_user(conn, admin) |> get(user_post_path(conn, :show, user, post))
assert html_response(conn, 200) =~ "Show post"
assert conn.assigns[:author_or_admin]
end
test "when not logged in, shows chosen resource with author flag set to false", %{conn: conn, user: user, post: post} do
conn = logout_user(conn, user) |> get(user_post_path(conn, :show, user, post))
assert html_response(conn, 200) =~ "Show post"
refute conn.assigns[:author_or_admin]
end
test "when logged in as a different user, shows chosen resource with author flag set to false", %{conn: conn, user: user, other_user: other_user, post: post} do
conn = login_user(conn, other_user) |> get(user_post_path(conn, :show, user, post))
assert html_response(conn, 200) =~ "Show post"
refute conn.assigns[:author_or_admin]
end
Теперь снова запустите тесты, чтобы убедиться – всё по-прежнему хорошо:
$ mix test
..........................................................................
Finished in 1.2 seconds (0.5s on load, 0.7s on tests)
74 tests, 0 failures
Randomized with seed 206903
Чудесно! Теперь, когда мы уверены в работе флага, возьмёмся за UI.
Подключаем кнопку Delete
Начнём с изменения вывода комментариев в шаблоне Post Show (файл web/templates/post/show.html.eex):
<div class="comments">
<h2>Comments</h2>
<%= for comment <- @post.comments do %>
<%= render Pxblog.CommentView,
"comment.html",
comment: comment,
author_or_admin: @conn.assigns[:author_or_admin],
conn: @conn,
post: @post
%>
<% end %>
</div>
Обратите внимание сколько всего мы сделали в функции render. Вначале было решено не использовать @author_or_admin, так как мы не можем быть уверенными, что параметр будет установлен. Вместо этого мы воспользовались более безопасной функцией @conn.assigns. Затем нам нужно передать в хелпер генерации путей @conn и @post.
Теперь шаблон комментария сможет считывать созданный нами флаг, так что перейдём к его редактированию (файл web/templates/comment/comment.html.eex). Здесь нужно сделать две вещи:
- Если пользователь – автор или администратор, отображаем комментарий. Если нет – не показываем ничего. Чтобы этого добиться, обернём вывод комментария некоторым условием.
- Если пользователь – автор или администратор, отображаем кнопки “Approve/Delete”.
Начнём с первого шага. Вверху шаблона добавьте строчку:
<%= if @conn.assigns[:author_or_admin] || @comment.approved do %>
(Снова обратите внимание на использование conn.assigns
вместо простого @value
). Внизу шаблона добавьте:
<% end %>
Затем найдите кнопки Approve/Delete и измените содержащий их div:
<div class="col-xs-4 text-right">
<%= if @conn.assigns[:author_or_admin] do %>
<%= unless @comment.approved do %>
<button class="btn btn-xs btn-primary approve">Approve</button>
<% end %>
<button class="btn btn-xs btn-danger delete">Delete</button>
<% end %>
</div>
Финальная версия шаблона должна выглядеть так:
<%= if @conn.assigns[:author_or_admin] || @comment.approved do %>
<div class="comment">
<div class="row">
<div class="col-xs-4">
<strong><%= @comment.author %></strong>
</div>
<div class="col-xs-4">
<em><%= @comment.inserted_at %></em>
</div>
<div class="col-xs-4 text-right">
<%= if @conn.assigns[:author_or_admin] do %>
<%= unless @comment.approved do %>
<button class="btn btn-xs btn-primary approve">Approve</button>
<% end %>
<button class="btn btn-xs btn-danger delete">Delete</button>
<% end %>
</div>
</div>
<div class="row">
<div class="col-xs-12">
<%= @comment.body %>
</div>
</div>
</div>
<% end %>
Теперь попробуйте открыть пост в двух различных окнах браузера: в одном – авторизованным пользователем, а в другом – гостем. Для первого будут показаны комментарии на проверке и кнопки, для второго – только одобренные комментарии.
Мы очень близки к завершению работы над комментариями, так что давайте докуём последний кусочек: настроим кнопки одобрения и удаления.
Настраиваем кнопку Delete
Чтобы заставить кнопку удаления работать, нужно убедиться, что контроллер поддерживает действие delete. Откройте файл web/controllers/comment_controller.ex и пролистните его вниз до функции delete. Прямо сейчас она возвращает только conn
, так что нужно изменить её на что-то подобное:
def delete(conn, %{"id" => id, "post_id" => post_id}) do
post = Repo.get!(Post, post_id) |> Repo.preload(:user)
Repo.get!(Comment, id) |> Repo.delete!
conn
|> put_flash(:info, "Deleted comment!")
|> redirect(to: user_post_path(conn, :show, post.user, post))
end
И теперь можно написать тест на удаление комментариев. Для этого откройте файл test/controllers/comment_controller_test.exs. Первым делом добавим строчку alias Pxblog.Comment, если её не было ранее. Затем добавим вставку комментария в блоке setup:
setup do
user = insert(:user)
post = insert(:post, user: user)
comment = insert(:comment)
{:ok, conn: build_conn(), user: user, post: post, comment: comment}
end
И наконец напишем сам тест:
test "deletes the comment", %{conn: conn, post: post, comment: comment} do
conn = delete(conn, post_comment_path(conn, :delete, post, comment))
assert redirected_to(conn) == user_post_path(conn, :show, post.user, post)
refute Repo.get(Comment, comment.id)
end
Теперь запустим тесты, всё должно быть зелёным. Осталось сделать только саму кнопку Delete Вернитесь в файл web/templates/comment/comment.html.eex и замените код кнопки Delete на следующий
<%= link "Delete",
method: :delete,
to: post_comment_path(@conn, :delete, @post, @comment),
class: "btn btn-xs btn-danger delete",
data: [confirm: "Are you sure you want to delete this comment?"]
%>
Здесь так много всего, что нужно остановиться подробнее. Сначала мы устанавливаем текст для кнопки. Затем говорим ей использовать метод DELETE вместо GET. А затем – куда отправить команду на удаление (вы можете использовать mix phoenix.routes для получения списка доступных маршрутов). Дальше определяем CSS-классы и устанавливаем всплывающее окошко с подтверждением действия.
Вот и всё! Проверьте функциональность самостоятельно. Если тесты зелёные, то значит мы закончили с работой над кнопкой Delete!
Настраиваем кнопку Approve
Реализация кнопки подтверждения будет немного более сложной. Нам нужно попробовать понять, в чём вообще суть обновления флага «одобрено». Начнём с написания кода для функции update в контроллере комментариев (файл web/controllers/comment_controller.ex):
def update(conn, %{"id" => id, "post_id" => post_id, "comment" => comment_params}) do
post = Repo.get!(Post, post_id) |> Repo.preload(:user)
comment = Repo.get!(Comment, id)
changeset = Comment.changeset(comment, comment_params)
case Repo.update(changeset) do
{:ok, _} ->
conn
|> put_flash(:info, "Comment updated successfully.")
|> redirect(to: user_post_path(conn, :show, post.user, post))
{:error, _} ->
conn
|> put_flash(:info, "Failed to update comment!")
|> redirect(to: user_post_path(conn, :show, post.user, post))
end
end
И напишем проверку на изменение статуса «approved» в файле test/controllers/comment_controller_test.exs:
defp login_user(conn, user) do
post conn, session_path(conn, :create), user: %{username: user.username, password: user.password}
end
test "updates chosen resource and redirects when data is valid and logged in as the author", %{conn: conn, user: user, post: post, comment: comment} do
conn = login_user(conn, user) |> put(post_comment_path(conn, :update, post, comment), comment: %{"approved" => true})
assert redirected_to(conn) == user_post_path(conn, :show, user, post)
assert Repo.get_by(Comment, %{id: comment.id, approved: true})
end
Перезапустим тесты. Всё должно стать зелёным! Теперь нам нужно
изменить кнопку «Approve», чтобы нажатие по ней отправляло запрос функции update с параметром approve установленным в true. Заменим её в файле web/templates/comment/comment.html.eex следующим кодом:
<%= form_for @conn, post_comment_path(@conn, :update, @post, @comment), [method: :put, as: :comment, style: "display: inline;"], fn f -> %>
<%= hidden_input f, :approved, value: "true" %>
<%= submit "Approve", class: "btn btn-xs btn-primary approve" %>
<% end %>
Это создаст маленькую формочку с единственной целью – обеспечить работу кнопки обновления. Оно отправляется через PUT, отображает инлайновую форму и устанавливает значение «approved» в true через скрытое поле. Мы близки к совершенству, за исключением того, что технически ЛЮБОЙ может изменять/удалять/подтверждать комментарии! Нам нужно добавить авторизацию в контроллер комментариев, чтобы приложение оставалось в безопасности!
Добавление авторизации в контроллер комментариев
Скопируем функции authorize_user и is_authorized_user? из PostController в CommentController (в файле web/controllers/comment_controller.ex), но с некоторыми изменениями. Для начала нам нужно убедиться, что мы на самом деле работаем с ожидаемым постом, так что изменим authorize_user на set_post_and_authorize_user:
defp set_post(conn) do
post = Repo.get!(Post, conn.params["post_id"]) |> Repo.preload(:user)
assign(conn, :post, post)
end
defp set_post_and_authorize_user(conn, _opts) do
conn = set_post(conn)
if is_authorized_user?(conn) do
conn
else
conn
|> put_flash(:error, "You are not authorized to modify that comment!")
|> redirect(to: page_path(conn, :index))
|> halt
end
end
defp is_authorized_user?(conn) do
user = get_session(conn, :current_user)
post = conn.assigns[:post]
user && (user.id == post.user_id) || Pxblog.RoleChecker.is_admin?(user))
end
И нам понадобится добавить плаг наверх контроллера:
plug :set_post_and_authorize_user when action in [:update, :delete]
Запустим тесты. Получим несколько ошибок в зависимости от того вошли мы в систему до обновления/удаления или нет, так что давайте изменим наши тесты (в файле test/controllers/comment_controller_test.exs):
test "deletes the comment when logged in as an authorized user", %{conn: conn, user: user, post: post, comment: comment} do
conn = login_user(conn, user) |> delete(post_comment_path(conn, :delete, post, comment))
assert redirected_to(conn) == user_post_path(conn, :show, post.user, post)
refute Repo.get(Comment, comment.id)
end
test "does not delete the comment when not logged in as an authorized user", %{conn: conn, post: post, comment: comment} do
conn = delete(conn, post_comment_path(conn, :delete, post, comment))
assert redirected_to(conn) == page_path(conn, :index)
assert Repo.get(Comment, comment.id)
end
test "updates chosen resource and redirects when data is valid and logged in as the author", %{conn: conn, user: user, post: post, comment: comment} do
conn = login_user(conn, user) |> put(post_comment_path(conn, :update, post, comment), comment: %{"approved" => true})
assert redirected_to(conn) == user_post_path(conn, :show, user, post)
assert Repo.get_by(Comment, %{id: comment.id, approved: true})
end
test "does not update the comment when not logged in as an authorized user", %{conn: conn, post: post, comment: comment} do
conn = put(conn, post_comment_path(conn, :update, post, comment), comment: %{"approved" => true})
assert redirected_to(conn) == page_path(conn, :index)
refute Repo.get_by(Comment, %{id: comment.id, approved: true})
end
И теперь запустим наши тесты:
$ mix test
Compiling 1 file (.ex)
...........................................................................
Finished in 1.1 seconds
75 tests, 0 failures
Randomized with seed 721237
Вот и всё! Все наши кнопки настроены и работают, и мы должным образом показываем/скрываем неподтверждённые посты и действия, в зависимости от того, авторизован пользователь или нет!
Заключение
Сейчас у нас есть полностью работающая часть комментариев с небольшой администраторской панелью. Есть много других возможностей, которые мы можем начать сюда добавлять. Например, систему живых комментариев (за которую мы примемся в следующей части), обновлённый дизайн нашей блоговой системы (для чего вникнем в Brunch), и вообще пробежимся по коду и немного почистим/зарефакторим его (например, сейчас у нас есть тонна дублирующего кода с пользовательской аутентификацией)! Также хотелось бы дать пользователям возможность входить через сторонние системы и добавить поддержку RSS-фидов, импорт из других платформ и т.п.