Yet Another Guide To Build a JSON API with Phoenix 1.5

We are going to build a polling app step-by-step. It will be similar to strawpoll.me where users can create and vote on polls and see the results in real-time.

In this blogpost we will only create the basic version of this app and in later blog posts we will add more features like authentication, ip blocking, different voting types and more.

Github repo: https://github.com/tamas-soos/expoll
Pull request: https://github.com/tamas-soos/expoll/pull/2/files

Prerequisites

  • Elixir v1.10.4
  • Phoenix v1.5.4
  • Postgres v11.2
  • REST client (Postman recommended or curl)
  • Websocket client (Phoenix Channels Chrome extension recommended)
  • Code Editor (VS Code with ElixirLS extension recommended)

App requirements

  • users can create accounts and log in
  • users can create polls with a question and options
  • users can publish their polls to make them available for voting
  • once a poll is published it can’t be edited, but can be voted on
  • when a user unpublished a poll the votes reset
  • anybody can be a voter (no account/registration is required)
  • voters get real-time updates on poll votes via websocket
  • voters can vote only once on each poll

For now we will only focus on the core functionality, which is creating/editing polls and being able to vote on them.

Designing our Database schema and JSON API

DB Design
From these requirements we can start designing our database schema. If we just try to sketch up our schema in a naive, non-relational way, it would look something like this:

- poll
    - id field
    - question field
    - options
        - id field
        - value field
        - votes
            - id field
            - option_id field

Each poll has multiple options and each option has zero or more votes. If we go and normalize this schema we will end up with this entity relationship diagram:

Great, this looks good!

API design
Initially our API is going to have 2 resources, a Poll resource and a nested Option resource:

  • GET - /polls
  • GET POST PUT DELETE - /polls/:id
  • GET - /polls/:id/options
  • GET POST PUT DELETE - /polls/:id/options/:option_id

Then we need a single action endpoint for voting:

  • POST - /polls/:id/vote
    • For the first version this could work: POST /polls/:id/options/:option_id/vote. However, we want to reserve the possibility for a multi-vote feature, where users can vote on multiple options. So the client only has to make a single request, instead of making a request for each option.

Now that we designed our database and api, we can start building it. Here’s the plan:

  1. Setup the project
  2. Add Poll resource endpoints
  3. Add nested Option resource endpoints
  4. Add the ability to vote on a polls

Setup the project

Scaffoling a new project with Phoenix is super easy with it’s generators, just run this command:

mix phx.new ex_poll --no-webpack --no-html

By supplying the --no-webpack and --no-html flags we can skip the frontend parts, since we are only building a JSON API.

After that hit enter to install the dependecies and run mix ecto.create to configure your database.

mix phx.new ex_poll --no-webpack --no-html

* creating ex_poll/config/config.exs
* creating ex_poll/config/dev.exs
* creating ex_poll/config/prod.exs
* creating ex_poll/config/prod.secret.exs
* creating ex_poll/config/test.exs
* creating ex_poll/lib/ex_poll/application.ex
* creating ex_poll/lib/ex_poll.ex
* creating ex_poll/lib/ex_poll_web/channels/user_socket.ex
* creating ex_poll/lib/ex_poll_web/views/error_helpers.ex
* creating ex_poll/lib/ex_poll_web/views/error_view.ex
* creating ex_poll/lib/ex_poll_web/endpoint.ex
* creating ex_poll/lib/ex_poll_web/router.ex
* creating ex_poll/lib/ex_poll_web/telemetry.ex
* creating ex_poll/lib/ex_poll_web.ex
* creating ex_poll/mix.exs
* creating ex_poll/README.md
* creating ex_poll/.formatter.exs
* creating ex_poll/.gitignore
* creating ex_poll/test/support/channel_case.ex
* creating ex_poll/test/support/conn_case.ex
* creating ex_poll/test/test_helper.exs
* creating ex_poll/test/ex_poll_web/views/error_view_test.exs
* creating ex_poll/lib/ex_poll/repo.ex
* creating ex_poll/priv/repo/migrations/.formatter.exs
* creating ex_poll/priv/repo/seeds.exs
* creating ex_poll/test/support/data_case.ex

Fetch and install dependencies? [Yn]
* running mix deps.get
* running mix deps.compile

