A Phoenix and Elixir REST API— Part 2
Adding POST and PUT
Where We’re Headed
When we left off last time, we had the beginnings of an API. We now have our database configured, and have the routes and controller actions in place to do GET requests. This time around, we will do some cleanup and add the ability to POST new users and use PUT requests to modify entries. Let’s get right to it!
Setting HTTP Return Status
Currently our GET requests work fine if we do successful calls, but if you try to do make a request on a non-existent entity, you still get a status of 200. We’d like to modify the application to return a 404 in the case on a non-existent record. This is actually very easy. When we perform any operation in the controller, we are just creating a pipeline that modifies the conn structure and returns a modified version of that conn. Phoenix provides a function that adds the proper status code and returns a new conn with that status. Given this, I created a function that accepts a conn and the data Ecto returned, and returns a new conn.
# web/controllers/user_controller.ex
defp conn_with_status(conn, nil) do
conn
|> put_status(:not_found)
end defp conn_with_status(conn, _) do
conn
|> put_status(:ok)
end
My assumption here is that a database call that returns nil indicates a not found error. So I use pattern matching to return either the :ok atom (200) or the :not_found atom (404).
With this function in place, I then modify the two controller actions to add this transformation. Given that currently this function will only be used within this single controller, I made it a private function using the defp keyword.
# web/controllers/user_controller.ex
def index(conn, _params) do
users = Repo.all(ApiExample.User) json conn_with_status(conn, users), users
end def show(conn, %{"id" => id}) do
user = Repo.get(ApiExample.User, String.to_integer(id)) json conn_with_status(conn, user), user
end
With this in place, we now get a 404 returned if we do a get to a non-existent user (i.e. /api/v1/users/100).
Building the POST Endpoint — Route and Controller
If we attempt to perform a POST operation to the users endpoint, we predictably get this error.
** (Phoenix.Router.NoRouteError) no route found for POST /api/v1/users (ApiExample.Router)
We will go down the usual path and add a route.
# web/router.ex
scope "/api/v1", ApiExample do
pipe_through :api get "/users", UserController, :index
get "/users/:id", UserController, :show
post "/users", UserController, :create
end
This gives us another predictable error.
** (UndefinedFunctionError) function ApiExample.UserController.create/2 is undefined or private
This prods us to open up user_controller.ex and add a create function. For now I’m just putting in a dummy function.
# web/controllers/user_controller.ex
def create(conn, params) do
json conn, nil
end
This allows the POST request to run, but nothing is written to the database yet. In order to do this, we need to create an Ecto changeset. The changeset is a function contained within the model that processes casting, validation and constraints for a model. Here is a simple example of changeset for our user model.
# web/models/user.ex
def changeset(model, params \\ :empty) do
model
|> cast(params, [:name, :email, :password, :stooge])
|> unique_constraint(:email)
end
Here we simply use the cast function to define the four required fields. We also apply a constraint that will guarantee the the email address is unique. We are doing no other input validation at this time, but it’s quite easy to add constraints similar to those on a Rails model.
Once we have the changeset, we can revisit our model and add code to commit data to our database.
# web/controllers/user_controller.ex
def create(conn, params) do
changeset = ApiExample.User.changeset(
%ApiExample.User{}, params)case Repo.insert(changeset) do
{:ok, user} ->
json conn |> put_status(:created), user
{:error, _changeset} ->
json conn |> put_status(:bad_request), %{errors: ["unable to create user"] }
end
end
The logic here is quite simple. We provide the params from the API call to the changeset function we just created. That changeset is then sent to the Ecto insert function. The insert function returns a tuple that indicates success or failure. We use pattern matching to determine what HTTP status and data to render into JSON. In the fail case, an Elixir map is returned with a list of error strings.
If you now use a REST client to perform put operations, you will see that valid data is properly written. You can use the get endpoint to conform the proper writing of data.
Adding a PUT Endpoint
Our final major task will be to allow the update of existing records using a put endpoint. For this simple example, we will assume that all fields are provided and we will overwrite them all.
We start off by adding a route.
# web/router.ex
scope "/api/v1", ApiExample do
pipe_through :api get "/users", UserController, :index
get "/users/:id", UserController, :show
post "/users", UserController, :create
put "/users/:id", UserController, :update
end
If we attempt to perform a user put, we get this error.
** (UndefinedFunctionError) function ApiExample.UserController.update/2 is undefined or private
This spurs us to add a controller action to perform the update.
# web/controllers/user_controller.ex
def update(conn, %{"id" => id} = params) do
user = Repo.get(ApiExample.User, id)
if user do
changeset = ApiExample.User.changeset(user, params)
case Repo.update(changeset) do
{:ok, user} ->
json conn |> put_status(:ok), user
{:error, result} ->
json conn |> put_status(:bad_request),
%{errors: ["bad update"] }
end
else
json conn |> put_status(:not_found),
%{errors: ["invalid user"] }
end
end
This was my first attempt. The put request received an additional parameter which contains the id of the record being updated. We use pattern matching to extract that id value into a variable of the same name. We then use an Ecto get to look for a current record with that id value. If that use exists, we use the update changeset with Ecto update to perform the update. Otherwise we return an invalid user error.
So we now have basic get, post and put endpoints that work. We’ll now work to do some cleanup the code and return more useful error messages.
Code Cleanup
Now that we have a working example, let’s clean up the code a bit. The nested conditional in the update function is quite ugly, so I will factor out the “happy path” into a private function.
defp perform_update(conn, user, params) do
changeset = ApiExample.User.changeset(user, params)
case Repo.update(changeset) do
{:ok, user} ->
json conn |> put_status(:ok), user
{:error, _result} ->
json conn |> put_status(:bad_request),
%{errors: ["unable to update user"]}
end
end
This allows us to simplify the update function like this.
def update(conn, %{"id" => id} = params) do
user = Repo.get(ApiExample.User, id)
if user do
perform_update(conn, user, params)
else
json conn |> put_status(:not_found),
%{errors: ["invalid user"]}
end
end
This feels a lot cleaner to me. I’m still not happy that I don’t have a consistent way of adding the status to the conn map, but I’m not going to improve that at this point.
Conclusion
We now have a very simple REST API to create, read and update this simple user object. I’ve purposely not performed any validation (other than uniqueness) on the model as I intend to use this in conjunction with an Elm front-end that performs validation. Eventually I will add more validation and return that validation back to the Elm front end.
I found that creating the create (POST) and update (PUT) endpoints was relatively straightforward. If you are used to using another Rails like framework, it should feel quite familiar. The only major difference is the separation of the changeset from the model. This seems like a little extra overhead at first but for more complex situations, you may want different changesets for different actions. In this case, the distinct changeset is very valuable.
In my next post, I will integrate this simple API with and Elm front end and demonstrate how we can extend that validation logic to perform uniqueness checking on top of the existing validations.