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.
- For the first version this could work:
Now that we designed our database and api, we can start building it. Here’s the plan:
- Setup the project
- Add Poll resource endpoints
- Add nested Option resource endpoints
- 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:
- Update migration file (and run the migration)
- Update Poll and Option schemas to reflect the association
- Update Context functions
- Add new nested route for Options resource
- Update Controller
- 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:
- Update Vote migration file (and run the migration)
- Update Option and Vote schemas to reflect the association
- Update Context functions and add a new Options query
- Add Vote Route
- Add Poll Channel for real time vote updates
- Update Controller and add websocket broadcast
- 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
- Create a new Options query that includes the calculated
vote_count
field - Update all the functions — that return with an option — to use our custom option query
- Update
create_vote
function to reflect the association - 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.