We are almost there! The following steps are missing:

    $ cd ex_poll

Then configure your database in config/dev.exs and run:

    $ mix ecto.create

Start your Phoenix app with:

    $ mix phx.server

You can also run your app inside IEx (Interactive Elixir) as:

    $ iex -S mix phx.server

Add Poll resource endpoints

Phoenix offers a bunch more generators that can be tremendous timesavers.
One of them is the mix phx.gen.json that will generate all the code to have a complete JSON resource. If you run it, it will output the following:

  • Ecto migration
    • Migrations are used to modify our database schema over time.
  • Context
    • Contexts are dedicated modules that expose and group related functionalities. With Contexts we can decouple and isolate our systems into manageable, independent parts.
  • Controllers
    • Controllers are responsible for making sense of the request, and producing the appropriate output. It will receive a request, fetch or save data using Context functions, and use a View to create JSON (or HTML) output.
  • Views
    • Defines the view layer of a Phoenix application, in our case it will allows us to compose and render JSON data easily.
  • Tests

So let’s run:

mix phx.gen.json Polls Poll polls question:string

The first argument Polls will be our Context module followed by the Poll schema and its plural name for the db table name. The rest are the schema fields.

After running it, we can see it generated all the files we need, great!

mix phx.gen.json Polls Poll polls question:string

* creating lib/ex_poll_web/controllers/poll_controller.ex
* creating lib/ex_poll_web/views/poll_view.ex
* creating test/ex_poll_web/controllers/poll_controller_test.exs
* creating lib/ex_poll_web/views/changeset_view.ex
* creating lib/ex_poll_web/controllers/fallback_controller.ex
* creating lib/ex_poll/polls/poll.ex
* creating priv/repo/migrations/20200811094921_create_polls.exs
* creating lib/ex_poll/polls.ex
* injecting lib/ex_poll/polls.ex
* creating test/ex_poll/polls_test.exs
* injecting test/ex_poll/polls_test.exs

Add the resource to your :api scope in lib/ex_poll_web/router.ex:

    resources "/polls", PollController, except: [:new, :edit]

Remember to update your repository by running migrations:

    $ mix ecto.migrate

Now do as instructed and add the resource to our api scope in lib/ex_poll_web/router.ex.

# lib/ex_poll_web/router.ex

  scope "/api", ExPollWeb do
    pipe_through :api

+   resources "/polls", PollController, except: [:new, :edit]
  end

scope block defines a scope in which routes can be nested, so every endpoint here will be prefixed with api/ and every request will go through the :api plug pipeline.

resources defines “RESTful” routes for a resource, very handy. These routes provide mappings between HTTP verbs (GET, POST, PUT, DELETE, PATCH) to Controller CRUD actions (create, read, update, delete).

After that, update the migration file by adding null: false to disallow creating polls without the quesiton field.

# priv/migrations/<timestamp>_create_polls.exs

  def change do
    create table(:polls) do
-     add :question, :string
+     add :question, :string, null: false

      timestamps()
    end
  end

By using a database constraint like null: false, we enforce data integrity at the database level, rather than relying on ad-hoc and error-prone application logic.

Alright, it’s time to test our app. Run mix ecto.create then mix phx.server and then let’s make a GET poll request.

curl -H "Content-Type: application/json" -X GET http://localhost:4000/api/polls

{"data":[]}

As expected, we have no polls yet so let’s create one and check again.

curl -H "Content-Type: application/json" -X POST -d '{"poll":{"question":"Which is your favourite ice cream?"}}' http://localhost:4000/api/polls

{"data":{"id":1,"question":"Which is your favourite ice cream?"}}

curl -H "Content-Type: application/json" -X GET http://localhost:4000/api/polls/1

{"data":{"id":1,"question":"Which is your favourite ice cream?"}}

Great! Our poll resource endpoint is done, let’s move on and add the options resource.

Add nested Option resource endpoints

Once again we will use the json generator for our Options resource by running:

mix phx.gen.json Polls Option options value:string poll_id:references:polls

By supplying poll_id:references:polls to the generator we can properly associate the given column to the primary key column of the polls table.

After running the generator it will warn us that we are “generating into an existing context”. In this case it’s fine, this is what we want so let’s proceed.

mix phx.gen.json Polls Option options value:string poll_id:references:polls

