Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

AngularJS: Upload files using $resource (solution)

I'm using AngularJS to interact with a RESTful webservice, using $resource to abstract the various entities exposed. Some of this entities are images, so I need to be able to use the save action of $resource "object" to send both binary data and text fields within the same request.

How can I use AngularJS's $resource service to send data and upload images to a restful webservice in a single POST request?

like image 888
Gian Marco Toso Avatar asked Jan 14 '14 14:01

Gian Marco Toso


3 Answers

I've searched far and wide and, while I might have missed it, I couldn't find a solution for this problem: uploading files using a $resource action.

Let's make this example: our RESTful service allows us to access images by making requests to the /images/ endpoint. Each Image has a title, a description and the path pointing to the image file. Using the RESTful service, we can get all of them (GET /images/), a single one (GET /images/1) or add one (POST /images). Angular allows us to use the $resource service to accomplish this task easily, but doesn't allow for file uploading - which is required for the third action - out of the box (and they don't seem to be planning on supporting it anytime soon). How, then, would we go about using the very handy $resource service if it can't handle file uploads? It turns out it's quite easy!

We are going to use data binding, because it's one of the awesome features of AngularJS. We have the following HTML form:

<form class="form" name="form" novalidate ng-submit="submit()">
    <div class="form-group">
        <input class="form-control" ng-model="newImage.title" placeholder="Title" required>
    </div>
    <div class="form-group">
        <input class="form-control" ng-model="newImage.description" placeholder="Description">
    </div>
    <div class="form-group">
        <input type="file" files-model="newImage.image" required >
    </div>

    <div class="form-group clearfix">
        <button class="btn btn-success pull-right" type="submit" ng-disabled="form.$invalid">Save</button>
    </div>
</form>

As you can see, there are two text input fields that are binded each to a property of a single object, which I have called newImage. The file input is binded as well to a property of the newImage object, but this time I've used a custom directive taken straight from here. This directive makes it so that every time the content of the file input changes, a FileList object is put inside the binded property instead of a fakepath (which would be Angular's standard behavior).

Our controller code is the following:

angular.module('clientApp')
.controller('MainCtrl', function ($scope, $resource) {
    var Image = $resource('http://localhost:3000/images/:id', {id: "@_id"});

    Image.get(function(result) {
        if (result.status != 'OK')
            throw result.status;

        $scope.images = result.data;
    })

    $scope.newImage = {};

    $scope.submit = function() {
        Image.save($scope.newImage, function(result) {
            if (result.status != 'OK')
                throw result.status;

            $scope.images.push(result.data);
        });
    }
}); 

(In this case I am running a NodeJS server on my local machine on port 3000, and the response is a json object containing a status field and an optional data field).

In order for the file upload to work, we just need to properly configure the $http service, for example within the .config call on the app object. Specifically, we need to transform the data of each post request to a FormData object, so that it's sent to the server in the correct format:

angular.module('clientApp', [
'ngCookies',
'ngResource',
'ngSanitize',
'ngRoute'
])
.config(function ($httpProvider) {
  $httpProvider.defaults.transformRequest = function(data) {
    if (data === undefined)
      return data;

    var fd = new FormData();
    angular.forEach(data, function(value, key) {
      if (value instanceof FileList) {
        if (value.length == 1) {
          fd.append(key, value[0]);
        } else {
          angular.forEach(value, function(file, index) {
            fd.append(key + '_' + index, file);
          });
        }
      } else {
        fd.append(key, value);
      }
    });

    return fd;
  }

  $httpProvider.defaults.headers.post['Content-Type'] = undefined;
});

The Content-Type header is set to undefined because setting it manually to multipart/form-data would not set the boundary value, and the server would not be able to parse the request correctly.

That's it. Now you can use $resource to save() objects containing both standard data fields and files.

WARNING This has some limitations:

  1. It doesn't work on older browsers. Sorry :(
  2. If your model has "embedded" documents, like

    { title: "A title", attributes: { fancy: true, colored: false, nsfw: true }, image: null }

    then you need to refactor the transformRequest function accordingly. You could, for example, JSON.stringify the nested objects, provided you can parse them on the other end

  3. English is not my main language, so if my explanation is obscure tell me and I'll try to rephrase it :)

  4. This is just an example. You can expand on this depending on what your application needs to do.

