elixirphoenix-frameworkecto

"(Protocol.UndefinedError) protocol Enumerable not implemented for" when rendering phoenix view


New to Elixir, Phoenix/Ecto, and Erlang in general, so bear with me.

I'm following other working examples of defining the Model, View and Controller in the Phoenix with Ecto and I'm just not understanding why their versions are working while mine is not. I'm trying to use TDD to bootstrap my way in to a working API (completely new for the service), and just keep running in to this Enumerable issue.

The model (conversation) is taken directly from an Exto Schema and changeset, and is being passed to a view using Phoenix's tools for linking views and models together. While I don't have a separate service class to hide the Ecto integration at this point, the code otherwise seems to be almost exactly as other, working controller's in our system. So what am I missing?

Here's my code segments: router code


      scope "/conversations" do
        post "/", ConversationController, :create
      end

Controller

defmodule ThingyWeb.ConversationController do
  use ThingyWeb, :controller

  alias Thingy.Conversations.Conversation
  alias Thingy.Repo

  def create(conn, conversation_params) do
    case Thingy.Auth.validate_session(conn) do
      {:ok, _} ->
        result =
          %Conversation{}
          |> Conversation.changeset(conversation_params)
          |> Repo.insert()

        case result do
          {:ok, conversation} ->
            IO.puts("store to db went well, now trying to render response")
            IO.inspect(conversation)
            conn
            |> put_status(201)
            |> render("created.json", conversation)

          {:error, _} ->
            conn
            |> put_status(400)
            |> render("error.json", %{message: "Unable to process conversation as provided"})
        end

      {:error, "No token"} ->
        conn
        |> put_status(401)
        |> render("error.json", %{message: "Authentication failed (no token)"})

      {:error, "Not found"} ->
        conn
        |> put_status(404)
        |> render("error.json", %{message: "Authentication failed (not found)"})
    end
  end
end

Model

defmodule Thingy.Conversations.Conversation do
  use Ecto.Schema
  import Ecto.Changeset

  schema "conversations" do
    field :title, :string
    field :start_date_time, :utc_datetime

    timestamps()
  end

  @doc false
  def changeset(conversation, attrs) do
    conversation
    |> cast(attrs, [:title, :start_date_time])
    |> validate_required([:title, :start_date_time])
  end
end

View

defmodule ThingyWeb.ConversationView do
  use ThingyWeb, :view

  def render("error.json", %{message: message}) do
    %{
      errors: [message]
    }
  end

  def render("created.json", %{conversation: conversation}) do
    render_one(conversation, ConversationView, "conversation.json")
  end

  def render("conversation.json", %{conversation: conversation}) do
    %{
      id: conversation.id,
      title: conversation.title
    }
  end
end

Test

 describe "Conversation operations" do
    setup [:login_user]

    test "able to provide details of a new conversation, and receive the assigned id in response",
         %{
           conn: conn,
           authentication: authentication
         } do
          IO.puts("starting problem test")
      conn =
        conn
        |> put_req_header("authorization", "Bearer " <> authentication.meallogger_token)
        |> put_req_header("content-type", "application/json")
        |> post(
          Routes.conversation_path(conn, :create),
          %{
            title: "new conversation",
            start_date_time: "2024-06-14T15:30:00Z",
          }
        )

      resp = json_response(conn, 201)

      assert %{
               "id" => _id,
             } = resp

      case validate_return_properties(resp, @expected_create_response_properties) do
        {:error, extra_keys} ->
          assert false, "there were extraneous keys in the json response: #{extra_keys}"
      end
    end
  end

Note: Other tests exist and do not fail, but they are all null/error case tests and therefore are not trying to render a response

Test Output and failure

starting problem test
store to db went well, now trying to render response
%Metabite.Conversations.Conversation{
  __meta__: #Ecto.Schema.Metadata<:loaded, "conversations">,
  id: 50,
  title: "new conversation",
  start_date_time: ~U[2024-06-14 15:30:00Z],
  inserted_at: ~N[2024-06-07 06:30:42],
  updated_at: ~N[2024-06-07 06:30:42]
}
Mix task exited with reason
normal
returning code 0

