Upload files - protocol Enumerable not implemented

Hello folks! I am trying to upload multiple files at once using Arc. I face such error: protocol Enumerable not implemented for %Plug.Upload when i try to create a product with attached file/s. Any idea what I am doing wrong here?

My schemas:

  schema "products" do
    field :colour, :string
    field :description, :string
    field :name, :string
    field :price, :decimal
    field :product_code, :string
    field :release_year, :integer

    belongs_to :category, Jordaniva.Categories.Category
    belongs_to :subcategory, Jordaniva.Categories.Subcategory
    belongs_to :type, Jordaniva.Inventory.Type

    has_many :items, Jordaniva.Inventory.Item
    has_many :photos, Jordaniva.Gallery.Photo

    timestamps()
  end

  @doc false
  def changeset(struct, attrs \\ %{}) do
    struct
    |> cast(attrs, [:name, :description, :price, :colour, :product_code, :release_year, :category_id, :subcategory_id, :type_id])
    |> cast_assoc(:items)
    |> cast_assoc(:photos)
    |> validate_required([:name, :description, :price, :colour, :product_code, :release_year, :category_id, :subcategory_id, :type_id])
  end
  schema "photos" do
    field :photo, Jordaniva.Photo.Type
    field :uuid, :string

    belongs_to :product, Jordaniva.Inventory.Product

    timestamps()
  end

  @doc false
  def changeset(photo, attrs) do
    photo
    |> Map.update(:uuid, Ecto.UUID.generate, fn val -> val || Ecto.UUID.generate end)
    |> cast_attachments(attrs, [:photo])
    |> validate_required([:photo])
  end

Product controller (new/create):

  def new(conn, _params) do
    changeset = Product.changeset(%Product{items: [%Item{}, %Item{}]})
    categories = Repo.all(Category) |> Enum.map(&{&1.name, &1.id})
    subcategories = Repo.all(Subcategory) |> Enum.map(&{&1.name, &1.id})
    render(conn, "new.html", changeset: changeset, categories: categories, subcategories: subcategories)
  end

  def create(conn, %{"photos" => product_params}) do
    case Inventory.create_product(product_params) do
      {:ok, product} ->
        conn
        |> put_flash(:info, "Product created successfully.")
        |> redirect(to: Routes.product_path(conn, :show, product))
      {:error, %Ecto.Changeset{} = changeset} ->
        categories = Repo.all(Category) |> Enum.map(&{&1.name, &1.id})
        subcategories = Repo.all(Subcategory) |> Enum.map(&{&1.name, &1.id})
        render(conn, "new.html", changeset: changeset, categories: categories, subcategories: subcategories)
    end
  end

Photo controller:

  def new(conn, _params) do
    changeset = Gallery.change_photo(%Photo{})
    render(conn, "new.html", changeset: changeset)
  end

  def create(conn, %{"product_id" => product_id, "photo" => photo_params}) do
    product = Jordaniva.Inventory.get_product!(product_id)
    case Gallery.create_photo(photo_params) do
      {:ok, _photo} ->
        conn
        |> put_flash(:info, "Photo created successfully.")
        |> redirect(to: Routes.photo_path(conn, :index))

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, "new.html", changeset: changeset)
    end
  end

Gallery.ex

  def create_photo(%Product{} = product, attrs \\ %{}) do
    %Photo{}
    |> Photo.changeset(attrs)
    |> Ecto.Changeset.put_assoc(:product, product)
    |> Repo.insert()
  end
  

  def create_photos(%Product{} = product, attrs \\ %{}) do
    Enum.each attrs["photos"], fn p ->
      create_photo(product, %{"photo" => p})
    end
  end
end

Uploaders/photo.ex

defmodule Jordaniva.Photo do
  use Arc.Definition
  use Arc.Ecto.Definition

  @extension_whitelist ~w(.jpg .jpeg .gif .png)

  @versions [:primary]
  @acl :public_read

  def default_url(:primary, _movie) do
    "http://placehold.it/350x200"
  end

  # To add a thumbnail version:
  @versions [:original, :thumb]

  # Whitelist file extensions:
  def validate({file, _}) do
    file_extension = file.file_name |> Path.extname() |> String.downcase()
    Enum.member?(@extension_whitelist, file_extension)
  end

  # Define a thumbnail transformation:
  def transform(:thumb, _) do
    {:convert, "-strip -thumbnail 150x150^ -gravity center -extent 150x150"}
  end

  # Override the persisted filenames:
  def filename(version, {file, scope}) do
    "#{scope.uuid}_#{version}"
  end

  # Override the storage directory:
  def storage_dir(version, {file, scope}) do
    "uploads/photos/"
  end
