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?
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:
- It doesn't work on older browsers. Sorry :(
-
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 English is not my main language, so if my explanation is obscure tell me and I'll try to rephrase it :)
- 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 $resource
s 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
.
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;
});
};
});
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);