Last Updated At: 07/20/2016
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.
Installing Phoenix
The best instructions for installing Phoenix can be found on the Phoenix website.
Step 1: Letās AddĀ Posts
We need to start by using the Phoenix mix task to create a new project, which we will call āpxblogā. We do this using the mix phoenix.new [project] [command]. Answer Y to all questions, since we do want to use phoenix.js and the other front-end requirements.
mix phoenix.new pxblog
Output:
* creating pxblog/config/config.exs...
Fetch and install dependencies? [Yn] y* running mix deps.get* running npm install && node node_modules/brunch/bin/brunch build
We are all set! Run your Phoenix application:
$ cd pxblog$ mix phoenix.server
You can also run your app inside IEx (Interactive Elixir) as:
$ iex -S mix phoenix.server
Before moving on, configure your database in config/dev.exs and run:
$ mix ecto.create
We should see a bunch of output indicating that our project has been completed and the initial work is done.
The mix ecto.create step may fail if we have not set up our postgres database correctly or configured our application to use the right credentials. If you open up config/dev.exs, you should see some configuration details at the bottom of the file:
# Configure your databaseconfig :pxblog, Pxblog.Repo,adapter: Ecto.Adapters.Postgres,username: "postgres",password: "postgres",database: "pxblog_dev",hostname: "localhost",pool_size: 10
Just change the username and password to a role that has the correct database creation permissions.
When we have that working, weāll start up the server just to make sure everything is fine.
$ iex -S mix phoenix.server
We should now be able to visit http://localhost:4000/ and see the āWelcome to Phoenix!ā page. Now that we have a good baseline, letās add our basic scaffold for creating posts, since this is after all a blogging engine.
The first thing weāll do is utilize one of Phoenixās generators to build us not only the Ecto model and migration, but the UI scaffolding to handle the CRUD (Create, Read, Update, Delete) operations for our Post object. Since this is a very, very simple blog engine right now, letās just stick with a title and a body; the title will be a string and the body will be text. The syntax for these generators is pretty straightforward:
mix phoenix.gen.html [Model Name] [Table Name] [Column Name:Column Type]ā¦
$ mix phoenix.gen.html Post posts title:string body:text
Output:
* creating web/controllers/post_controller.ex...
Add the resource to your browser scope in web/router.ex:
resources "/posts", PostController
Remember to update your repository by running migrations:
$ mix ecto.migrate
Next, to make this scaffold accessible (and stop Elixir from complaining), weāre going to open up web/router.ex, and add the following to our root scope (the ā/ā scope, using theĀ :browser pipeline):
resources "/posts", PostController
Finally, weāre going to make sure that our database has this new migration loaded by calling mix ecto.migrate.
Output:
Compiling 9 files (.ex)Generated pxblog app
15:52:20.004 [info] == Running Pxblog.Repo.Migrations.CreatePost.change/0 forward
15:52:20.004 [info] create table posts
15:52:20.019 [info] == Migrated in 0.0s
Finally, letās restart our server, and then visit http://localhost:4000/posts and we should see the āListing postsā header with a table containing the columns in our object.
Mess around a bit and you should be able to create new posts, edit posts, and delete posts. Pretty cool for very little work!
Step 1B: Writings Tests forĀ Posts
The beautiful thing of working with the scaffolds at the start is that it will create the baseline tests for us from the start. We donāt even really need to modify much yet since we havenāt really changed any of the scaffolds, but letās analyze what was created for us so that we can be better prepared to write our own tests later.
First, weāll open up test/models/post_test.exs and take a look:
defmodule Pxblog.PostTest douse Pxblog.ModelCase
alias Pxblog.Post
@valid_attrs %{body: "some content", title: "some content"}@invalid_attrs %{}
test "changeset with valid attributes" dochangeset = Post.changeset(%Post{}, @valid_attrs)assert changeset.valid?end
test "changeset with invalid attributes" dochangeset = Post.changeset(%Post{}, @invalid_attrs)refute changeset.valid?endend
Letās take this apart and understand whatās going on.
defmodule Pxblog.PostTest do
Clearly, we need to define our Test module using our applicationās namespace.
use Pxblog.ModelCase
Next, we tell this module that it is going to be using the functions and DSL introduced by the ModelCase macro set.
alias Pxblog.Post
Now make sure that the test has visibility into the model itself.
@valid_attrs %{body: āsome contentā, title: āsome contentā}
Set up some basic valid attributes that would be able to create a successful changeset. This just provides a module-level variable that we can pull from every time we want to be able to create a valid model.
@invalid_attrs %{}
Like the above, but creating, unsurprisingly, an invalid attribute set.
test "changeset with valid attributes" dochangeset = Post.changeset(%Post{}, @valid_attrs)assert changeset.valid?end
Now, we create our test by giving it a string-based name using the ātestā function. Inside of our function body, we first create a changeset from the Post model (given it a blank struct and the list of valid parameters). We then assert that the changeset is valid, since that is what weāre expecting with the @valid_attrs variable.
test "changeset with invalid attributes" dochangeset = Post.changeset(%Post{}, @invalid_attrs)refute changeset.valid?end
Finally, we check against creating a changeset with an invalid parameter list, and instead of asserting the changeset is valid, we perform the reverse operation. refute is essentially assert not true.
This is a pretty good example of how to write a model test file. Now letās take a look at the controller tests.
Letās take a look at the top, since that all looks roughly the same:
defmodule Pxblog.PostControllerTest douse Pxblog.ConnCase
alias Pxblog.Post@valid_attrs %{body: "some content", title: "some content"}@invalid_attrs %{}
The first change set can see is Pxblog.ConnCase; weāre relying on the DSL that is exposed for controller-level tests. Other than that, the rest of the lines should be pretty familiar.
Letās take a look at the first test:
test "lists all entries on index", %{conn: conn} doconn = get conn, post_path(conn, :index)assert html_response(conn, 200) =~ "Listing posts"end
Here, we grab the āconnā variable that is going to be sent via a setup block in ConnCase. Iāll explain this later. The next step is for us to call the appropriate verb to hit the expected route, which in our case is a get request against our index action.
We then assert that the response of this action returns HTML with a status of 200 (āokā) and contains the phrase āListing posts.ā
test "renders form for new resources", %{conn: conn} doconn = get conn, post_path(conn, :new)assert html_response(conn, 200) =~ "New post"end
The next test is basically the same, but weāre just validating the ānewā action instead. Simple stuff.
test "creates resource and redirects when data is valid", %{conn: conn} doconn = post conn, post_path(conn, :create), post: @valid_attrsassert redirected_to(conn) == post_path(conn, :index)assert Repo.get_by(Post, @valid_attrs)end
Now, weāre doing something new. First, this time weāre posting to the post_path helper with our list of valid parameters. Weāre expecting to get redirected to the index route for the post resource. redirected_to takes in a connection object as an argument, since we need to see where that connection object was redirected to.
Finally, we assert that the object represented by those valid parameters are inserted into the database successfully through querying our Ecto Repo, looking for a Post model that matches our @valid_attrs parameters.
Now, we want to test the negative path for creating a new Post.
test "does not create resource and renders errors when data is invalid", %{conn: conn} doconn = post conn, post_path(conn, :create), post: @invalid_attrsassert html_response(conn, 200) =~ "New post"end
So, we post to the same create path but with our invalid_attrs parameter list, and we assert that it renders out the New Post form again.
test "shows chosen resource", %{conn: conn} dopost = Repo.insert! %Post{}conn = get conn, post_path(conn, :show, post)assert html_response(conn, 200) =~ "Show post"end
To test our show action, we make sure that we create a Post model to work with. We then call our get function to the post_path helper, and we make sure it returns the appropriate resource. However, if we attempt to grab a show path to a resource that does not exist, we do the following:
test "renders page not found when id is nonexistent", %{conn: conn} doassert_error_sent 404, fn ->get conn, post_path(conn, :show, -1)endend
We see a new pattern here, but one that is actually pretty simple to digest. We expect that if we fetch a resource that does not exist, that we should receive a 404. We then pass it an anonymous function which contains the code we want to execute that should return that error. Simple!
The rest of the tests are just repeats of the above for each path, with the exception of our delete action. Letās take a look:
test "deletes chosen resource", %{conn: conn} dopost = Repo.insert! %Post{}conn = delete conn, post_path(conn, :delete, post)assert redirected_to(conn) == post_path(conn, :index)refute Repo.get(Post, post.id)end
Here, most of what we see is the same, with the exception of using our delete verb. We assert that we redirect out of the delete page back to the index, but we do something new here: we refute that the Post object exists anymore. Assert and Refute are truthy, so the existence of an object at all will work with an Assert and cause a Refute to fail.
We didnāt add any code to our view, so we donāt do anything with our PostView module.
Step 2: AddingĀ Users
Weāre going to follow almost the exact same steps we followed with creating our Post object to create our User object, except with a few different columns. First, weāll run:
$ mix phoenix.gen.html User users username:string email:string password_digest:string
Output:
* creating web/controllers/user_controller.ex...
Add the resource to your browser scope in web/router.ex:
resources "/users", UserController
Remember to update your repository by running migrations:
$ mix ecto.migrate
Next, weāll open up web/router.ex, and add the following to our browser scope again:
resources "/users", UserController
The syntax here is defining a standard resourceful route where the first argument is the URL and the second is the controllerās class name. Weāll then run mix ecto.migrate
Output:
Compiling 11 files (.ex)Generated pxblog app
16:02:03.987 [info] == Running Pxblog.Repo.Migrations.CreateUser.change/0 forward
16:02:03.987 [info] create table users
16:02:03.996 [info] == Migrated in 0.0s
And finally, restart the server and check out http://localhost:4000/users. We can now independently create Posts and Users! Unfortunately, this isnāt a very useful blog yet. After all, we can create users (anyone can, in fact), but we canāt even log in. Plus, password digests arenāt coming from any encryption algorithm; the user is just creating them and weāre storing them in plain text! No bueno!
Weāll make this look more like a real user registration screen instead.
Our tests for the user stuff looks exactly the same as what was auto-generated for our Posts, so weāll leave those alone until we start modifying the logic (IE, right now!)
Step 3: Saving a Password Hash instead of aĀ Password
When we visit /users/new, we see three fields: Username, Email, and PasswordDigest. But when you register on other sites, you would enter a password and a password confirmation! How can we correct this?
In web/templates/user/form.html.eex, delete the following lines:
<div class=āform-groupā><%= label f, :password_digest, class: ācontrol-labelā %><%= text_input f, :password_digest, class: āform-controlā %><%= error_tag f, :password_digest %></div>
And add in its place:
<div class="form-group"><%= label f, :password, "Password", class: "control-label" %><%= password_input f, :password, class: "form-control" %><%= error_tag f, :password %></div>
<div class="form-group"><%= label f, :password_confirmation, "Password Confirmation", class: "control-label" %><%= password_input f, :password_confirmation, class: "form-control" %><%= error_tag f, :password_confirmation %></div>
Refresh the page (should happen automatically), enter user details, hit submit.
Error:
Oops, something went wrong! Please check the errors below.
This is because weāre creating a password and password confirmation but nothing is being done to create the actual password_digest. Letās write some code to do this. First, weāre going to modify the actual schema to do something new:
In web/models/user.ex:
schema "users" dofield :username, :stringfield :email, :stringfield :password_digest, :string
timestamps
Virtual Fields
field :password, :string, virtual: truefield :password_confirmation, :string, virtual: trueend
Note the addition of the two fields,Ā :password andĀ :password_confirmation. Weāre declaring these as virtual fields, as these do not actually exist in our database but need to exist as properties in our User struct. This also allows us to apply transformations in our changeset function.
We then modify the list of required fields and casted fields to includeĀ :password andĀ :password_confirmation.
def changeset(struct, params \\ %{}) dostruct|> cast(params, [:username, :email, :password, :password_confirmation])|> validate_required([:username, :email, :password, :password_confirmation])end
If you run test/models/user_test.exs at this point, youāll notice that our āchangeset with valid attributesā test is now failing. Thatās because we made password and password_confirmation required but did not update our @valid_attrs map to include either. Letās change that line to:
@valid_attrs %{email: "[email protected]", password: "test1234", password_confirmation: "test1234", username: "testuser"}
Our model tests should be right back to passing! We also need our controller tests passing. In test/controllers/user_controller_test.exs, weāll make some modifications. First, weāll distinguish between valid creation attributes and valid searching attributes:
@valid_create_attrs %{email: "[email protected]", password: "test1234", password_confirmation: "test1234", username: "testuser"}@valid_attrs %{email: "[email protected]", username: "testuser"}
Then weāll modify our creation test:
test "creates resource and redirects when data is valid", %{conn: conn} doconn = post conn, user_path(conn, :create), user: @valid_create_attrsassert redirected_to(conn) == user_path(conn, :index)assert Repo.get_by(User, @valid_attrs)end
And our update test:
test "updates chosen resource and redirects when data is valid", %{conn: conn} douser = Repo.insert! %User{}conn = put conn, user_path(conn, :update, user), user: @valid_create_attrsassert redirected_to(conn) == user_path(conn, :show, user)assert Repo.get_by(User, @valid_attrs)end
With our tests back to green, we need to modify the changeset function to change our password into a password digest on the fly:
def changeset(struct, params \\ %{}) dostruct|> cast(params, [:username, :email, :password, :password_confirmation])|> validate_required([:username, :email, :password, :password_confirmation])|> hash_passwordend
defp hash_password(changeset) dochangeset|> put_change(:password_digest, "ABCDE")end
Right now weāre just stubbing out the behavior of our hashing function. The first step is to make sure that we can modify our changeset as we go along. Letās verify this behavior first. Go back to http://localhost:4000/users in our browser, click on āNew userā, and create a new user with any details. When we hit the index page again, we should expect to see the user created with a password_digest value of āABCDEā.
And run our tests again for this file. Our tests are passing, but we havenāt added any tests for this new hash_password work. Letās add a test in our suite of model tests that will add a test on password digest:
test "password_digest value gets set to a hash" dochangeset = User.changeset(%User{}, @valid_attrs)assert get_change(changeset, :password_digest) == "ABCDE"end
This is a great step forward, but not terribly great for security! Letās modify our hashes to be real password hashes with Bcrypt, courtesy of the comeonin library.
First, open up mix.exs and addĀ :comeonin to our list of applications:
def application do[mod: {Pxblog, []},applications: [:phoenix, :phoenix_pubsub, :phoenix_html, :cowboy, :logger, :gettext,:phoenix_ecto, :postgrex, :comeonin]]end
And weāll also need to modify our deps definition:
defp deps do[{:phoenix, "~> 1.2.0"},{:phoenix_pubsub, "~> 1.0"},{:phoenix_ecto, "~> 3.0"},{:postgrex, ">= 0.0.0"},{:phoenix_html, "~> 2.6"},{:phoenix_live_reload, "~> 1.0", only: :dev},{:gettext, "~> 0.11"},{:cowboy, "~> 1.0"},{:comeonin, "~> 2.3"}]end
Same here, note the addition of {:comeonin, ā~> 2.3ā}. Now, letās shut down the server weāve been running and run mix deps.get. If all goes well (it should!), then now you should be able to rerun iex -S mix phoenix.server to restart your server.
Our old hash_password method is neat, but we need it to actually hash our password. Since weāve added the comeonin library, which provides us a nice Bcrypt module with a hashpwsalt method, so letās import that into our User model.
In web/models/user.ex, add the following line to the top just under our use Pxblog.Web,Ā :model line:
import Comeonin.Bcrypt, only: [hashpwsalt: 1]
What weāre doing here is pulling in the Bcrypt module under the Comeonin namespace and importing the hashpwsalt method with an arity of 1. And now weāre going to modify our hash_password method to work.
defp hash_password(changeset) doif password = get_change(changeset, :password) dochangeset|> put_change(:password_digest, hashpwsalt(password))elsechangesetendend
Letās try creating a user again! This time, after entering in our data for username, email, password, and password confirmation, we should see an encrypted digest show up in the password_digest field!
Now, weāre going to want to work on the hash_password function that we added. The first thing weāre going to want to do is change the configuration for our testing environment to make sure our tests donāt slow down dramatically when working with our password encryption. Open up config/test.exs and add the following to the bottom:
config :comeonin, bcrypt_log_rounds: 4
This will configure ComeOnIn when itās in our test environment to not try too hard to encrypt our password. Since this is only for tests, we donāt need anything super secure and would prefer sanity and speed instead! In config/prod.exs, weāll want to instead change that to:
config :comeonin, bcrypt_log_rounds: 14
Letās write a test for the comeonin call. Weāll make it a little less specific; we just want to verify the encryption. In test/models/user_test.exs:
test "password_digest value gets set to a hash" dochangeset = User.changeset(%User{}, @valid_attrs)assert Comeonin.Bcrypt.checkpw(@valid_attrs.password, Ecto.Changeset.get_change(changeset, :password_digest))end
For some more test coverage, letās add a case to handle if the password = get_change() line is not hit:
test "password_digest value does not get set if password is nil" dochangeset = User.changeset(%User{}, %{email: "[email protected]", password: nil, password_confirmation: nil, username: "test"})refute Ecto.Changeset.get_change(changeset, :password_digest)end
Since assert/refute use truthiness, we can see if this block of code leaves password_digest blank, which it does! Weāre doing a good job of covering our work with specs!
Step 4: Letās logĀ in!
Letās add a new controller, SessionController and an accompanying view, SessionView. Weāll start simple and build our way up to a better implementation over time.
Create web/controllers/session_controller.ex:
defmodule Pxblog.SessionController douse Pxblog.Web, :controller
def new(conn, _params) dorender conn, "new.html"endend
Create web/views/session_view.ex:
defmodule Pxblog.SessionView douse Pxblog.Web, :viewend
Create web/templates/session/new.html.eex:
<h2>Login</h2>
And finally, letās update the router to include this new controller. Add the following line to our ā/ā scope:
resources "/sessions", SessionController, only: [:new]
The only route that we want to expose for the time being is new, so weāre going to limit it just to that. Again, we want to keep things simple and build up from a stable foundation.
Now letās visit http://localhost:4000/sessions/new and we should expect to see the Phoenix framework header and the āLoginā header.
Letās give it a real form. Create web/templates/session/form.html.eex:
<%= form_for @changeset, @action, fn f -> %><%= if f.errors != [] do %><div class="alert alert-danger"><p>Oops, something went wrong! Please check the errors below:</p><ul><%= for {attr, message} <- f.errors do %><li><%= humanize(attr) %> <%= message %></li><% end %></ul></div><% end %>
<div class="form-group"><label>Username</label><%= text_input f, :username, class: "form-control" %></div>
<div class="form-group"><label>Password</label><%= password_input f, :password, class: "form-control" %></div>
<div class="form-group"><%= submit "Submit", class: "btn btn-primary" %></div><% end %>
And modify web/templates/session/new.html.eex to call our new form by adding one line:
<%= render "form.html", changeset: @changeset, action: session_path(@conn, :create) %>
The autoreloading will end up displaying an error page right now because we havenāt actually defined @changeset, which as you may guess needs to be a changeset. Since weāre working with the member object, which has username and password fields already on it, letās use that!
In web/controllers/session_controller.ex, we need to alias the User model to be able to use it further. At the top of our class, under our use Pxblog.Web,Ā :controller line, add the following:
alias Pxblog.User
And in the new function, modify the call to render as follows:
render conn, "new.html", changeset: User.changeset(%User{})
We need to pass it the connection, the template weāre rendering (minus the eex), and a list of additional variables that should be exposed to our templates. In this case, we want to expose @changeset, so we specify changeset: here, and we give it the Ecto changeset for the User with a blank User struct. (%User{} is a User Struct with no values set)
Refresh now and we get a different error message that should resemble the following:
No helper clause for Pxblog.Router.Helpers.session_path/2 defined for action :create.The following session_path actions are defined under your router:*:new
In our form, we are referencing a route that doesnāt actually exist yet! We are using the session_path helper, passing it the @conn object, but then specifying theĀ :create path which weāve not created yet.
Weāve gotten part of the way there. Now letās make it so we can actually post our login details and set the session.
Letās update our routes to allow posting to create.
In web/router.ex, change our reference to SessionController to also includeĀ :create.
resources "/sessions", SessionController, only: [:new, :create]
In web/controllers/session_controller.ex, we need to import a new function, checkpw from Comeoninās Bcrypt module. We do this via the following line:
import Comeonin.Bcrypt, only: [checkpw: 2]
This line is saying āImport from the Comeonin.Bcrypt module, but only the checkpw function with an arity of 2ā. And then letās add a scrub_params plug to deal with User data. Before our functions, weāll add:
plug :scrub_params, "user" when action in [:create]
āscrub_paramsā is a special function that cleans up any user input; in the case where something is passed in as a blank string, for example, scrub_params will convert it into a nil value instead to avoid creating entries in your database that have empty strings.
And letās add our function to handle the create post. Weāre going to add this to the bottom of our SessionController module. Thereās going to be a lot of code here, so weāll take it piece by piece.
In web/controllers/session_controller.ex:
def create(conn, %{"user" => user_params}) doRepo.get_by(User, username: user_params["username"])|> sign_in(user_params["password"], conn)end
The first bit of this code, Repo.get_by(User, username: user_params[āusernameā]) pulls the first applicable User from our Ecto Repo that has a matching username, or will otherwise return nil.
Here is some output to verify this behavior:
iex(3)> Repo.get_by(User, username: "flibbity")[debug] SELECT u0."id", u0."username", u0."email", u0."password_digest", u0."inserted_at", u0."updated_at" FROM "users" AS u0 WHERE (u0."username" = $1) ["flibbity"] OK query=0.7msnil
iex(4)> Repo.get_by(User, username: "test")[debug] SELECT u0."id", u0."username", u0."email", u0."password_digest", u0."inserted_at", u0."updated_at" FROM "users" AS u0 WHERE (u0."username" = $1) ["test"] OK query=0.8ms%Pxblog.User{__meta__: %Ecto.Schema.Metadata{source: "users", state: :loaded},email: "test", id: 15,inserted_at: %Ecto.DateTime{day: 24, hour: 19, min: 6, month: 6, sec: 14,usec: 0, year: 2015}, password: nil, password_confirmation: nil,password_digest: "$2b$12$RRkTZiUoPVuIHMCJd7yZUOnAptSFyM9Hw3Aa88ik4erEsXTZQmwu2",updated_at: %Ecto.DateTime{day: 24, hour: 19, min: 6, month: 6, sec: 14,usec: 0, year: 2015}, username: "test"}
We then take the user, and chain that user into a sign_in method. We havenāt written that yet, so letās do so!
defp sign_in(user, password, conn) when is_nil(user) doconn|> put_flash(:error, "Invalid username/password combination!")|> redirect(to: page_path(conn, :index))end
defp sign_in(user, password, conn) doif checkpw(password, user.password_digest) doconn|> put_session(:current_user, %{id: user.id, username: user.username})|> put_flash(:info, "Sign in successful!")|> redirect(to: page_path(conn, :index))elseconn|> put_session(:current_user, nil)|> put_flash(:error, "Invalid username/password combination!")|> redirect(to: page_path(conn, :index))endend
The first thing to notice is the order that these methods are defined in. The first of these methods has a guard clause attached to it, so that method will only be executed when that guard clause is true, so if the user is nil, we redirect back to the index of the page (root) path with an appropriate flash message.
The second method will get called if the guard clause is false and will handle all other scenarios. We check the result of that checkpw function, and if it is true, we set the user to the current_user session variable and redirect with a success message. Otherwise, we clear out the current user session, set an error message, and redirect back to the root.
If we return to our login page http://localhost:4000/sessions/new, we should be able to test out login with a valid set of credentials and invalid credentials and see the appropriate error messages!
We need to write some specs for this controller, as well. Weāll create test/controllers/session_controller_test.exs and fill it with the following:
defmodule Pxblog.SessionControllerTest douse Pxblog.ConnCasealias Pxblog.User
setup doUser.changeset(%User{}, %{username: "test", password: "test", password_confirmation: "test", email: "[email protected]"})|> Repo.insert{:ok, conn: build_conn()}end
test "shows the login form", %{conn: conn} doconn = get conn, session_path(conn, :new)assert html_response(conn, 200) =~ "Login"end
test "creates a new user session for a valid user", %{conn: conn} doconn = post conn, session_path(conn, :create), user: %{username: "test", password: "test"}assert get_session(conn, :current_user)assert get_flash(conn, :info) == "Sign in successful!"assert redirected_to(conn) == page_path(conn, :index)end
test "does not create a session with a bad login", %{conn: conn} doconn = post conn, session_path(conn, :create), user: %{username: "test", password: "wrong"}refute get_session(conn, :current_user)assert get_flash(conn, :error) == "Invalid username/password combination!"assert redirected_to(conn) == page_path(conn, :index)end
test "does not create a session if user does not exist", %{conn: conn} doconn = post conn, session_path(conn, :create), user: %{username: "foo", password: "wrong"}assert get_flash(conn, :error) == "Invalid username/password combination!"assert redirected_to(conn) == page_path(conn, :index)endend
We start off with our standard setup block, and write a pretty standard assertion for a get request. The creation bits are where this starts to get more interesting:
test "creates a new user session for a valid user", %{conn: conn} doconn = post conn, session_path(conn, :create), user: %{username: "test", password: "test"}assert get_session(conn, :current_user)assert get_flash(conn, :info) == "Sign in successful!"assert redirected_to(conn) == page_path(conn, :index)end
The first bit here is posting to our session creation path. We then verify that we set the current_user session variable, the flash message for the action, and finally, we assert where we are being redirected to.
The other two calls are just using the same sort of assertions (and in one case, a refutation) to make sure weāre testing all of the paths that the sign_in function can hit. Again, very simple stuff!
Step 5: Exposing our current_user
Letās modify our layout to display a message or a link depending on if the member is logged in or not.
In web/views/layout_view.ex, letās write a helper that will make it easy for us to access the user information, so weāll add:
def current_user(conn) doPlug.Conn.get_session(conn, :current_user)end
Letās write a test to make sure this works.
In web/templates/layout/app.html.eex, instead of the āGet Startedā link, letās do the following:
<li><%= if user = current_user(@conn) do %>Logged in as<strong><%= user.username %></strong><br><%= link "Log out", to: session_path(@conn, :delete, user.id), method: :delete %><% else %><%= link "Log in", to: session_path(@conn, :new) %><% end %></li>
Again, letās step through this piece by piece. One of the first things we need to do is figure out who the current user is, assuming theyāre logged in. Weāre going with a simple first, refactor later approach, so for right now weāre going to just set a user object from the session right in our template. get_session is part of the Plug.Conn object. If the user exists (this takes advantage of Elixirās Ruby-like truthiness values in that nil will return false here.)
If the user is logged in, weāll also want to provide a logout link as well. Even though this does not exist yet, it will eventually need to exist, so for right now weāre going to send it along. Weāll treat a session like a resource, so to logout, weāll ādeleteā the session, so weāll provide a link to it here.
We also want to output the current userās username. Weāre storing the user struct in theĀ :current_user session variable, so we can just access the username as user.username.
If we could not find the user, then weāll just provide the login link. Again, weāre treating sessions like a resource here, so ānewā will provide the appropriate route to create a new session.
You probably noticed that when everything refreshed, weāre getting another error message about a missing matching function clause. Letās add our delete route as well to keep Phoenix happy!
In web/router.ex, weāll modify our āsessionsā route to also allowĀ :delete:
resources "/sessions", SessionController, only: [:new, :create, :delete]
And letās modify the controller as well. In web/controllers/session_controller.ex, add the following:
def delete(conn, _params) doconn|> delete_session(:current_user)|> put_flash(:info, "Signed out successfully!")|> redirect(to: page_path(conn, :index))end
Since weāre just deleting theĀ :current_user key, we donāt actually care what the params are, so we mark those as unused with an underscore. We set a flash message to make the UI a little more clear to the user and redirect back to our root route.
We can now log in, log out, and see login failures too! Things are shaping up for the best! But first, we need to write some tests. Weāll start with the tests for our LayoutView.
defmodule Pxblog.LayoutViewTest douse Pxblog.ConnCase, async: true
alias Pxblog.LayoutViewalias Pxblog.User
setup doUser.changeset(%User{}, %{username: "test", password: "test", password_confirmation: "test", email: "[email protected]"})|> Repo.insert{:ok, conn: build_conn()}end
test "current user returns the user in the session", %{conn: conn} doconn = post conn, session_path(conn, :create), user: %{username: "test", password: "test"}assert LayoutView.current_user(conn)end
test "current user returns nothing if there is no user in the session", %{conn: conn} douser = Repo.get_by(User, %{username: "test"})conn = delete conn, session_path(conn, :delete, user)refute LayoutView.current_user(conn)endend
Letās analyze. The first thing weāre going to do is alias the LayoutView and User modules so that we can shorten some of our code. Next, in our setup block, we create a User and throw it into the database. We then return our standard {:ok, conn: build_conn()} tuple.
We then write our first test by logging in to our session create action and asserting that, after logging in, the LayoutView.current_user function returns some data.
We then write for our negative case; we explicitly delete the session and refute that a user is returned out of our current_user call. We also updated our SessionController by adding a delete action, so we need to get our tests all set.
test "deletes the user session", %{conn: conn} douser = Repo.get_by(User, %{username: "test"})conn = delete conn, session_path(conn, :delete, user)refute get_session(conn, :current_user)assert get_flash(conn, :info) == "Signed out successfully!"assert redirected_to(conn) == page_path(conn, :index)end
We make sure that current_user from the session is blank, then we check the flash message and make sure we get redirected out!
Possible errors with compiling assets
One thing to note is that you may end up hitting an error when youāre trying to compile the assets with brunch. The error message I got was:
16 Dec 23:30:20 ā error: Compiling of āweb/static/js/app.jsā failed. Couldnāt find preset āes2015ā relative to directory āweb/static/jsā ; Compiling of āweb/static/js/socket.jsā failed. Couldnāt find preset āes2015ā relative to directory āweb/static/jsā ; Compiling of ādeps/phoenix/web/static/js/phoenix.jsā failed. Couldnāt find preset āes2015ā relative to directory ādeps/phoenix/web/static/jsā ; Compiling of ādeps/phoenix_html/web/static/js/phoenix_html.jsā failed. Couldnāt find preset āes2015ā relative to directory ādeps/phoenix_html/web/static/jsā
You can fix this with installing babel-preset-es2015 with NPM. I ran the following command:
npm install -g babel-preset-es2015
Now if you start up the server you should see all of the assets correctly compiled in!
Next post in thisĀ series
Writing a Blog Engine in Phoenix and Elixir: Part 2, Authorization_Last Updated At: 07/21/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!