Writing a Blog Engine in Phoenix and Elixir: Part 3, Adding Roles to our Models

Written by diamondgfx | Published 2015/10/14
Tech Story Tags: elixir | phoenix | web-development | phoenix-framework

TLDRvia the TL;DR App

Latest Update: 07/21/2016

Previous Post in this series

Writing a Blog Engine in Phoenix and Elixir: Part 2, Authorization_Last Updated At: 07/21/2016_medium.com

Current Versions

As of the time of writing this, the current versions of our applications are:

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

If you are reading this and these are not the latest, let me know and I’ll update this tutorial accordingly.

Where We Left Off

When we last left off, we finished associating our Posts with Users, and began the process of properly restricting access to posts unless we had valid users (and were the user the created the post in the first place), but what if we want to have multiple users? We’ll want to keep things in check with sane user rules so that we do not end up in a scenarios where some rogue user goes off and deletes everyone’s account or posts across the board. We’ll solve this with a fairly standard solution: creating roles.

Creating Roles

We’ll start off by running the following command in our terminal:

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

To which we should expect to see something similar to the following output:

* 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

We’ll follow the script’s advice and run mix ecto.migrate immediately. Assuming our DB is already setup properly, we should see output similar to the following:

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’ll also run mix test to sanity check that our new model addition has not disrupted any other tests. If everything is all green, then we’re all set to move on to modifying our User model to have associated an associated Role.

Adding the Roles Associations

The general design I’m following for this particular feature implementation is that each User has one Role, and that each Role has multiple Users, so we’ll modify the web/models/user.ex file to reflect this:

In the schema “users” do section, we’ll add the following line:

belongs_to :role, Pxblog.Role

In this case, we’re going to place the role_id foreign key on the Users table, so we want to say that a User “belongs to” a Role. We’ll also open up web/models/role.ex and change the schema “roles” do section by adding the following line:

has_many :users, Pxblog.User

We’ll then run mix test again, but we should be expecting many failures. We told Ecto that our users table had a relationship to the roles table but we never defined that in our database, so we’re going to have to modify our users table to hold a reference to a 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

Let’s open up the migration file that it created. By default, it’ll contain:

defmodule Pxblog.Repo.Migrations.AddRoleIdToUsers douse Ecto.Migration

def change doendend

We need to add a few things. The first thing is that we need to alter the users table to add the reference to roles, so we’ll do this with the following code:

alter table(:users) doadd :role_id, references(:roles)end

And we should add an index to role_id, so we’ll add the following line:

create index(:users, [:role_id])

Finally, we’ll run mix ecto.migrate again, and should see everything migrate successfully! If we run our tests now, everything should be green again! Unfortunately, our tests are not quite perfect. For one thing, we never modified our tests for the Post/User models to make sure that a Post, for example, must have a User defined. Similarly, we don’t want to be able to create a User when they don’t have a Role. In web/models/user.ex, we’ll change the changeset function to read as follows (note the two additions of :role_id):

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

Creating a Test Helper

Running our tests now will result in a lot of failures, but that’s okay! We’re going to be doing a lot of work to clean up our tests, and one of the things we’re going to need is some sort of test helper to avoid us having to write the same code over and over. We’re going to create a new file to help us build out these models. Create test/support/test_helper.ex and populate it with the following code:

defmodule Pxblog.TestHelper doalias Pxblog.Repoalias Pxblog.Useralias Pxblog.Rolealias Pxblog.Post

import Ecto, only: [build_assoc: 2]

def create_role(%{name: name, admin: admin}) doRole.changeset(%Role{}, %{name: name, admin: admin})|> Repo.insertend

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

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

Let’s talk about what that file is doing before we move on with fixing our tests. The first thing to note is where we’ve placed the file: test/support, where we can place any modules we want to make available to our test suite at large. We’ll still need to alias references to these in each of the files, but that’s okay!

We alias out our Repo, User, Role, and Post modules first so we can access them with shorter syntax and we also import Ecto so we can access the build_assoc method for building associations.

