📜 ⬆️ ⬇️

Creating a blog engine with Phoenix and Elixir / Part 3. Add roles



From the translator: “ Elixir and Phoenix are a great example of where modern web development is heading. Already, these tools provide high-quality access to real-time technologies for web applications. Sites with increased interactivity, multiplayer browser games, microservices - those areas in which these technologies will serve a good service. The following is a translation of a series of 11 articles that describe in detail the aspects of development on the Phoenix framework that would seem such a trivial thing as a blog engine. But do not hurry to sulk, it will be really interesting, especially if the articles encourage you to pay attention to the Elixir or become its followers.

In this part, we will add support for roles and begin to differentiate access rights. The key point of this series of articles is that a lot of attention is paid to tests, and this is great!
')
Read to the end to find out why you need to subscribe to Wunsh.ru and how to win a very useful prize . ”

At the moment, our application is based on:


Where we stayed


We broke up with you last time when we finished linking posts with users and started the process of restricting access to posts for users who are not their authors. We would also like to ensure that users follow the rules, without waiting for the development of events in which some wretch will delete all users, or all posts.

To solve this problem, we will use a fairly standard approach: create roles.

Creating roles


Let's start by running the following command in the terminal:

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

Which should display something similar:

 * 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 

I propose to use the advice of the script and immediately run the command mix ecto.migrate . Based on the assumption that our database is configured properly, we should see a similar conclusion:

 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 

We will also run the tests to make sure that adding a new model is not broken. If they are all green, then we will move on and associate the roles with users.

Add a relationship between roles and users


The main idea that I followed when implementing this feature is that each user can have only one role, and each role belongs to several users at once. To do this, modify the file web/models/user.ex as written below.

Inside the “users” section of the scheme , add the following line:

 belongs_to :role, Pxblog.Role 

In this case, we are going to place the foreign key role_id in the users table, that is, we say that the user "belongs" to the role. Also open the web/models/role.ex and add the following line to the "roles" section of the schema:

 has_many :users, Pxblog.User 

Then run the tests again, but this time we will get a bunch of errors. We told Ecto that our user table has a link to the role table, but nowhere in the database have it identified. So we need to change the user table to keep the role reference in the role_id field. Call the command:

 $ mix ecto.gen.migration add_role_id_to_users 

Conclusion:

 Compiling 5 files (.ex) * creating priv/repo/migrations * creating priv/repo/migrations/20160721184919_add_role_id_to_users.exs 

Let's open the newly created migration file. By default, it looks like this:

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

We need to make a few adjustments. Let's start by changing the users table. Add a link to the role in this way:

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

We also need to add an index to the role_id field:

 create index(:users, [:role_id]) 

Finally, run the mix ecto.migrate again. Migration must be successful! If we run the tests now, they will all be green again!

Unfortunately, our tests are not perfect. First of all, we did not change them with the Post / User models. Therefore, we cannot be sure that, for example, a user is necessarily defined for a post. Similarly, we should not be able to create users without a role. Let's change the changeset function in the web/models/user.ex as follows (note the addition : role_id in two places):

 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 

Creating a helper for tests


As a result of running tests, you can now get a large number of errors, but this is normal! We need to do a lot of work to get them in order. And we start by adding a certain test helper that will save us from writing the same code over and over again. Create a new test/support/test_helper.ex and fill it with the following code:

 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 

Before we proceed to edit the tests further, let's talk about what this file does. The first thing you should pay attention to is where we put it. Namely, in the test / support directory, in which we can also put any modules to make them available to our tests in general. We still need to reference this helper from each test file, but it should be so!

So, first we specify aliases for the Repo , User , Role and Post modules to shorten the syntax for calling them. Then import Ecto to access the build_assoc function to create associations.

In the create_role function , we expect to receive a dictionary for input, which includes the name of the role and the administrator flag. Since we used the Repo.insert function Repo.insert , then we’ll get the standard answer {:ok, model} when the addition is successful. In other words, this is simply the insertion of a Role revision.

We begin to move down the chain from the role received to the input, which we pass on to create a user model (because we have defined : users as an association), on the basis of which we create a User revision with the parameters mentioned earlier. The end result is passed to the Repo.insert() function and everything is ready!

Though difficult to explain, we are dealing with super readable and super clear code. We get the role, create the associated user, prepare it for adding to the database and then directly add it!

In the create_post function , we do the same thing, except that instead of the user and the role, we work with the post and the user!

We fix the tests


Let's start by editing the file test/models/user_test.exs . First we need to add alias Pxblog.TestHelper to the very top of the module definition, which will allow us to use convenient helpers that we created a little earlier. Then we will create a setup block before the tests to reuse the role.

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

And then in the first test with the help of pattern matching, we will get the role from the setup block. Let's save ourselves some more time and write a helper function to get valid attributes along with the role:

 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 