end

And full error:

[info] POST /products
[debug] Processing with JordanivaWeb.ProductController.create/2
  Parameters: %{"_csrf_token" => "DQAuSi4VWwZsAgQxSltSVwYCMSkSTnR2_b_ggd30Us4dz-jesRTyb897", "photos" => %{"category_id" => "1", "colour" => "test66", "description" => "test66", "name" => "test66", "photos" => [%Plug.Upload{content_type: "image/png", filename: "Zrzut ekranu 2020-07-20 o 22.46.06.png", path: "/var/folders/1t/q27498654m376jz6fl02fqd80000gn/T//plug-1597/multipart-1597933231-847704270337599-3"}], "price" => "666", "product_code" => "test66", "release_year" => "2004", "subcategory_id" => "1", "type_id" => "1"}}
  Pipelines: [:browser]
[debug] QUERY OK source="types" db=0.3ms idle=1764.5ms
SELECT t0."id", t0."name", t0."inserted_at", t0."updated_at" FROM "types" AS t0 ORDER BY t0."name" []
[info] Sent 500 in 32ms
[error] #PID<0.1214.0> running JordanivaWeb.Endpoint (connection #PID<0.1213.0>, stream id 1) terminated
Server: localhost:4000 (http)
Request: POST /products
** (exit) an exception was raised:
    ** (Protocol.UndefinedError) protocol Enumerable not implemented for %Plug.Upload{content_type: "image/png", filename: "Zrzut ekranu 2020-07-20 o 22.46.06.png", path: "/var/folders/1t/q27498654m376jz6fl02fqd80000gn/T//plug-1597/multipart-1597933231-847704270337599-3"} of type Plug.Upload (a struct). This protocol is implemented for the following type(s): Ecto.Adapters.SQL.Stream, Postgrex.Stream, DBConnection.Stream, DBConnection.PrepareStream, HashSet, Range, Map, Function, List, Stream, Date.Range, HashDict, GenEvent.Stream, MapSet, File.Stream, IO.Stream
        (elixir 1.10.4) lib/enum.ex:1: Enumerable.impl_for!/1
        (elixir 1.10.4) lib/enum.ex:141: Enumerable.reduce/3
        (elixir 1.10.4) lib/enum.ex:3383: Enum.reduce/3
        (arc_ecto 0.11.3) lib/arc_ecto/schema.ex:58: Arc.Ecto.Schema.convert_params_to_binary/1
        (jordaniva 0.1.0) lib/jordaniva/gallery/photo.ex:19: Jordaniva.Gallery.Photo.changeset/2
        (ecto 3.4.5) lib/ecto/changeset.ex:829: anonymous fn/4 in Ecto.Changeset.on_cast_default/2
        (ecto 3.4.5) lib/ecto/changeset/relation.ex:128: Ecto.Changeset.Relation.do_cast/6
        (ecto 3.4.5) lib/ecto/changeset/relation.ex:338: Ecto.Changeset.Relation.map_changes/9
        (ecto 3.4.5) lib/ecto/changeset/relation.ex:112: Ecto.Changeset.Relation.cast/5
        (ecto 3.4.5) lib/ecto/changeset.ex:800: Ecto.Changeset.cast_relation/4
        (jordaniva 0.1.0) lib/jordaniva/inventory/product.ex:29: Jordaniva.Inventory.Product.changeset/2
        (jordaniva 0.1.0) lib/jordaniva/inventory.ex:67: Jordaniva.Inventory.create_product/1
        (jordaniva 0.1.0) lib/jordaniva_web/controllers/product_controller.ex:28: JordanivaWeb.ProductController.create/2
        (jordaniva 0.1.0) lib/jordaniva_web/controllers/product_controller.ex:1: JordanivaWeb.ProductController.action/2
        (jordaniva 0.1.0) lib/jordaniva_web/controllers/product_controller.ex:1: JordanivaWeb.ProductController.phoenix_controller_pipeline/2
        (phoenix 1.5.4) lib/phoenix/router.ex:352: Phoenix.Router.__call__/2
        (jordaniva 0.1.0) lib/jordaniva_web/endpoint.ex:1: JordanivaWeb.Endpoint.plug_builder_call/2
        (jordaniva 0.1.0) lib/plug/debugger.ex:132: JordanivaWeb.Endpoint."call (overridable 3)"/2
        (jordaniva 0.1.0) lib/jordaniva_web/endpoint.ex:1: JordanivaWeb.Endpoint.call/2
        (phoenix 1.5.4) lib/phoenix/endpoint/cowboy2_handler.ex:65: Phoenix.Endpoint.Cowboy2Handler.init/4

