Search code examples
filereactjselixirreduxredux-form

File attachments for redux-form and elixir/phoenix as backend API (serialization issue)


I have two product controllers in my elixir/phoenix backend. First - API endpoint (pipe_through :api) and second controller piping through :browser:

# router.ex
scope "/api", SecretApp.Api, as: :api do
  pipe_through :api

  resources "products", ProductController, only: [:create, :index]
end

scope "/", SecretApp do
  pipe_through :browser # Use the default browser stack

  resources "products", ProductController, only: [:new, :create, :index]
end

ProductController handles requests from form generated by elixir form helpers and accepts some file attachments. Everything is fine with it. Here is create action and params processed by this action:

def create(conn, %{"product" => product_params}) do
  changeset = Product.changeset(%Product{}, product_params)

  case Repo.insert(changeset) do
    {:ok, _product} ->
      conn
      |> put_flash(:info, "Product created successfully.")
      |> redirect(to: product_path(conn, :index))
    {:error, changeset} ->
      render(conn, "new.html", changeset: changeset)
  end
end

params from log (I am using arc for handling image uploads in elixir code)

[debug] Processing by SecretApp.ProductController.create/2
  Parameters: %{"_csrf_token" => "Zl81JgdhIQ8GG2c+ei0WCQ9hTjI+AAAA0fwto+HMdQ7S7OCsLQ9Trg==", "_utf8" => "✓", 
              "product" => %{"description" => "description_name", 
                "image" => %Plug.Upload{content_type: "image/png", 
                  filename: "wallpaper-466648.png", 
                  path: "/tmp/plug-1460/multipart-754282-298907-1"}, 
                "name" => "product_name", "price" => "100"}}
  Pipelines: [:browser]

Api.ProductController handles requests from redux-from. Here is action, view and params, which are processed by this action:

# action in controller
def create(conn, %{"product" => product_params}) do
  changeset = Product.changeset(%Product{}, product_params)

  case Repo.insert(changeset) do
    {:ok, _product} ->
      conn
      |> render("index.json", status: :ok)
    {:error, changeset} ->
      conn
      |> put_status(:unprocessable_entity)
      |> render("error.json", changeset: changeset)
  end
end

# product_view.ex
def render("index.json", resp=%{status: status}) do
  %{status: status}
end

def render("error.json", %{changeset: changeset}) do
  errors = Enum.into(changeset.errors, %{})

  %{
    errors: errors
  }
end

[info] POST /api/products/
[debug] Processing by SecretApp.Api.ProductController.create/2
  Parameters: %{"product" => %{"description" => "product_description", "image" => "wallpaper-466648.png", "name" => "product_name", "price" => "100"}}
  Pipelines: [:api]
[info] Sent 422 in 167ms

Create action fails with 422 status, because image can't be saved with these params. My problem that I can't access image from backend code, I only have it in my JS code as FileList object. I don't understand how to pass image to backend code. Here is how this attachment represented in my JS code (FileList, containing information about uploaded image).

value:FileList
  0: File
    lastModified: 1381593256801
    lastModifiedDate: Sat Oct 12 2013 18:54:16 GMT+0300 
    name: "wallpaper-466648.png"
    size: 1787293
    type: "image/png"
    webkitRelativePath: ""

I only have WebkitRelativePath (In case with first controller I have path to image: "/tmp/plug-1460/multipart-754282-298907-1") and I don't know what can I do with this JS object and how to access real image represented by this JS object (here is a redux-form reference about file uploads).

Could you help me? How to explain to elixir how to find an image? I just would like to submit file attachments to my backend using JS code (because there a lot of interesting features for async validation etc).

Here is a link to a full app if it could be helpful