Summarize. We match the sample key role , obtained from the setup block, and then change the valid_attrs key to include a valid role in our helper! As soon as we change this test and run it again, we will immediately return to the green state of the test/models/user_test.exs .

Now open the file test/controllers/user_controller_test.exs . To pass tests from it, we will use the same lessons. At the very top, add the alias Pxblog.Role instruction, as well as the alias Pxblog.TestHelper next. Then we place the setup block in which the role is created and the conn object is returned :

 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 

Add the valid_create_attrs helper, which takes a role as an argument, and returns a new dictionary of valid attributes with the role_id added.

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

Finally, let's make the create and update actions use this helper, as well as pattern matching the user_role value from our dictionary.

 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 

Now all user controller tests should pass! However, running the mix test still shows errors.

Correcting post controller tests


We ended up working with PostController tests on adding a bunch of helpers that make it easy to create posts with users. Therefore, now we need to add the concept of roles to them so that we can create valid users. Let's start by adding a link to Pxblog.Role to the very top of the test/controllers/post_controller_test.exs :

 alias Pxblog.Role alias Pxblog.TestHelper 

Then create a setup block, slightly different from what we did earlier.

 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 

The first thing we did here was to create a standard role without administrative privileges. On the next line, based on this role, we created a user. Then for this user created a post. We have already discussed the piece with the entrance, so go ahead. Finally, we return all the models we have just created so that each of the tests selects the ones that are needed by using the pattern matching.

We only need to change one test so that everything turns green again. The “redirects when trying to try a different user” test fails because it tries to create another user on the fly without knowing the role. We'll fix it a little bit:

 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 

So, we added getting the role through pattern matching in the test definition, and then slightly modified the creation of other_user , so that TestHelper was used here along with the resulting role.

We have the opportunity to refactor because we added a post object from TestHelper as one of the values ​​that can be obtained by pattern matching. Therefore, we can change all build_post calls to a post object obtained in this way. After all changes, the file should look like this:

 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 

Eliminate tests for Session Controller


Some tests from the test/controllers/session_controller_test.exs file do not pass due to the fact that we did not tell them to start using our TestHelper . As before, add aliases to the top of the file and change the setup block:

 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 

That should be enough for the tests to start! Hooray!

We fix the remaining tests


We still have two broken dough. So let's make them green!

 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 

At the very top of the test/views/layout_view_test.exs you can see how a user is created without a role! In the setup block, we also do not return this user, which leads to such sad consequences! Horror! So let's quickly refactor the entire file:

 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 

Here we add an alias for the Role model, create a valid role, create a valid user with this role, and then return the resulting user with the object conn . And finally, in both test functions, we get the user through pattern matching. Now run the mix test and ...

All tests are green! But we received a few warnings (because overdone with taking care of clean code).

 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 

To fix it, just go to each of these files and delete the problematic aliases and functions, since we don't need them anymore!

 $ mix test 

Conclusion:

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

Create initial admin data


Ultimately, we need to allow the creation of new users only by the administrator. But this means that a situation will arise in which initially we will not be able to create either users or administrators. We cure this ailment by adding seed data for the administrator by default. To do this, open the priv/repo/seeds.exs and paste the following code into it:

 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! 

And then load our sides with the following command:

 $ mix run priv/repo/seeds.exs 

What will happen next


Now that our models are configured and ready to interact with the roles, and all the tests are green again, we need to start adding functionality to the controllers to restrict certain operations if the user does not have rights to perform them. In the next post we will look at how best to implement this functionality, how to add a helper module, and of course, how to keep the tests green.

Other series articles


  1. Introduction
  2. Authorization
  3. Adding Roles
  4. Process roles in controllers
  5. We connect ExMachina
  6. Markdown support
  7. Add comments
  8. We finish with comments
  9. Channels
  10. Channel testing
  11. Conclusion


Conclusion from the Wuns


We have two great news at once! First, there are more of us, and thanks to this, all subscribers will receive a new article tomorrow ( which does not belong to this cycle ) about what the Elixir attracts developers and thus keeps them. So, if you have not subscribed, do not waste time and rather do it !

Secondly, we decided to play a super-useful gift - the book by Dave Thomas “Programming Elixir” . A great introduction to the language ( and not only for beginners ) from the guru of the programming programming literature Dave Thomas. In order to get it, you need to publish a post on Habrahabr on the subject of the Elixir language and indicate that the article was published specifically for the contest from Wunsh.ru. The winner will be the person whose article will score the highest rating. Detailed conditions read the link .

Other parts:
  1. Introduction
  2. Authorization
  3. Adding Roles
  4. Soon...

Good luck in learning, stay with us!

Source: https://habr.com/ru/post/315252/


All Articles