Have you found something in the mean while? If not, I will try to dive in.

The error you’re getting points to this code in arc_ecto:

and suggests that it’s getting a Plug.Upload struct instead of a map %{"photo": %Plug.Upload{}}

Try IO.inspecting things until you find where the parameter shape doesn’t match what you expect.

1 Like

Sadly no, I have not found solution.

Thanks for your reply, I will IO.inspect my code and try to find where the parameters don’t match.

I’ve made tiny changes to my code, and right now, I do not see any error message, but the problem is I cannot upload any file/s. When I add a new image and click save it shows “Oops, something went wrong! Please check the errors below.” but doesn’t display anything below. In the terminal, it looks like that:

[debug] Processing with JordanivaWeb.ProductController.create/2
  Parameters: %{"_csrf_token" => "Ewo7HQVpOGEiIxsdHkk4TUMsPCsQBR0VpNId63iUIUmRndr72bVramuY", "photos" => %{"category_id" => "1", "colour" => "test1", "description" => "test1", "name" => "test1", "photos" => %{"0" => %{"photos" => [%Plug.Upload{content_type: "text/html", filename: "+ud6zQ2o.html", path: "/var/folders/1t/q27498654m376jz6fl02fqd80000gn/T//plug-1598/multipart-1598452423-991068043046336-5"}, %Plug.Upload{content_type: "image/jpeg", filename: "air_jordan1.jpg", path: "/var/folders/1t/q27498654m376jz6fl02fqd80000gn/T//plug-1598/multipart-1598452423-154324122449949-5"}]}}, "price" => "1111", "product_code" => "test1", "release_year" => "1111", "subcategory_id" => "1", "type_id" => "1"}}
  Pipelines: [:browser]
[debug] QUERY OK source="types" db=0.3ms idle=893.5ms
SELECT t0."id", t0."name", t0."inserted_at", t0."updated_at" FROM "types" AS t0 ORDER BY t0."name" []
[debug] QUERY OK source="categories" db=0.2ms idle=894.1ms
SELECT c0."id", c0."name", c0."inserted_at", c0."updated_at" FROM "categories" AS c0 []
[debug] QUERY OK source="subcategories" db=0.2ms idle=894.6ms
SELECT s0."id", s0."name", s0."category_id", s0."inserted_at", s0."updated_at" FROM "subcategories" AS s0 []
[info] Sent 200 in 11ms

IO.inspect in photo controller doesn’t show anything, only in the product’s controller:

%Plug.Conn{
  adapter: {Plug.Cowboy.Conn, :...},
  assigns: %{
    types: [
      %Jordaniva.Inventory.Type{
        __meta__: #Ecto.Schema.Metadata<:loaded, "types">,
        id: 3,
        inserted_at: ~N[2020-08-12 10:46:24],
        name: "Kids",
        updated_at: ~N[2020-08-12 10:46:24]
      },
      %Jordaniva.Inventory.Type{
        __meta__: #Ecto.Schema.Metadata<:loaded, "types">,
        id: 1,
        inserted_at: ~N[2020-08-12 10:46:24],
        name: "Men",
        updated_at: ~N[2020-08-12 10:46:24]
      },
      %Jordaniva.Inventory.Type{
        __meta__: #Ecto.Schema.Metadata<:loaded, "types">,
        id: 2,
        inserted_at: ~N[2020-08-12 10:46:24],
        name: "Women",
        updated_at: ~N[2020-08-12 10:46:24]
      }
    ]
  },
  before_send: [#Function<0.73641281/1 in Plug.CSRFProtection.call/2>,
   #Function<2.102658996/1 in Phoenix.Controller.fetch_flash/2>,
   #Function<0.105793137/1 in Plug.Session.before_send/2>,
   #Function<0.60895335/1 in Plug.Telemetry.call/2>,
   #Function<0.33293990/1 in Phoenix.LiveReloader.before_send_inject_reloader/3>],
  body_params: %{
    "_csrf_token" => "Gg03ClYqHGQcRik4EXU4P0MpDBsJHDchyIEsepMPw0_waXrE2gfBxt_m",
    "photos" => %{
      "category_id" => "1",
      "colour" => "test2",
      "description" => "test2",
      "name" => "test2",
      "photos" => %{
        "0" => %{
          "photos" => [
            %Plug.Upload{
              content_type: "image/jpeg",
              filename: "air_jordan1.jpg",
              path: "/var/folders/1t/q27498654m376jz6fl02fqd80000gn/T//plug-1598/multipart-1598449943-267269738935643-2"
            }
          ]
        }
      },
      "price" => "1111",
      "product_code" => "test2",
      "release_year" => "2222",
      "subcategory_id" => "1",
      "type_id" => "1"
    }
  },
  cookies: %{
    "_jordaniva_key" => "SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYY0RyeTNaUTRrdnZPcC1KenFOallxaGhM.YHsDOvqNysF273T4z74asaQEmNvfLdwOIzE8bjIs3H8"
  },
  halted: false,
  host: "localhost",
  method: "POST",
  owner: #PID<0.1440.0>,
  params: %{
    "_csrf_token" => "Gg03ClYqHGQcRik4EXU4P0MpDBsJHDchyIEsepMPw0_waXrE2gfBxt_m",
    "photos" => %{
      "category_id" => "1",
      "colour" => "test2",
      "description" => "test2",
      "name" => "test2",
      "photos" => %{
        "0" => %{
          "photos" => [
            %Plug.Upload{
              content_type: "image/jpeg",
              filename: "air_jordan1.jpg",
              path: "/var/folders/1t/q27498654m376jz6fl02fqd80000gn/T//plug-1598/multipart-1598449943-267269738935643-2"
            }
          ]
        }
      },
      "price" => "1111",
      "product_code" => "test2",
      "release_year" => "2222",
      "subcategory_id" => "1",
      "type_id" => "1"
    }
  },
  path_info: ["products"],
  path_params: %{},
  port: 4000,
  private: %{
    JordanivaWeb.Router => {[], %{}},
    :phoenix_action => :create,
    :phoenix_controller => JordanivaWeb.ProductController,
    :phoenix_endpoint => JordanivaWeb.Endpoint,
    :phoenix_flash => %{},
    :phoenix_format => "html",
    :phoenix_layout => {JordanivaWeb.LayoutView, :app},
    :phoenix_request_logger => {"request_logger", "request_logger"},
    :phoenix_router => JordanivaWeb.Router,
    :phoenix_view => JordanivaWeb.ProductView,
    :plug_multipart => :done,
    :plug_session => %{"_csrf_token" => "cDry3ZQ4kvvOp-JzqNjYqhhL"},
    :plug_session_fetch => :done
  },
  query_params: %{},
  query_string: "",
  remote_ip: {127, 0, 0, 1},
  req_cookies: %{
    "_jordaniva_key" => "SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYY0RyeTNaUTRrdnZPcC1KenFOallxaGhM.YHsDOvqNysF273T4z74asaQEmNvfLdwOIzE8bjIs3H8"
  },
  req_headers: [
    {"accept",
     "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8"},
    {"accept-encoding", "gzip, deflate"},
    {"accept-language", "pl,en-US;q=0.7,en;q=0.3"},
    {"connection", "keep-alive"},
    {"content-length", "83996"},
    {"content-type",
     "multipart/form-data; boundary=---------------------------12180823701865628394499381825"},
    {"cookie",
     "_jordaniva_key=SFMyNTY.g3QAAAABbQAAAAtfY3NyZl90b2tlbm0AAAAYY0RyeTNaUTRrdnZPcC1KenFOallxaGhM.YHsDOvqNysF273T4z74asaQEmNvfLdwOIzE8bjIs3H8"},
    {"host", "localhost:4000"},
    {"origin", "http://localhost:4000"},
    {"referer", "http://localhost:4000/products"},
    {"upgrade-insecure-requests", "1"},
    {"user-agent",
     "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:79.0) Gecko/20100101 Firefox/79.0"}
  ],
  request_path: "/products",
  resp_body: nil,
  resp_cookies: %{},
  resp_headers: [
    {"cache-control", "max-age=0, private, must-revalidate"},
    {"x-request-id", "Fi7VwTKakmA5xvwAAAYo"},
    {"x-frame-options", "SAMEORIGIN"},
    {"x-xss-protection", "1; mode=block"},
    {"x-content-type-options", "nosniff"},
    {"x-download-options", "noopen"},
    {"x-permitted-cross-domain-policies", "none"},
    {"cross-origin-window-policy", "deny"}
  ],
  scheme: :http,
  script_name: [],
  secret_key_base: :...,
  state: :unset,
  status: nil
}

This indicates the request went down the {:error, %Ecto.Changeset{} = changeset} path in the controller. What errors are in that changeset?

1 Like

To clarify - in your photo controller’s create action:

  def create(conn, %{"product_id" => product_id, "photo" => photo_params}) do
    product = Jordaniva.Inventory.get_product!(product_id)
    case Gallery.create_photo(photo_params) do
      {:ok, _photo} ->
        conn
        |> put_flash(:info, "Photo created successfully.")
        |> redirect(to: Routes.photo_path(conn, :index))

      {:error, %Ecto.Changeset{} = changeset} ->
        # The 200 status code in your log shows the code goes down this path
        # Because otherwise the response would be a 302 status code
        # 
        # Let's see what the changeset can tell us:
        IO.inspect(changeset, label: "create_photo changeset")

        render(conn, "new.html", changeset: changeset)
    end
  end

When I put this piece of code in my photo controller create action, there is no output like no parameters are passed. When I IO.inspect product controller create action I got this output.

create_product changeset: #Ecto.Changeset<
  action: :insert,
  changes: %{
    category_id: 1,
    colour: "test4",
    description: "test4",
    name: "test4",
    photos: [
      #Ecto.Changeset<
        action: :insert,
        changes: %{},
        errors: [photo: {"can't be blank", [validation: :required]}],
        data: #Jordaniva.Gallery.Photo<>,
        valid?: false
      >
    ],
    price: #Decimal<444>,
    product_code: "test4",
    release_year: 2000,
    subcategory_id: 1,
    type_id: 1
  },
  errors: [],
  data: #Jordaniva.Inventory.Product<>,
  valid?: false
>

I see the error that errors: [photo: {"can't be blank" but I always try to upload some image.

What you’re uploading isn’t showing up where the changeset expects it, so it’s being ignored.

Your parameters are shaped like (from the logs):

%{
  "_csrf_token" => "Ewo7HQVpOGEiIxsdHkk4TUMsPCsQBR0VpNId63iUIUmRndr72bVramuY",
  "photos" => %{
    "category_id" => "1",
    "colour" => "test1",
    "description" => "test1",
    "name" => "test1",
    "photos" => %{
      "0" => %{
        "photos" => [
           %Plug.Upload{content_type: "text/html", filename: "+ud6zQ2o.html", path: "/var/folders/1t/q27498654m376jz6fl02fqd80000gn/T//plug-1598/multipart-1598452423-991068043046336-5"},
           %Plug.Upload{content_type: "image/jpeg", filename: "air_jordan1.jpg", path: "/var/folders/1t/q27498654m376jz6fl02fqd80000gn/T//plug-1598/multipart-1598452423-154324122449949-5"}
        ]
      }
    },
    "price" => "1111",
    "product_code" => "test1",
    "release_year" => "1111",
    "subcategory_id" => "1",
    "type_id" => "1"
  }
}
  • The outer "photos" wrapper is removed by the pattern-match in ProductController.create

  • The next "photos" wrapper is removed by cast_assoc(:photos), which iterates over each element and calls Photo.changeset

  • in this case, there is one element:

%{"photos" => [...array of Plug.Upload structs...]}

But Photo.changeset is expecting a map like:

%{"photo" => %Plug.Upload{}}

Ohh, I see. Could you tell me/give a hint where I should start with reshaping my code or how do I fix that?