Search code examples
javascriptruby-on-railsrubyangularjsangular-file-upload

Not able to upload a file to Rails server with Angular


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.