You are generating into an existing context.

The ExPoll.Polls context currently has 6 functions and 1 files in its directory.

  * It's OK to have multiple resources in the same context as long as they are closely related. But if a context grows too large, consider breaking it apart

  * If they are not closely related, another context probably works better

The fact two entities are related in the database does not mean they belong to the same context.

If you are not sure, prefer creating a new context over adding to the existing one.

Would you like to proceed? [Yn]

* creating lib/ex_poll_web/controllers/option_controller.ex
* creating lib/ex_poll_web/views/option_view.ex
* creating test/ex_poll_web/controllers/option_controller_test.exs
* creating lib/ex_poll/polls/option.ex
* creating priv/repo/migrations/20200811100601_create_options.exs
* injecting lib/ex_poll/polls.ex
* injecting test/ex_poll/polls_test.exs

Add the resource to your :api scope in lib/ex_poll_web/router.ex:

    resources "/options", OptionController, except: [:new, :edit]

Remember to update your repository by running migrations:

    $ mix ecto.migrate

Great, however after we generated the code it won’t just work out of the box, we do need to make these changes:

  1. Update migration file (and run the migration)
  2. Update Poll and Option schemas to reflect the association
  3. Update Context functions
  4. Add new nested route for Options resource
  5. Update Controller
  6. Update View

Update migration file

Here we change the on_delete :nothing option to on_delete :delete_all. It will generate a foreign key constraint that will delete all options for a given poll when the poll is removed from the database.

# priv/migrations/<timestamp>_create_options.exs

  def change do
    create table(:options) do
-     add :value, :string
-     add :poll_id, references(:polls, on_delete: :nothing)
+     add :value, :string, null: false
+     add :poll_id, references(:polls, on_delete: :delete_all), null: false
	
      timestamps()
    end
	
    create index(:options, [:poll_id])
  end

Once we made the changes we can run mix ecto.migrate.

Update Poll and Options schemas to reflect the association

# lib/ex_poll/polls/poll.ex

+ alias ExPoll.Polls.Option

  schema "polls" do
    field :question, :string
+   has_many(:options, Option, on_replace: :delete)
	
    timestamps()
  end
	
  @doc false
  def changeset(poll, attrs) do
    poll
    |> cast(attrs, [:question])
+   |> cast_assoc(:options)
    |> validate_required([:question])
  end

has_many indicates a one-to-many association with another schema. The current schema has zero or more records of the other schema. The other schema often has a belongs_to field with the reverse association.

cast_assoc is used when you want to create the associated record along with your changeset. In this case create a Poll with Options with a single changeset.

# lib/ex_poll/polls/option.ex

+ alias ExPoll.Polls.Poll

  schema "options" do
    field :value, :string
-   field :poll_id, :id
+   belongs_to(:poll, Poll)
	
    timestamps()
  end
	
  def changeset(option, attrs) do
    option
    |> cast(attrs, [:value])
    |> validate_required([:value])
+   |> assoc_constraint(:poll)
  end

assoc_constraint will check if the associated field exists. This is similar to foreign key constraint except that the field is inferred from the association definition. This is useful to guarantee that a child will only be created if the parent exists in the database too. Therefore, it only applies to belongs_to associations.

Update Context functions

# lib/ex_poll/polls.ex

- def get_poll!(id), do: Repo.get!(Poll, id)
+ def get_poll!(id) do
+   Poll
+   |> Repo.get!(id)
+   |> Repo.preload(:options)
+ end

  def create_poll(attrs \\ %{}) do
    %Poll{}
    |> Poll.changeset(attrs)
    |> Repo.insert()
+   |> case do
+     {:ok, %Poll{} = poll} -> {:ok, Repo.preload(poll, :options)}
+     error -> error
+   end
  end
	
- def create_option(attrs \\ %{}) do
-   %Option{}
+ def create_option(%Poll{} = poll, attrs \\ %{}) do
+   poll
+   |> Ecto.build_assoc(:options)
    |> Option.changeset(attrs)
    |> Repo.insert()
  end

Repo.preload is a powerful tool that helps us to avoid N+1 queries by forcing us to be explicit about what associations we want to bring alongside our main data. It allows us to preload structs after they have been fetched from the database.

