Not able to upload a file to Rails server with Angular

Issue

So I am using the gem 'angular-file-upload-rails' which installs me this Angular plugin: Angular File Ipload

Now the code I am using currently to upload my file looks like this:

HTML:

<form ng-controller="MediumNewCtrl">
    <input type="file" ng-file-select="upload2($files)" multiple>
</form>

Coffescript:

$scope.upload2 = ($file) ->
        console.log($file[0])
        fileReader = new FileReader()
        fileReader.readAsArrayBuffer($file[0])
        fileReader.onload = (e) ->
            $upload.http(
                url: "/media.json"
                headers: 'Content-Type': $file[0].type
                data: medium: {text: 'text', image_video: e.target.result}
            ).progress((evt) ->
                console.log "percent: " + parseInt(100.0 * evt.loaded / evt.total)
                return
            ).success((data, status, headers, config) ->
                # file is uploaded successfully
                console.log data

            ).error((data) ->
                console.log 'Error'
            )

And now when I look at what my server responded, I see this:

Started POST "/media.json" for 127.0.0.1 at 2014-12-12 20:19:10 +0200
Processing by Content::MediaController#create as JSON
  User Load (0.8ms)  SELECT  "users".* FROM "users"  WHERE "users"."id" = 1  ORDER BY "users"."id" ASC LIMIT 1

{"action"=>"create", "controller"=>"content/media", "format"=>"json"}
Completed 400 Bad Request in 3ms

ActionController::ParameterMissing - param is missing or the value is empty: medium:

Is the problem in the fact that I format it as json? But shouldnt atleast the text params be passed to the controller?

I cannot use the html Post too because the nature of my application is so that it will intercept all HTML requests when you log in.

Also maybe worth nothing that I use paperclip to manage my uploads for me. So I probably have to get the file sent into a proper format too for it?

Solution

It looks like you are using the ‘upload right away’ pattern. Here is a complete example for future seekers:

app/views/static-pages/index.html:

<div ng-app='myApp'>

  <h1>StaticPages#index</h1>
  <p>Find me in: app/views/static_pages/index.html.erb</p>
  <hr>

  <div ng-controller="FileUploadCtrl">
    <input type="file" 
      ng-file-select=""
      ng-model='selectedFiles' 
      ng-file-change="myUpload(selectedFiles)" 
      ng-multiple="true">
  </div>

</div>

app/assets/javascripts/main.js.coffee:

@app = angular.module 'myApp', ['angularFileUpload']

app/assets/javascripts/FileUploadCtrl.js.coffee:

@app.controller 'FileUploadCtrl', [
  '$scope', 
  '$upload', 
  '$timeout', 
  ($scope, $upload, $timeout) ->

    $scope.myUpload = (files) ->
      len = files.length
      i = 0
      fileReader = undefined
      csrf_token = document.querySelector('meta[name="csrf-token"]').getAttribute('content')

      for file in files
        fileReader = new FileReader()

        #-------
        fileReader.onload = (e) ->

          #Define function for timeout, e.g. $timeout(timeout_func, 5000) 
          timeout_func = ->
            file.upload = $upload.http {
              url: "/static_pages/upload",
              method: 'POST',
              headers: {
                'Content-Type': file.type,
                'X-CSRF-TOKEN': csrf_token
              },
              data: e.target.result #the file's contents as an ArrayBuffer
            }

            file.upload.then(
              (success_resp) -> file.result = success_resp.data,  #response from server (=upload.html)
              (failure_resp) -> 
                if failure_resp.status > 0
                  $scope.errorMsg = "#{failure_resp.status}: #{response.data}"
            )

            file.upload.progress( (evt) ->
              file.progress = Math.min 100, parseInt(100.0 * evt.loaded / evt.total)
            )
          #end of timeout_func

          $timeout timeout_func, 5000 

        #end of FileReader.onload

        fileReader.readAsArrayBuffer file
]

Note: In the code above, I had to add the csrf lines because in app/views/layouts/application.rb, I have this:

<%= csrf_meta_tags %>

which causes rails to add a csrf token to each web page. angular-file-upload was causing rails CSRF Errors, so I had to retrieve the csrf token and add it to the request headers.

app/assets/javascripts/application.js:

//I removed: 
//     require turbolinks 
//for angular app
//
//= require jquery
//= require jquery_ujs
//
//The 'require_tree .' adds all the files in some random
//order, but for AngularJS the order is important:
//
//= require angular
//= require angular-file-upload-all
//
//And for some inexplicable reason, this is needed:
//= require main
//I would think 'require_tree .' would be enough for that file.
//
//= require_tree .