In create_role, we expect a map that includes a role name and whether it’s admin or not. We’re using Repo.insert here which means we’ll be returning the standard {:ok, model} response on successful insertions. Otherwise, it’s just a simple insert of a Role changeset.

In create_user, we take as our first argument the role we want to use and and as the second argument a map of parameters to use to create our user. We start with our role and then pipe that into our build function, creating a User model (since we specified :users as the association), which then gets piped into User.changeset with the parameters mentioned previously. The end result of that gets piped into Repo.insert(), and we’re done!

While being kind of complicated to explain, we end up with super readable and super understandable code. Take a role, build an associated user, prep it for insertion into the database, and then insert it!

For create_post we do the same thing, except with a post and user instead of a user and role!

Fixing Our Tests

We’ll start by fixing up test/models/user_test.exs. The first thing we need to do is add alias Pxblog.TestHelper to the top of our module definition so we can use those handy helpers we created earlier. Next, we’re going to create a setup block before our tests to reuse a role.

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

And then in our first test, we’re going to change it around to pattern match against the role key from our setup block. We’re going to save ourselves a little bit of typing whenever we want this role included, so we’re going to write a helper function and modify the test:

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

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

To recap, we pattern match on the role key coming from our setup block, and then we’re modifying the valid_attrs key to include a valid role id in our helper method! When we modify this spec and run it, we should now be back to green specs for test/models/user___test.exs!

Next, open up test/controllers/user_controller___test.exs and we’ll use the same lessons to get this file passing again. We’ll add an alias Pxblog.Role statement at the top of our controller file, as well as an alias Pxblog.TestHelper statement, and add setup code to create a role and return that out with 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

We’ll add a helper function called valid_create_attrs that takes in a role as an argument and returns out a new map with role_id set as well.

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

Finally, we’ll modify our create and update actions to use this new helper and pattern match on the user_role value from our map.

test "creates resource and redirects when data is valid", %{conn: conn, user_role: user_role} doconn = 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} douser = 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

Our user controller tests should now all be green! Running mix test is still going to give us failures, sadly.

Fixing the Post Controller Tests

In our Posts Controller, we ended up creating a lot of helper functions to facilitate our building posts with users, so we need to modify those helpers and add the concept of roles so that we can create a valid user. We’ll start by adding a reference to Pxblog.Role up at the top of our post controller. In test/controllers/post_controller_test.exs:

alias Pxblog.Rolealias Pxblog.TestHelper

Then we’ll create our setup block, deviating slightly from what we’ve done in previous setup blocks.

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 need to do is create a role, and just a standard non-admin role will do just fine here. In the next line, we’ll also create a user using that role. Next, we’ll create a post for the user. We’ve already covered the login piece, so we’ll skip that. Finally, we return out all of the new models we created to allow each test to pattern match as they need to.

And we have one test that we’ll modify as well to get everything green. Our “redirects when trying to edit a post for a different user” test fails because it attempts to create a second user on the fly with no concept of a role. We’ll change it around ever so slightly:

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.haltedend

So here we’ve added the role: role bit to our test definition to pattern match on the role key, and changed the other_user creation bit to instead use TestHelper and reference the role we pattern matched on.

We have a quick opportunity to refactor since we helpfully included a post object from our TestHelper as one of the values we can pattern match. Everywhere we previously called build_post we can drop and instead pattern match on our post object. The full file after all of our modifications should be:

defmodule Pxblog.PostControllerTest douse Pxblog.ConnCase

alias Pxblog.Postalias 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) dopost conn, session_path(conn, :create), user: %{username: user.username, password: user.password}end

test "lists all entries on index", %{conn: conn, user: user} doconn = 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} doconn = 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} doconn = post conn, user_post_path(conn, :create, user), post: @valid_attrsassert 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} doconn = post conn, user_post_path(conn, :create, user), post: @invalid_attrsassert html_response(conn, 200) =~ "New post"end

test "shows chosen resource", %{conn: conn, user: user, post: post} doconn = 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} doassert_error_sent 404, fn ->get conn, user_post_path(conn, :show, user, -1)endend