Ecto.build_assoc used when we are creating a new record and we want to associate it with a parent record by setting a foreign key which is inferred from the parent struct.

While we are here we can also delete list_options since we will never list literally every option, just the ones that are associated with a given poll.

Alright, let’s test out our app by running iex -S mix

$ iex -S mix
...
iex> alias ExPoll.Polls
...
iex> {:ok, poll} = Polls.create_poll(%{question: "Which one is your favourite food?"})
...
iex> Polls.create_option(poll, %{value: "Pizza"})
...
{:ok,
 %ExPoll.Polls.Option{
   __meta__: #Ecto.Schema.Metadata<:loaded, "options">,
   id: 7,
   inserted_at: ~N[2020-08-11 11:25:47],
   poll: #Ecto.Association.NotLoaded<association :poll is not loaded>,
   poll_id: 5,
   updated_at: ~N[2020-08-11 11:25:47],
   value: "Pizza"
 }}

iex> Polls.get_poll!(5)                              
...
%ExPoll.Polls.Poll{
  __meta__: #Ecto.Schema.Metadata<:loaded, "polls">,
  id: 5,
  inserted_at: ~N[2020-08-11 11:24:25],
  options: [
    %ExPoll.Polls.Option{
      __meta__: #Ecto.Schema.Metadata<:loaded, "options">,
      id: 7,
      inserted_at: ~N[2020-08-11 11:25:47],
      poll: #Ecto.Association.NotLoaded<association :poll is not loaded>,
      poll_id: 5,
      updated_at: ~N[2020-08-11 11:25:47],
      value: "Pizza"
    }
  ],
  question: "Which one is your favourite food?",
  updated_at: ~N[2020-08-11 11:24:25]
}

iex> Polls.update_poll(poll, %{question: "Which one is the best pizza?", options: [%{value: "Margherita"}, %{value: "Pineapple"}]})
...
{:ok,
 %ExPoll.Polls.Poll{
   __meta__: #Ecto.Schema.Metadata<:loaded, "polls">,
   id: 5,
   inserted_at: ~N[2020-08-11 11:24:25],
   options: [
     %ExPoll.Polls.Option{
       __meta__: #Ecto.Schema.Metadata<:loaded, "options">,
       id: 8,
       inserted_at: ~N[2020-08-11 11:30:07],
       poll: #Ecto.Association.NotLoaded<association :poll is not loaded>,
       poll_id: 5,
       updated_at: ~N[2020-08-11 11:30:07],
       value: "Margherita"
     },
     %ExPoll.Polls.Option{
       __meta__: #Ecto.Schema.Metadata<:loaded, "options">,
       id: 9,
       inserted_at: ~N[2020-08-11 11:30:07],
       poll: #Ecto.Association.NotLoaded<association :poll is not loaded>,
       poll_id: 5,
       updated_at: ~N[2020-08-11 11:30:07],
       value: "Pineapple"
     }
   ],
   question: "Which one is the best pizza?",
   updated_at: ~N[2020-08-11 11:30:07]
 }}

Great! Our core application works, now we need to update the web parts so we can interface with it through an api.

Add new nested route for Options resource

# lib/ex_poll_web/router.ex

  scope "/api", ExPollWeb do
    pipe_through :api
	
-   resources "/polls", PollController, except: [:new, :edit]
+   resources "/polls", PollController, except: [:new, :edit] do
+     resources "/options", OptionController, except: [:new, :edit]
+   end
  end

If we use the handy mix phx.routes task, it will print out all the routes and we can see that our options path is correctly nested. Great!

          poll_path  GET     /api/polls                       ExPollWeb.PollController :index
          poll_path  GET     /api/polls/:id                   ExPollWeb.PollController :show
          poll_path  POST    /api/polls                       ExPollWeb.PollController :create
          poll_path  PATCH   /api/polls/:id                   ExPollWeb.PollController :update
                     PUT     /api/polls/:id                   ExPollWeb.PollController :update
          poll_path  DELETE  /api/polls/:id                   ExPollWeb.PollController :delete
   poll_option_path  GET     /api/polls/:poll_id/options      ExPollWeb.OptionController :index
   poll_option_path  GET     /api/polls/:poll_id/options/:id  ExPollWeb.OptionController :show
   poll_option_path  POST    /api/polls/:poll_id/options      ExPollWeb.OptionController :create
   poll_option_path  PATCH   /api/polls/:poll_id/options/:id  ExPollWeb.OptionController :update
                     PUT     /api/polls/:poll_id/options/:id  ExPollWeb.OptionController :update
   poll_option_path  DELETE  /api/polls/:poll_id/options/:id  ExPollWeb.OptionController :delete