I hope this helps, cheers!

EDIT:

As pointed out by @david, a less invasive solution would be to define this behavior only for those $resources that actually need it, and not to transform each and every request made by AngularJS. You can do that by creating your $resource like this:

$resource('http://localhost:3000/images/:id', {id: "@_id"}, { 
    save: { 
        method: 'POST', 
        transformRequest: '<THE TRANSFORMATION METHOD DEFINED ABOVE>', 
        headers: '<SEE BELOW>' 
    } 
});

As for the header, you should create one that satisfies your requirements. The only thing you need to specify is the 'Content-Type' property by setting it to undefined.

like image 112
Gian Marco Toso Avatar answered Nov 15 '22 09:11

Gian Marco Toso


The most minimal and least invasive solution to send $resource requests with FormData I found to be this:

angular.module('app', [
    'ngResource'
])

.factory('Post', function ($resource) {
    return $resource('api/post/:id', { id: "@id" }, {
        create: {
            method: "POST",
            transformRequest: angular.identity,
            headers: { 'Content-Type': undefined }
        }
    });
})

.controller('PostCtrl', function (Post) {
    var self = this;

    this.createPost = function (data) {
        var fd = new FormData();
        for (var key in data) {
            fd.append(key, data[key]);
        }

        Post.create({}, fd).$promise.then(function (res) {
            self.newPost = res;
        }).catch(function (err) {
            self.newPostError = true;
            throw err;
        });
    };

});
like image 43
timetowonder Avatar answered Nov 15 '22 11:11

timetowonder


Please note that this method won't work on 1.4.0+. For more information check AngularJS changelog (search for $http: due to 5da1256) and this issue. This was actually an unintended (and therefore removed) behaviour on AngularJS.

I came up with this functionality to convert (or append) form-data into a FormData object. It could probably be used as a service.

The logic below should be inside either a transformRequest, or inside $httpProvider configuration, or could be used as a service. In any way, Content-Type header has to be set to NULL, and doing so differs depending on the context you place this logic in. For example inside a transformRequest option when configuring a resource, you do:

var headers = headersGetter();
headers['Content-Type'] = undefined;

or if configuring $httpProvider, you could use the method noted in the answer above.

In the example below, the logic is placed inside a transformRequest method for a resource.

appServices.factory('SomeResource', ['$resource', function($resource) {
  return $resource('some_resource/:id', null, {
    'save': {
      method: 'POST',
      transformRequest: function(data, headersGetter) {
        // Here we set the Content-Type header to null.
        var headers = headersGetter();
        headers['Content-Type'] = undefined;

        // And here begins the logic which could be used somewhere else
        // as noted above.
        if (data == undefined) {
          return data;
        }

        var fd = new FormData();

        var createKey = function(_keys_, currentKey) {
          var keys = angular.copy(_keys_);
          keys.push(currentKey);
          formKey = keys.shift()

          if (keys.length) {
            formKey += "[" + keys.join("][") + "]"
          }

          return formKey;
        }

        var addToFd = function(object, keys) {
          angular.forEach(object, function(value, key) {
            var formKey = createKey(keys, key);

            if (value instanceof File) {
              fd.append(formKey, value);
            } else if (value instanceof FileList) {
              if (value.length == 1) {
                fd.append(formKey, value[0]);
              } else {
                angular.forEach(value, function(file, index) {
                  fd.append(formKey + '[' + index + ']', file);
                });
              }
            } else if (value && (typeof value == 'object' || typeof value == 'array')) {
              var _keys = angular.copy(keys);
              _keys.push(key)
              addToFd(value, _keys);
            } else {
              fd.append(formKey, value);
            }
          });
        }

        addToFd(data, []);

        return fd;
      }
    }
  })
}]);

So with this, you can do the following without problems:

var data = { 
  foo: "Bar",
  foobar: {
    baz: true
  },
  fooFile: someFile // instance of File or FileList
}

SomeResource.save(data);
like image 10
Parziphal Avatar answered Nov 15 '22 09:11

Parziphal