Upload image as PATCH/PUT request through phoenix api (with Arc?)

In this application phoenix is used as an API, and the front end is React Redux. My first attempt was using FormData, which was not successful because apparently it’s only suited to POST requests. I’m trying to stick with CRUD here by uploading an image as part of a PATCH request using isomorphic-fetch. My next attempt gets me closer, by saving the file to a tmp location via URL.createObjectURl:

handleImageChange(e) {
   e.preventDefault();

     const reader = new FileReader();
     const file = e.target.files[0];
     const src = window.URL.createObjectURL(file)
     reader.onloadend = () => {
     this.setState({
       imagePreviewUrl: reader.result
     });
     this.setState({
      photo: {
        filename: file.name,
        content_type: file.type,
        path: src
      }
    });
  }

  reader.readAsDataURL(file)
}

However, sending the temporary file path results in an invalid_file_path error in phoenix when attempting to save using iex:

Photo.store("blob:http://localhost:4000/7252ed69-bc36-4a1d-a7c5-ca8c0ae4ec5e")
    # => {:error, :invalid_file_path}

The other method I’ve tried is modifying the changeset to use the photo_params, and cast_attachments in the model…

The params:

 changes: %{photo: %{"content_type" => "image/jpeg",
     "filename" => "welcome-bg.jpg",
     "path" => "blob:http://localhost:4000/d31be00a-f88d-4a9f-b7d2-51f804726927"}},
 errors: [], data: #Knitwhiz.Design<>, valid?: true>  

Controller update action:

def update(conn, %{"id" => id, "design" => design_params, "photo" => photo_params}) do
    design = Repo.get!(Design, id)
    changeset = Design.changeset(design, design_params)
    |> Ecto.Changeset.change
    |> Ecto.Changeset.put_change(:photo, photo_params)

    case Repo.update(changeset) do
      {:ok, design} ->
        render(conn, "show.json", design: design)
      {:error, changeset} ->
        conn
        |> put_status(:unprocessable_entity)
        |> render(Knitwhiz.ChangesetView, "error.json", changeset: changeset)
    end
  end

The model:

def changeset(struct, params \\ %{}) do
    struct
    |> cast(params, [:name :special_instructions])
    |> cast_attachments(params, [:photo])
    |> validate_required([:name])
 end

The error (for this case!):

[error] #PID<0.4409.0> running Knitwhiz.Endpoint terminated
Server: localhost:4000 (http)
Request: PUT /api/v1/designs/1
** (exit) an exception was raised:
    ** (FunctionClauseError) no function clause matching in Arc.Ecto.Type.dump/2
        (arc_ecto) lib/arc_ecto/type.ex:39: Arc.Ecto.Type.dump(Knitwhiz.Photo, %{"content_type" => "image/jpeg", "filename" => "welcome-bg.jpg", "path" => "blob:http://localhost:4000/d31be00a-f88d-4a9f-b7d2-51f804726927"})
        (ecto) lib/ecto/type.ex:662: Ecto.Type.process_dumpers/3
        (ecto) lib/ecto/repo/schema.ex:687: Ecto.Repo.Schema.dump_field!/6
        (ecto) lib/ecto/repo/schema.ex:700: anonymous fn/6 in Ecto.Repo.Schema.dump_fields!/5
        (stdlib) lists.erl:1263: :lists.foldl/3
        (ecto) lib/ecto/repo/schema.ex:698: Ecto.Repo.Schema.dump_fields!/5
        (ecto) lib/ecto/repo/schema.ex:647: Ecto.Repo.Schema.dump_changes!/6
        (ecto) lib/ecto/repo/schema.ex:258: anonymous fn/13 in Ecto.Repo.Schema.do_update/4
        (ecto) lib/ecto/repo/schema.ex:676: anonymous fn/3 in Ecto.Repo.Schema.wrap_in_transaction/6
        (ecto) lib/ecto/adapters/sql.ex:615: anonymous fn/3 in Ecto.Adapters.SQL.do_transaction/3
        (db_connection) lib/db_connection.ex:1274: DBConnection.transaction_run/4
        (db_connection) lib/db_connection.ex:1198: DBConnection.run_begin/3
        (db_connection) lib/db_connection.ex:789: DBConnection.transaction/3
        (knitwhiz) web/controllers/api/v1/design_controller.ex:48: Knitwhiz.DesignController.update/2
        (knitwhiz) web/controllers/api/v1/design_controller.ex:1: Knitwhiz.DesignController.action/2
        (knitwhiz) web/controllers/api/v1/design_controller.ex:1: Knitwhiz.DesignController.phoenix_controller_pipeline/2
        (knitwhiz) lib/knitwhiz/endpoint.ex:1: Knitwhiz.Endpoint.instrument/4
        (knitwhiz) lib/phoenix/router.ex:261: Knitwhiz.Router.dispatch/2
        (knitwhiz) web/router.ex:1: Knitwhiz.Router.do_call/2
        (knitwhiz) lib/knitwhiz/endpoint.ex:1: Knitwhiz.Endpoint.phoenix_pipeline/1

I’m beginning to think it would just be simpler to load images directly to S3, without using arc or arc_ecto. Suggestions, comments, opinions?

2 Likes

I solved this by going back to using FormData, but adding a separate POST route to the same endpoint used for the update action. After adding the route, I could simplify the elixir code back to it’s original boilerplate. Then, in the redux action I just needed to add the FormData:

Object.keys(formData).forEach((key) => {
      if (formData[key] instanceof File) {
        form_data.append(`design[${key}]`, formData[key], formData[key].name);
      } else {
        form_data.append(`design[${key}]`, formData[key]);
      }
    });
    
     httpPostForm(`/api/v1/designs/${id}`, form_data)
      .then((resp) => {
        dispatch({
          type: DESIGN_UPDATED,
          currentDesign: resp.data,
          message: 'Your design has been successfully updated'
        });
      }) ...

export function httpPostForm(url, data) {
  console.log('data', data);
  return fetch(url, {
    method: 'POST',
    headers: formHeaders(),
    credentials: 'same-origin',
    body: data
  })
  .then(checkStatus)
  .then(parseJSON);
}

function formHeaders() {
  const authToken = localStorage.getItem('AuthToken');
  return { 'Accept': 'application/json, */*', Authorization: authToken };
}

FormData will set the appropriate content-type, so important not to set it manually in the headers.

2 Likes