...

Update Controller

# lib/ex_poll_web/controllers/option_controller.ex	

- def index(conn, _params) do
-   options = Polls.list_options()
-   render(conn, "index.json", options: options)
+ def index(conn, %{"poll_id" => poll_id}) do
+   poll = Polls.get_poll!(poll_id)
+   render(conn, "index.json", options: poll.options)
  end
	
- def create(conn, %{"option" => option_params}) do
+ def create(conn, %{"poll_id" => poll_id, "option" => option_params}) do
+   poll = Polls.get_poll!(poll_id)
	
-   with {:ok, %Option{} = option} <- Polls.create_option(option_params) do
+   with {:ok, %Option{} = option} <- Polls.create_option(poll, option_params) do
      conn
      |> put_status(:created)
-     |> put_resp_header("location", Routes.option_path(conn, :show, option))
+     |> put_resp_header("location", Routes.poll_option_path(conn, :show, poll_id, option))
      |> render("show.json", option: option)
    end
  end

Update the controller with our new updated context functions. Then fix the Routes.option_path to Routes.poll_option_path since options is now a nested under poll.

Update View

We will add a new view for the poll resource, which is going to be used whenever we get a single poll.

# ex_poll_web/views/poll_view.ex

- alias ExPollWeb.PollView
+ alias ExPollWeb.{PollView, OptionView}
	
  def render("show.json", %{poll: poll}) do
-   %{data: render_one(poll, PollView, "poll.json")}
+   %{data: render_one(poll, PollView, "poll_with_options.json")}
  end

+ def render("poll_with_options.json", %{poll: poll}) do
+   %{
+     id: poll.id,
+     question: poll.question,
+     options: render_many(poll.options, OptionView, "option.json")
+   }
+ end

After we made the changes we can test the api. Let’s run the server with mix phx.server and make some requests.

curl -H "Content-Type: application/json" -X GET http://localhost:4000/api/polls/4

{"data":{"id":4,"options":[{"id":6,"value":"Strawberry"},{"id":5,"value":"Vanilla"},{"id":4,"value":"Chocolate"}],"question":"Which one is your favourote ice cream?"}}

curl -H "Content-Type: application/json" -X GET http://localhost:4000/api/polls/4/options/6

{"data":{"id":6,"value":"Strawberry"}}

curl -H "Content-Type: application/json" -X POST -d '{"option":{"value":"Salted Caramel"}}' http://localhost:4000/api/polls/4/options

{"data":{"id":12,"value":"Salted Caramel"}}

Great — now we have an Option resource that is correctly associated with the Polls resource. Let’s move on and add the ability to vote on polls.

Add the ability to vote on a polls

We will use the json generator one last time. Let’s run the command below and proceed with the override.

mix phx.gen.json Polls Vote votes option_id:references:options

mix phx.gen.json Polls Vote votes option_id:references:options

You are generating into an existing context.

The ExPoll.Polls context currently has 12 functions and 2 files in its directory.

  * It's OK to have multiple resources in the same context as long as they are closely related. But if a context grows too large, consider breaking it apart

  * If they are not closely related, another context probably works better

The fact two entities are related in the database does not mean they belong to the same context.

If you are not sure, prefer creating a new context over adding to the existing one.

Would you like to proceed? [Yn] 
* creating lib/ex_poll_web/controllers/vote_controller.ex
* creating lib/ex_poll_web/views/vote_view.ex
* creating test/ex_poll_web/controllers/vote_controller_test.exs
* creating lib/ex_poll/polls/vote.ex
* creating priv/repo/migrations/20200812101756_create_votes.exs
* injecting lib/ex_poll/polls.ex
* injecting test/ex_poll/polls_test.exs

Add the resource to your :api scope in lib/ex_poll_web/router.ex:

    resources "/votes", VoteController, except: [:new, :edit]

Remember to update your repository by running migrations:

    $ mix ecto.migrate

