AngularJS: Upload files using $resource (solution)
Asked Answered
U

3

55

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?

Uncomfortable answered 14/1, 2014 at 14:16 Comment(0)
U
49

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.

Uncomfortable answered 14/1, 2014 at 14:16 Comment(7)
You can also integrate this into just one $resource by putting the function into the settings object: $resource('localhost:3000/images/:id', {id: "@_id"}, { save: { method: 'POST', transformRequest: ..., header: ... } });Tradesman
That would actually be a very good idea, I'll update the answer!Uncomfortable
I went ahead and fixed the nested objects issue you mentioned in your post, if you feel like including it in your post - transformRequest: function(data, headersGetter) { 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 {if (value !== null && typeof value === 'object'){fd.append(key, JSON.stringify(value)); } else {fd.append(key, value);}}});return fd;}Triphibious
What is files-model in files-model="newImage.image" for a directive?Lettielettish
It's also linked in the wall of text of the answer: github.com/angular/angular.js/issues/1375#issuecomment-21933012Uncomfortable
With the last method, if the form data doesn't contain a File (because it could be optional), Angular will send the request as text/plain, causing the server to miss the posted data. So I think I need the headers object to be variable: if a File is included, set Content-Type to undefined, otherwise don't change it.Tabling
can you explain more in depth why you need to set content-type to undefined?Loveland
K
25

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;
        });
    };

});
Kasey answered 26/10, 2015 at 16:34 Comment(5)
This makes sense. Considering I wrote the original post more than a year ago, I can't say if this was viable back then but it probably was. My only question is how do you put files into your data?Uncomfortable
well, basically you listen to a change event on a file-input element. You can wrap this into a directive, something like this (works with images)Kasey
This is the most clear & clean solution I've seen to include simple file uploads with angular forms. Still have to worry about FormData support (caniuse.com/#search=formData) but fortunately that's not an issue for my project. A note if you are using Rails for the backend (I'm using Rails 4.2): since the Content-Type will now be multipart form data, you will have to modify your wrap_parameters since it only kicks in for Content-Type json by default (see #16381393)Outdoors
thank you so much! this should be the marked answer.Protohistory
where does your data come from? is it from form name attribute? like so ` <form name="data" ng-submit="createPost(data);">`Loveland
T
10

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);
Tabling answered 11/9, 2014 at 19:37 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.