We're building an Elm client for Firestorm that interacts via channels. We will start with a single channel for fetching the initial data. In the process, we'll test and define a JSON encoding for each of our resources. Let's get started.
Project
I've made a tag of the dailydrip/firestorm repo before this episode , but we're going to just discuss the code so I've checked out the repo after this episode
First, let's look at how we represent a category in JSON. We'll start by looking
at the
FetchViewTest
:
vim test/web/views/api/v1/fetch_view_test.exs
We'll always render a
ReplenishResponse
for our channels. This is just a
struct that has a key for each type of resource, where each value is a list of
those resources. I've written a test for the
FetchView
that tests that we can
return each type of resource:
defmodule FirestormWeb.Web.Api.V1.FetchViewTest do
use FirestormWeb.Web.ConnCase, async: true
alias FirestormWeb.Web.Api.V1.{FetchView, CategoryView, ThreadView, PostView, UserView}
alias FirestormWeb.Store.ReplenishResponse
alias FirestormWeb.Forums
test "rendering an empty ReplenishResponse successfully" do
result = FetchView.render("index.json", %ReplenishResponse{})
assert %{categories: [], users: [], threads: [], posts: []} == result
end
test "rendering a ReplenishResponse with a category" do
{:ok, elixir} = Forums.create_category(%{title: "Elixir"})
result = FetchView.render("index.json", %ReplenishResponse{categories: [elixir]})
elixir_json = CategoryView.render("show.json", elixir)
assert %{categories: [elixir_json], users: [], threads: [], posts: []} == result
end
test "rendering a ReplenishResponse with a thread" do
{:ok, elixir} = Forums.create_category(%{title: "Elixir"})
{:ok, bob} = Forums.create_user(%{email: "bob@smoothterminal.com", name: "Bob Vladbob", username: "bob"})
{:ok, otp_is_cool} = Forums.create_thread(elixir, bob, %{title: "OTP is cool", body: "Don't you think?"})
result = FetchView.render("index.json", %ReplenishResponse{threads: [otp_is_cool]})
otp_is_cool_json = ThreadView.render("show.json", otp_is_cool)
assert %{categories: [], users: [], threads: [otp_is_cool_json], posts: []} == result
end
test "rendering a ReplenishResponse with a post" do
{:ok, elixir} = Forums.create_category(%{title: "Elixir"})
{:ok, bob} = Forums.create_user(%{email: "bob@smoothterminal.com", name: "Bob Vladbob", username: "bob"})
{:ok, otp_is_cool} = Forums.create_thread(elixir, bob, %{title: "OTP is cool", body: "Don't you think?"})
{:ok, yup} = Forums.create_post(otp_is_cool, bob, %{body: "Yup"})
result = FetchView.render("index.json", %ReplenishResponse{posts: [yup]})
yup_json = PostView.render("show.json", yup)
assert %{categories: [], users: [], threads: [], posts: [yup_json]} == result
end
test "rendering a ReplenishResponse with a user" do
{:ok, bob} = Forums.create_user(%{email: "bob@smoothterminal.com", name: "Bob Vladbob", username: "bob"})
result = FetchView.render("index.json", %ReplenishResponse{users: [bob]})
bob_json = UserView.render("show.json", bob)
assert %{categories: [], users: [bob_json], threads: [], posts: []} == result
end
end
Let's look at the definition of
ReplenishResponse
and
FetchView
vim lib/firestorm_web/store/replenish_response.ex
This is just a struct that holds a list of each type of resource that our system knows about. We're structuring the protocol this way because we want to be able to return any information we wish to the frontend - for instance, imagine you asked for a post but we also sent you the post's user. Ultimately we could make these channels stateful, and know whether or not you needed us to do that ahead of time.
defmodule FirestormWeb.Store.ReplenishResponse do
defstruct categories: [], threads: [], users: [], posts: []
alias FirestormWeb.Forum.{Category, Thread, User, Post}
@type t :: %FirestormWeb.Store.ReplenishResponse{
categories: list(Category.t),
threads: list(Thread.t),
users: list(User.t),
posts: list(Post.t),
}
end
Next, we'll look at the
FetchView
:
vim lib/firestorm_web/web/views/api/v1/fetch_view.ex
This module just renders a map containing a key for each resource type, and maps each of the resources through its corresponding render function:
defmodule FirestormWeb.Web.Api.V1.FetchView do
use FirestormWeb.Web, :view
alias FirestormWeb.Store.ReplenishResponse
alias FirestormWeb.Web.Api.V1.{CategoryView, ThreadView, PostView, UserView}
def render("index.json", %ReplenishResponse{categories: categories, users: users, threads: threads, posts: posts}) do
%{
categories: render_categories(categories),
threads: render_threads(threads),
posts: render_posts(posts),
users: render_users(users)
}
end
defp render_categories(categories) do
categories
|> Enum.map(&(CategoryView.render("show.json", &1)))
end
defp render_threads(threads) do
threads
|> Enum.map(&(ThreadView.render("show.json", &1)))
end
defp render_posts(posts) do
posts
|> Enum.map(&(PostView.render("show.json", &1)))
end
defp render_users(users) do
users
|> Enum.map(&(UserView.render("show.json", &1)))
end
end
We can look at the
CategoryView
module to see how we render a
Category
:
vim lib/firestorm_web/web/views/api/v1/category_view.ex
defmodule FirestormWeb.Web.Api.V1.CategoryView do
use FirestormWeb.Web, :view
alias FirestormWeb.Forums.Category
def render("show.json", %Category{id: id, title: title, slug: slug, inserted_at: inserted_at, updated_at: updated_at}) do
%{
id: id,
title: title,
slug: slug,
inserted_at: inserted_at,
updated_at: updated_at
}
end
end
The rest of the resources have views defined in much the same way:
defmodule FirestormWeb.Web.Api.V1.ThreadView do
use FirestormWeb.Web, :view
alias FirestormWeb.Forums.Thread
def render("show.json", %Thread{id: id, title: title, inserted_at: inserted_at, updated_at: updated_at, category_id: category_id}) do
%{
id: id,
title: title,
inserted_at: inserted_at,
updated_at: updated_at,
category_id: category_id
}
end
end
defmodule FirestormWeb.Web.Api.V1.PostView do
use FirestormWeb.Web, :view
alias FirestormWeb.Forums.Post
def render("show.json", %Post{id: id, body: body, inserted_at: inserted_at, updated_at: updated_at, thread_id: thread_id}) do
%{
id: id,
body: body,
inserted_at: inserted_at,
updated_at: updated_at,
thread_id: thread_id
}
end
end
defmodule FirestormWeb.Web.Api.V1.UserView do
use FirestormWeb.Web, :view
alias FirestormWeb.Forums.User
def render("show.json", %User{id: id, username: username, name: name, inserted_at: inserted_at, updated_at: updated_at}) do
%{
id: id,
name: name,
username: username,
inserted_at: inserted_at,
updated_at: updated_at,
}
end
end
With this, we have the basic response ready for our channels. We wanted to
introduce a channel that we can fetch specific data out of. We fetch the data by
sending in a
ReplenishRequest
. Let's model a
ReplenishRequest
:
defmodule FirestormWeb.Store.ReplenishRequest do
# It is just a struct with a list of ids or slugs to look up resources by.
defstruct categories: [], threads: [], users: [], posts: []
@typep finder :: integer | String.t
@type t :: %FirestormWeb.Store.ReplenishRequest{
categories: list(finder),
threads: list(finder),
users: list(finder),
posts: list(finder),
}
# Requesting a category will place the finder into the categories list for the
# ReplenishRequest
def request_category(request, category_id) do
%__MODULE__{ request | categories: [category_id | request.categories] }
end
# Ditto for other resources
def request_thread(request, thread_id) do
%__MODULE__{ request | threads: [thread_id | request.threads] }
end
def request_user(request, user_id) do
%__MODULE__{ request | users: [user_id | request.users] }
end
def request_post(request, post_id) do
%__MODULE__{ request | posts: [post_id | request.posts] }
end
end
This is a tiny little module that makes it easy to request different resources and transmit the request in bulk.
Our
StoreChannel
will listen for these requests and respond with the requested data.
Let's look at the
StoreChannelTest
.
defmodule FirestormWeb.Web.StoreChannelTest do
use FirestormWeb.Web.ChannelCase
alias FirestormWeb.Web.StoreChannel
alias FirestormWeb.Web.Api.V1.FetchView
alias FirestormWeb.Store.{ReplenishResponse, ReplenishRequest}
alias FirestormWeb.Forums
setup do
{:ok, _, socket} =
socket("user_id", %{some: :assign})
|> subscribe_and_join(StoreChannel, "store:fetch")
{:ok, socket: socket}
end
# If someone sends in a ReplenishRequest that's not asking for anything, we'll
# dutifully not send them anything.
test "responds to an empty request", %{socket: socket} do
request =
empty_replenish_request()
ref = push socket, "fetch", request
assert_response ref, %ReplenishResponse{}
end
# If they ask for a category, we'll give it to them.
test "responds with a category", %{socket: socket} do
{:ok, elixir} = Forums.create_category(%{title: "Elixir"})
request =
empty_replenish_request()
|> ReplenishRequest.request_category(elixir.id)
ref = push socket, "fetch", request
assert_response ref, %ReplenishResponse{categories: [elixir]}
end
# And so on for threads, posts, and users.
test "responds with a thread", %{socket: socket} do
{:ok, elixir} = Forums.create_category(%{title: "Elixir"})
{:ok, bob} = Forums.create_user(%{email: "bob@smoothterminal.com", name: "Bob Vladbob", username: "bob"})
{:ok, otp_is_cool} = Forums.create_thread(elixir, bob, %{title: "OTP is cool", body: "Don't you think?"})
request =
empty_replenish_request()
|> ReplenishRequest.request_thread(otp_is_cool.id)
ref = push socket, "fetch", request
assert_response ref, %ReplenishResponse{threads: [otp_is_cool]}
end
test "responds with a post", %{socket: socket} do
{:ok, elixir} = Forums.create_category(%{title: "Elixir"})
{:ok, bob} = Forums.create_user(%{email: "bob@smoothterminal.com", name: "Bob Vladbob", username: "bob"})
{:ok, otp_is_cool} = Forums.create_thread(elixir, bob, %{title: "OTP is cool", body: "Don't you think?"})
{:ok, yup} = Forums.create_post(otp_is_cool, bob, %{body: "Yup"})
request =
empty_replenish_request()
|> ReplenishRequest.request_post(yup.id)
ref = push socket, "fetch", request
assert_response ref, %ReplenishResponse{posts: [yup]}
end
test "responds with a user", %{socket: socket} do
{:ok, bob} = Forums.create_user(%{email: "bob@smoothterminal.com", name: "Bob Vladbob", username: "bob"})
request =
empty_replenish_request()
|> ReplenishRequest.request_user(bob.id)
ref = push socket, "fetch", request
assert_response ref, %ReplenishResponse{users: [bob]}
end
def empty_replenish_request() do
%ReplenishRequest{}
end
# And here's a little helper we wrote to reduce some boilerplate.
def assert_response(ref, response) do
expected = FetchView.render("index.json", response)
assert_reply ref, :ok, ^expected
end
end
To make this test work, we've added the channel to the
UserSocket
:
defmodule FirestormWeb.Web.UserSocket do
use Phoenix.Socket
## Channels
channel "store:*", FirestormWeb.Web.StoreChannel
# ...
end
Finally, we can look at the
StoreChannel
itself:
defmodule FirestormWeb.Web.StoreChannel do
use FirestormWeb.Web, :channel
alias FirestormWeb.Store.{ReplenishResponse, ReplenishRequest}
alias FirestormWeb.Web.Api.V1.FetchView
alias FirestormWeb.Forums
def join("store:fetch", payload, socket) do
if authorized?(payload) do
{:ok, socket}
else
{:error, %{reason: "unauthorized"}}
end
end
defp authorized?(_) do
true
end
# As the comment below makes clear, this is awful. We'll introduce an Ecto query
# that requests the data in bulk rather than requesting each in its own SQL
# query, but it's low on my priority list until the rest of Firestorm is
# finished.
def handle_in("fetch", replenish_request, socket) do
replenish_request = Poison.decode!(Poison.encode!(replenish_request), as: %ReplenishRequest{})
# TODO: Make this not awful
categories =
replenish_request.categories
|> Enum.map(&(Forums.get_category!(&1)))
threads =
replenish_request.threads
|> Enum.map(&(Forums.get_thread!(&1)))
posts =
replenish_request.posts
|> Enum.map(&(Forums.get_post!(&1)))
users =
replenish_request.users
|> Enum.map(&(Forums.get_user!(&1)))
{:reply, {:ok, FetchView.render("index.json", %ReplenishResponse{categories: categories, threads: threads, posts: posts, users: users})}, socket}
end
end
With this, the Elm client - or any client that can talk Phoenix channels - is capable of requesting any of our data and receiving a standard response.
Summary
Today we introduced channels to our application and defined how to represent each of our resources in JSON. I hope you enjoyed it. See you soon!
Comments for Adding Channels for our Data Store