I didn’t use gems for angular or angular-file-upload. I just copied the AngularJS code into a file named angular.js which I put inside app/assets/javascripts. Similarly, I copied the code in angular-file-upload-all into app/assets/javascripts/angular-file-upload-all.js

app/controllers/static_pages_controller.rb:

class StaticPagesController < ApplicationController

  def index
  end

  def upload
    puts "****PARAMS:"
    p params 

    puts "****body of request: #{request.body.read.inspect}"  #inspect => outputs "" for empty body rather than nothing
    puts "****Content-Type: #{request.headers['Content-Type']}"

    render nothing: true
  end  

end

config/routes.rb:

Test1::Application.routes.draw do
  root "static_pages#index" 
  post "static_pages/upload"

As far as I can tell the data: key needs to be the contents of the file (as an ArrayBuffer). To get rails to insert additional data in the params hash, you could use the url, for example

url: "/media.json" + '?firstName=Kaspar 

On the server side, the only way I could access the file was using request.body.read and the headers with request.headers['Content-Type']. What did you end up doing?

Also, I found two problems with file.type here:

headers: {
  'Content-Type': file.type,

1) For some reason, neither FireFox nor Chrome can determine the file type of a .json file, so file.type ends up being a blank string: "". Rails then enters the file’s contents as a key in the params hash. Huh?

If you tack .json onto the end of the url:

url: "/static_pages/upload.json",

…then Rails will parse the body of the request as JSON and enter the key/value pairs in the params hash. But adding .json to the url doesn’t make the code very general because it prevents other file types from being processed correctly.

Here is a more general solution for uploading .json files:

  for file in files
    file_type = file.type

    if file_type is ''  #is => ===
      [..., file_ext] = file.name.split '.'

      if file_ext is 'json' 
        file_type = 'application/json'

…then later in the code:

 headers: {
    'Content-Type': file_type, #instead of file.type

2) However, there is still a closure problem in the original code, which needs to be corrected in order for multiple file selections to work correctly. If you select multiple files, then the file_type for all the files will end up being the file_type of the last file. For instance, if you select a .txt file and a .json file, then both files will have the type of the second file, i.e. application/json. That’s problematic because rails will try to parse the body of the text file as JSON, which will produce the error ActionDispatch::ParamsParser::ParseError.

To correct the closure problem, one well known solution is to define a wrapper function around fileReader.onload(). Coffeescript has a syntax that makes adding a wrapper function especially pain free:

    do (file_type) ->  #wrapper function, which `do` immediately executes sending it the argument file_type
      fileReader.onload = (e) ->
        ...
        ...

By adding one line, you can fix the shared variable problem. For details on what that does, go to the coffeescript home page and search the page for: do keyword.

app/assets/javascripts/FileUploadCtrl.js.coffee:

@app.controller 'FileUploadCtrl', [
  '$scope', 
  '$upload', 
  '$timeout', 
  ($scope, $upload, $timeout) ->

    $scope.myUpload = (files) ->
      len = files.length
      i = 0
      fileReader = undefined
      csrf_token = document.querySelector('meta[name="csrf-token"]').getAttribute('content')

      for file in files
        #console.log file
        file_type = file.type
        #console.log file_type

        if file_type is '' 
          [..., file_ext] = file.name.split '.'
          #console.log file_ext
          if file_ext is 'json'
            file_type = 'application/json'
            #console.log "Corrected file_type: " + file_type


        fileReader = new FileReader()

        #-------
        do (file_type) ->
          fileReader.onload = (e) ->

            #Define function for timeout, e.g. $timeout(timeout_func, 5000) 
            timeout_func = ->

              file.upload = $upload.http( 
                url: "/static_pages/upload"
                method: 'POST'
                headers: 
                  'Content-Type': file_type 
                  'X-CSRF-TOKEN': csrf_token
                data: e.target.result, #file contents as ArrayBuffer
              )

              file.upload.then(
                (success_resp) -> file.result = success_resp.data,  #response from server 
                (failure_resp) -> 
                  if failure_resp.status > 0
                    $scope.errorMsg = "#{failure_resp.status}: #{response.data}"
              )

              file.upload.progress (evt) ->
                file.progress = Math.min 100, parseInt(100.0 * evt.loaded / evt.total)
            #end of timeout_func

            $timeout timeout_func, 5000 

          #end of FileReader.onload

        fileReader.readAsArrayBuffer file
]

Finally, in this code,

data: e.target.result

…the entity returned by e.target.result is an ArrayBuffer, and I wasn’t able to figure out how to modify that to add additional data.

Answered By – 7stud

Answer Checked By – Katrina (AngularFixing Volunteer)

Leave a Reply

Your email address will not be published.