Great, now let’s make some changes:

  1. Update Vote migration file (and run the migration)
  2. Update Option and Vote schemas to reflect the association
  3. Update Context functions and add a new Options query
  4. Add Vote Route
  5. Add Poll Channel for real time vote updates
  6. Update Controller and add websocket broadcast
  7. Update Option View

Update Vote migration file

Let’s repeat what we have done with the previous migration and switch to on_delete :delete_all so when the referenced option is deleted all the votes that are associated with that option will be deleted.

# priv/migrations/<timestamp>_create_votes.exs

  def change do
    create table(:votes) do
-     add :option_id, references(:options, on_delete: :nothing)
+     add :option_id, references(:options, on_delete: :delete_all), null: false
	
      timestamps()
    end
	
    create index(:votes, [:option_id])
  end

Don’t forget to run mix ecto.migrate after we made the changes.

Update Option and Vote schemas to reflect the association

# lib/ex_poll/polls/option.ex

- alias ExPoll.Polls.Poll
+ alias ExPoll.Polls.{Poll, Vote}
	
  schema "options" do
    field :value, :string
+   field :vote_count, :integer, default: 0, virtual: true
    belongs_to(:poll, Poll)
+   has_many(:votes, Vote)
    timestamps()
  end

We will add vote_count as a virtual field here. Virtual fields are useful because it becomes part of the Option struct, but it won’t be persisted into the database. For our use-case there’s no need to persist the vote count since it can be calculated from a simple SQL query.

# lib/ex_poll/polls/vote.ex

+ alias ExPoll.Polls.Option

  schema "votes" do
-   field :option_id, :id
+   belongs_to(:option, Option)
	
    timestamps()
  end
	
  @doc false
  def changeset(vote, attrs) do
    vote
    |> cast(attrs, [])
    |> validate_required([])
+   |> assoc_constraint(:option)
  end

Update Context functions and add a new Options query

  1. Create a new Options query that includes the calculated vote_count field
  2. Update all the functions — that return with an option — to use our custom option query
  3. Update create_vote function to reflect the association
  4. Delete list_votes, get_vote!, update_vote, delete_vote functions
# lib/ex_poll/polls.ex

...

+ defp poll_with_options_query(id) do
+   from p in Poll,
+     where: p.id == ^id,
+     preload: [options: ^options_query()]
+ end

  def get_poll!(id) do
-   Poll
-   |> Repo.get!(id)
-   |> Repo.preload(:options)
+   id
+   |> poll_with_options_query()
+   |> Repo.one!()
  end

  def create_poll(attrs \\ %{}) do
    %Poll{}
    |> Poll.changeset(attrs)
    |> Repo.insert()
    |> case do
-     {:ok, %Poll{} = poll} -> {:ok, Repo.preload(poll, :options)}
+     {:ok, %Poll{} = poll} -> {:ok, Repo.preload(poll, options: options_query())}
      error -> error
    end
  end
	
...

+ defp options_query do
+   from o in Option,
+     left_join: v in assoc(o, :votes),
+     group_by: o.id,
+     select_merge: %{vote_count: count(v.id)}
+ end

+ defp option_query(id) do
+   from o in options_query(),
+     where: o.id == ^id
+ end
	
- def get_option!(id), do: Repo.get!(Option, id)
+ def get_option!(id) do
+   id
+   |> option_query()
+   |> Repo.one!()
+ end
	
...
	
- def create_vote(attrs \\ %{}) do
+ def create_vote(%Option{} = option) do
-   %Vote{}
-   |> Vote.changeset(attrs)
+   option
+   |> Ecto.build_assoc(:votes)
+   |> change_vote()
    |> Repo.insert()
  end

preload: [options: ^options_query()] is used to customize how we want the preload to be fetched.

select_merge is macro that is useful for merging and composing selects. This select_merge: %{vote_count: count(v.id)} will translate into this select: %{o | vote_count: count(v.id)}).

Nice! Time to test out our elixir app again. Run iex -S mix and check whether voting on an option works and the vote_count field is properly displayed.