1) test Conversation operations able to provide details of a new conversation, and receive the assigned id in response (ThingyWeb.ConversationControllerTest)
     test/thingy_web/controllers/conversation_controller_test.exs:50
     ** (Protocol.UndefinedError) protocol Enumerable not implemented for %{id: 49, title: "new conversation", __struct__: Thingy.Conversations.Conversation, layout: false, inserted_at: ~N[2024-06-07 06:30:40], conn: %Plug.Conn{adapter: {Plug.Adapters.Test.Conn, :...}, assigns: %{id: 49, title: "new conversation", __struct__: Thingy.Conversations.Conversation, layout: false, inserted_at: ~N[2024-06-07 06:30:40], __meta__: #Ecto.Schema.Metadata<:loaded, "conversations">, start_date_time: ~U[2024-06-14 15:30:00Z], updated_at: ~N[2024-06-07 06:30:40], current_user: 1}, body_params: %{"start_date_time" => "2024-06-14T15:30:00Z", "title" => "new conversation"}, cookies: %{}, halted: false, host: "www.example.com", method: "POST", owner: #PID<0.587.0>, params: %{"start_date_time" => "2024-06-14T15:30:00Z", "title" => "new conversation"}, path_info: ["api", "v1", "conversations"], path_params: %{}, port: 80, private: %{ThingyWeb.Router => {[], %{PhoenixSwagger.Plug.SwaggerUI => []}}, :phoenix_view => ThingyWeb.ConversationView, :phoenix_template => "created.json", :phoenix_router => ThingyWeb.Router, :phoenix_endpoint => ThingyWeb.Endpoint, :phoenix_action => :create, :phoenix_controller => ThingyWeb.ConversationController, :before_send => [#Function<0.54455629/1 in Plug.Telemetry.call/2>], :plug_session_fetch => #Function<1.76384852/1 in Plug.Session.fetch_session/1>, :plug_skip_csrf_protection => true, :phoenix_recycled => true, :phoenix_request_logger => {"request_logger", "request_logger"}, :phoenix_format => "json", :phoenix_layout => {ThingyWeb.LayoutView, :app}}, query_params: %{}, query_string: "", remote_ip: {127, 0, 0, 1}, req_cookies: %{}, req_headers: [{"accept", "application/json"}, {"authorization", "Bearer auth-token-123"}, {"content-type", "application/json"}], request_path: "/api/v1/conversations", resp_body: nil, resp_cookies: %{}, resp_headers: [{"cache-control", "max-age=0, private, must-revalidate"}, {"x-request-id", "F9alGnAy2l0PbroAAAbB"}], scheme: :http, script_name: [], secret_key_base: :..., state: :unset, status: 201}, __meta__: #Ecto.Schema.Metadata<:loaded, "conversations">, start_date_time: ~U[2024-06-14 15:30:00Z], updated_at: ~N[2024-06-07 06:30:40], current_user: 1} of type Thingy.Conversations.Conversation (a struct). This protocol is implemented for the following type(s): DBConnection.PrepareStream, DBConnection.Stream, Date.Range, Ecto.Adapters.SQL.Stream, File.Stream, Function, GenEvent.Stream, HashDict, HashSet, IO.Stream, Jason.OrderedObject, JasonV.OrderedObject, List, Map, MapSet, Phoenix.LiveView.LiveStream, Postgrex.Stream, Range, Stream
     code: |> post(
     stacktrace:
       (elixir 1.16.3) lib/enum.ex:1: Enumerable.impl_for!/1
       (elixir 1.16.3) lib/enum.ex:166: Enumerable.reduce/3
       (elixir 1.16.3) lib/enum.ex:4396: Enum.reverse/1
       (elixir 1.16.3) lib/enum.ex:3726: Enum.to_list/1
       (elixir 1.16.3) lib/map.ex:224: Map.new_from_enum/1
       (phoenix_view 2.0.2) lib/phoenix_view.ex:370: Phoenix.View.render/3
       (phoenix_view 2.0.2) lib/phoenix_view.ex:557: Phoenix.View.render_to_iodata/3
       (phoenix 1.6.15) lib/phoenix/controller.ex:772: Phoenix.Controller.render_and_send/4
       (thingy 0.1.1) lib/thingy_web/controllers/conversation_controller.ex:1: ThingyWeb.ConversationController.action/2
       (thingy 0.1.1) lib/thingy_web/controllers/conversation_controller.ex:1: ThingyWeb.ConversationController.phoenix_controller_pipeline/2
       (phoenix 1.6.15) lib/phoenix/router.ex:354: Phoenix.Router.__call__/2
       (thingy 0.1.1) lib/thingy_web/endpoint.ex:1: ThingyWeb.Endpoint.plug_builder_call/2
       (thingy 0.1.1) lib/thingy_web/endpoint.ex:1: ThingyWeb.Endpoint."call (overridable 3)"/2
       (thingy 0.1.1) deps/plug/lib/plug/debugger.ex:136: ThingyWeb.Endpoint."call (overridable 4)"/2
       (thingy 0.1.1) lib/thingy_web/endpoint.ex:1: ThingyWeb.Endpoint.call/2
       (phoenix 1.6.15) lib/phoenix/test/conn_test.ex:225: Phoenix.ConnTest.dispatch/5
       test/thingy_web/controllers/conversation_controller_test.exs:60: (test)

Solution

  • Based on the comment by @Dogbert, I made the following change in the controller code:

                conn
                |> put_status(201)
                |> render("created.json", conversation: conversation)
    

    adding the naming did the trick, but pointed out I was missing an internal aliasing from my view:

    defmodule ThingyWeb.ConversationView do
      use ThingyWeb, :view
      alias ThingyWeb.ConversationView
    

    After I did that, the test failed as expected based on the assertion criteria.

    I'm not sure I understand exactly why that change mattered, and if someone could help explain I'd appreciate it. Similarly, not sure I understand why I had to alias the module to itself in order for the render("created.json" function to be able to find the render("conversation.json" function, so again any help understanding would be appreciated.