[011.3] Adding Channels for our Data Store

Creating JSON representations of our resources and introducing a channel to fetch data from.

Subscribe now

Adding Channels for our Data Store [09.13.2017]

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@example.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@example.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@example.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@example.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@example.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@example.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!

Resources