juandemarco juandemarco - 6 months ago 66
AngularJS Question

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?

Answer

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.