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)
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.