Solution

  • Finally I've managed to solve this problem. The solution is in correct serialization of redux-form submitted params.

    Here is my redux form, starting point of the request:

    // product_form.js
    
    import React, { PropTypes } from 'react';
    import {reduxForm} from 'redux-form';
    
    class ProductForm extends React.Component {
      static propTypes = {
        fields: PropTypes.object.isRequired,
        handleSubmit: PropTypes.func.isRequired,
        error: PropTypes.string,
        resetForm: PropTypes.func.isRequired,
        submitting: PropTypes.bool.isRequired
      };
    
      render() {
        const {fields: {name, description, price, image}, handleSubmit, resetForm, submitting, error} = this.props;
    
        return (
          <div className="product_form">
            <div className="inner">
              <form onSubmit={handleSubmit} encType="multipart/form-data">
                <div className="form-group">
                  <label className="control-label"> Name </label>
                  <input type="text" className="form-control" {...name} />
                  {name.touched && name.error && <div className="col-xs-3 help-block">{name.error}</div>}
                </div>
    
                <div className="form-group">
                  <label className="control-label"> Description </label>
                  <input type="textarea" className="form-control" {...description} />
                  {description.touched && description.error && <div className="col-xs-3 help-block">{description.error}</div>}
                </div>
    
                <div className="form-group">
                  <label className="control-label"> Price </label>
                  <input type="number" step="any" className="form-control" {...price} />
                  {price.touched && price.error && <div className="col-xs-3 help-block">{price.error}</div>}
                </div>
    
                <div className="form-group">
                  <label className="control-label"> Image </label>
                  <input type="file" className="form-control" {...image} value={ null } />
                  {image.touched && image.error && <div className="col-xs-3 help-block">{image.error}</div>}
                </div>
    
                <div className="form-group">
                  <button type="submit" className="btn btn-primary" >Submit</button>
                </div>
              </form>
            </div>
          </div>
        );
      }
    }
    
    ProductForm = reduxForm({
      form: 'new_product_form',
      fields: ['name', 'description', 'price', 'image']
    })(ProductForm);
    
    export default ProductForm;
    

    This form passes the following params to the function handleSubmit after user presses the button "Submit"

    # values variable
    Object {name: "1", description: "2", price: "3", image: FileList}
    
    # where image value is 
    value:FileList
      0: File
        lastModified: 1381593256801
        lastModifiedDate: Sat Oct 12 2013 18:54:16 GMT+0300 
        name: "wallpaper-466648.png"
        size: 1787293
        type: "image/png"
        webkitRelativePath: ""
    

    To pass these params to backend I am using the FormData Web API and the file-upload request using isomorphic-fetch npm module

    Here is the code did the trick:

    // product_form_container.js (where form submit processed, see _handleSubmit function)
    
    import React                   from 'react';
    import ProductForm             from '../components/product_form';
    import { Link }                from 'react-router';
    import { connect }             from 'react-redux';
    import Actions                 from '../actions/products';
    import * as form_actions            from 'redux-form';
    import {httpGet, httpPost, httpPostForm} from '../utils';
    
    class ProductFormContainer extends React.Component {
      _handleSubmit(values) {
        return new Promise((resolve, reject) => {
          let form_data = new FormData();
    
          Object.keys(values).forEach((key) => {
            if (values[key] instanceof FileList) {
              form_data.append(`product[${key}]`, values[key][0], values[key][0].name);
            } else {
              form_data.append(`product[${key}]`, values[key]);
            }
          });
    
          httpPostForm(`/api/products/`, form_data)
          .then((response) => {
            resolve();
          })
          .catch((error) => {
            error.response.json()
            .then((json) => {
              let responce = {};
              Object.keys(json.errors).map((key) => {
                Object.assign(responce, {[key] : json.errors[key]});
              });
    
              if (json.errors) {
                reject({...responce, _error: 'Login failed!'});
              } else {
                reject({_error: 'Something went wrong!'});
              };
            });
          });
        });
      }
    
      render() {
        const { products } = this.props;
    
        return (
          <div>
            <h2> New product </h2>
            <ProductForm title="Add product" onSubmit={::this._handleSubmit} />
    
            <Link to='/admin/products'> Back </Link>
          </div>
        );
      }
    }
    
    export default connect()(ProductFormContainer);
    

    where httpPostForm is a wrapper around fetch:

    export function httpPostForm(url, data) {
      return fetch(url, {
        method: 'post',
        headers: {
          'Accept': 'application/json'
        },
        body: data,
      })
      .then(checkStatus)
      .then(parseJSON);
    }
    

    And that's it. There was nothing to fix in my elixir code, Api.ProductController remains the same (see initial post). But now it receives request with the following params:

    [info] POST /api/products/
    [debug] Processing by SecretApp.Api.ProductController.create/2
      Parameters: %{"product" => %{
                    "description" => "2", 
                    "image" => %Plug.Upload{
                      content_type: "image/jpeg",
                      filename: "monkey_in_jungle-t3.jpg", 
                      path: "/tmp/plug-1461/multipart-853391-603088-1"
                    }, 
                   "name" => "1", 
                   "price" => "3"}}
      Pipelines: [:api]
    

    Many thanks for everyone trying to help me. Hope this could help someone struggling with similar serialization issues.