test "renders form for editing chosen resource", %{conn: conn, user: user, post: post} doconn = 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} doconn = put conn, user_post_path(conn, :update, user, post), post: @valid_attrsassert 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} doconn = 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} doconn = 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} doconn = 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.haltedend

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.haltedendend

Fixing the Session Controller Tests

test/controllers/session_controller_test.exs has some failing tests as well since we’ve not updated it to use our TestHelper. We’ll add a aliases at the top and modify the setup block, as we have elsewhere:

defmodule Pxblog.SessionControllerTest douse Pxblog.ConnCase

alias Pxblog.Useralias 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

This should be sufficient to make these tests pass! Hooray!

Fixing the Rest of Our Tests

We still have two failing tests. Let’s get those green!

  1. test current user returns the user in the session (Pxblog.LayoutViewTest)test/views/layout_view_test.exs:13Expected truthy, got nilcode: 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 paramstacktrace:(phoenix) lib/phoenix/param.ex:67: Phoenix.Param.Atom.to_param/1(pxblog) web/router.ex:1: Pxblog.Router.Helpers.session_path/4test/views/layout_view_test.exs:20

Open up test/views/layout_view_test.exs, and up at the top we see a User getting created without a Role! In our setup block, we’re also not passing our created User along, so we have to look it up over and over again! Gross! We’re going to refactor the whole file:

defmodule Pxblog.LayoutViewTest douse Pxblog.ConnCase, async: true

alias Pxblog.LayoutViewalias 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} doconn = 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} doconn = delete conn, session_path(conn, :delete, user)refute LayoutView.current_user(conn)endend

We add an alias for our Role model, create a valid Role, create a valid User with that role, and then return out user with the conn. Finally, in both of our test methods, we pattern match on user so that we can reuse that model. Now, run mix test and…

Everything is green! But we ARE getting a couple of warnings when running our tests (since we made everything so lovely and clean).

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

Just go in to each of those files and remove the offending aliases and functions since we don’t need them anymore!

$ mix test.........................................

Finished in 0.4 seconds41 tests, 0 failures

Randomized with seed 588307

Creating an Admin Seed

Eventually we’ll be restricting new user creation to admins only. For us to do so, however, means that we’ll be in a weird catch-22 state where we have no members or admins, thus meaning we cannot create members or admins, and so on. We’ll remedy this by providing a seed for a default admin user. Open up priv/repo/seeds.exs and insert the following code:

alias Pxblog.Repoalias Pxblog.Rolealias 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 we’ll run our seed file by invoking the following command:

$ mix run priv/repo/seeds.exs

Coming Up Next

Now that we have our models set up and ready for dealing with our roles and have our tests back to green, we need to start modifying the functionality in our controllers to restrict certain operations unless you’re the appropriate user or an admin. In the next post we’ll explore how best to implement this functionality, how to add a helper module, and of course, we’ll keep our tests green!

Next post in this series

Writing a Blog Engine in Phoenix and Elixir: Part 4, Adding Roles to our Controllers_Latest Update: 01/26/2016_medium.com

Check out my new book!

Hey everyone! If you liked what you read here and want to learn more with me, check out my new book on Elixir and Phoenix web development:

Phoenix Web Development | PACKT Books_Learn to build a high-performance functional prototype of a voting web application from scratch using Elixir and…_www.packtpub.com

I’m really excited to finally be bringing this project to the world! It’s written in the same style as my other tutorials where we will be building the scaffold of a full project from start to finish, even covering some of the trickier topics like file uploads, Twitter/Google OAuth logins, and APIs!

Hacker Noon is how hackers start their afternoons. We’re a part of the @AMIfamily. We are now accepting submissions and happy to discuss advertising & sponsorship opportunities.

To learn more, read our about page, like/message us on Facebook, or simply, tweet/DM @HackerNoon.

If you enjoyed this story, we recommend reading our latest tech stories and trending tech stories. Until next time, don’t take the realities of the world for granted!


Published by HackerNoon on 2015/10/14