$ iex -S mix
...
iex> option = Polls.get_option!(6)
...
%ExPoll.Polls.Option{
  __meta__: #Ecto.Schema.Metadata<:loaded, "options">,
  id: 6,
  inserted_at: ~N[2020-08-11 11:19:38],
  poll: #Ecto.Association.NotLoaded<association :poll is not loaded>,
  poll_id: 4,
  updated_at: ~N[2020-08-11 11:19:38],
  value: "Strawberry",
  vote_count: 0,
  votes: #Ecto.Association.NotLoaded<association :votes is not loaded>
}

iex> Polls.create_vote(option)
...
{:ok,
 %ExPoll.Polls.Vote{
   __meta__: #Ecto.Schema.Metadata<:loaded, "votes">,
   id: 2,
   inserted_at: ~N[2020-08-12 13:10:41],
   option: #Ecto.Association.NotLoaded<association :option is not loaded>,
   option_id: 6,
   updated_at: ~N[2020-08-12 13:10:41]
 }}

iex> option = Polls.get_option!(6)
...
%ExPoll.Polls.Option{
  __meta__: #Ecto.Schema.Metadata<:loaded, "options">,
  id: 6,
  inserted_at: ~N[2020-08-11 11:19:38],
  poll: #Ecto.Association.NotLoaded<association :poll is not loaded>,
  poll_id: 4,
  updated_at: ~N[2020-08-11 11:19:38],
  value: "Strawberry",
  vote_count: 1,
  votes: #Ecto.Association.NotLoaded<association :votes is not loaded>
}

Add Vote Route

For voting we only need a single action endpoint, so let’s add it.

# lib/ex_poll_web/router.ex

  scope "/api", ExPollWeb do
    pipe_through :api
	
    resources "/polls", PollController, except: [:new, :edit] do
      resources "/options", OptionController, except: [:new, :edit]
    end

+   post("/polls/:id/vote", VoteController, :create)
  end

Add Poll Channel

Channels enable soft real-time communication via websocket with and between connected clients. Here we will create a poll:<poll_id> channel to which users can connect to and recieve real-time vote updates.

# lib/ex_poll_web/channels/user_socket.ex

- # channel "room:*", ExPollWeb.RoomChannel
+ channel "poll:*", ExPollWeb.PollChannel
# lib/ex_poll_web/channels/poll_channel.ex

+ defmodule ExPollWeb.PollChannel do
+   use ExPollWeb, :channel
+	
+   def join("poll:" <> _poll_id, _payload, socket) do
+     {:ok, socket}
+   end
+ end

Update Controller and Add websocket broadcast

First we update the controller with our new Context functions. Then we will add the Endpoint.broadcast! that will enable us to broadcast a new vote to all connected users in real-time.

While we are here let’s also delete index, show, update, delete functions, since we are never going to use them.

# lib/ex_poll_web/controllers/vote_controller.ex

+ alias ExPollWeb.Endpoint

- def create(conn, %{"vote" => vote_params}) do
+ def create(conn, %{"id" => id, "vote" => %{"option_id" => option_id}}) do
+   option = Polls.get_option!(option_id)

-   with {:ok, %Vote{} = vote} <- Polls.create_vote(vote_params) do
+   with {:ok, %Vote{} = vote} <- Polls.create_vote(option) do
+     Endpoint.broadcast!("poll:" <> id, "new_vote", %{option_id: option.id})

      conn
      |> put_status(:created)
-     |> put_resp_header("location", Routes.vote_path(conn, :show, vote))
      |> render("show.json", vote: vote)
    end
  end

Endpoint.broadcast! will broadcast to all nodes a "new_vote" event with a %{option_id: option.id} message to a given poll:<poll_id> topic.

Update Option View

Lastly, we need to update the option view to render with our new vote_count field.

# lib/ex_poll_web/views/option_view.ex

  def render("option.json", %{option: option}) do
-   %{id: option.id, value: option.value}
+   %{
+     id: option.id,
+     value: option.value,
+     vote_count: option.vote_count
+   }
  end

Wrapping up

That’s it for now, you’ve seen how to create a basic Phoenix JSON API. If you want to see how tests are done please check the pull request below.

In the upcoming blogposts we will add more functionality, until then you can reach out to me on twitter or leave a feedback here in the comment section.

Github repo: https://github.com/tamas-soos/expoll
Pull request: https://github.com/tamas-soos/expoll/pull/2/files

Thanks to Alvise Susmel, Lukas Potepa and Alexandra Abbas for reading/reviewing drafts of this.