AngularJS: carga archivos usando $ recurso (solución)

Estoy usando AngularJS para interactuar con un RESTful web RESTful , usando $resource para abstraer las diversas entidades expuestas. Algunas de estas entidades son imágenes, por lo que necesito poder usar la acción save de $resource “object” para enviar datos binarios y campos de texto dentro de la misma solicitud.

¿Cómo puedo usar el servicio $resource AngularJS para enviar datos y subir imágenes a un servicio web tranquilo en una sola solicitud POST ?

    He buscado por todas partes y, aunque podría haberlo perdido, no pude encontrar una solución para este problema: cargar archivos usando una acción $ recurso.

    Hagamos este ejemplo: nuestro servicio RESTful nos permite acceder a las imágenes haciendo solicitudes a /images/ endpoint. Cada Image tiene un título, una descripción y la ruta que apunta al archivo de imagen. Usando el servicio RESTful, podemos obtener todos ellos ( GET /images/ ), uno solo ( GET /images/1 ) o agregar uno ( POST /images ). Angular nos permite usar el servicio de $ recursos para realizar esta tarea fácilmente, pero no permite la carga de archivos (que se requiere para la tercera acción) de manera inmediata (y no parecen estar planeando respaldarla en cualquier momento pronto ). ¿Cómo, entonces, podríamos usar el muy útil servicio de $ recursos si no puede manejar las cargas de archivos? ¡Resulta que es bastante fácil!

    Vamos a utilizar el enlace de datos, ya que es una de las características increíbles de AngularJS. Tenemos el siguiente formulario HTML:

     

    Como puede ver, hay dos campos de input texto que están enlazados cada uno a una propiedad de un solo objeto, que he llamado newImage . La input archivo también está vinculada a una propiedad del newImage objeto newImage , pero esta vez he usado una directiva personalizada tomada directamente desde aquí . Esta directiva hace que cada vez que el contenido de la input archivo cambie, un objeto FileList se ponga dentro de la propiedad binded en lugar de un fakepath (que sería el comportamiento estándar de Angular).

    Nuestro código de controlador es el siguiente:

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

    (En este caso estoy ejecutando un servidor NodeJS en mi máquina local en el puerto 3000, y la respuesta es un objeto json que contiene un campo de status y un campo de data opcional).

    Para que la carga del archivo funcione, solo necesitamos configurar correctamente el servicio $ http, por ejemplo, dentro de la llamada .config en el objeto de la aplicación. Específicamente, necesitamos transformar los datos de cada solicitud de publicación a un objeto FormData, para que se envíe al servidor en el formato correcto:

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

    El encabezado Content-Type se establece en undefined porque establecerlo manualmente en multipart/form-data no establecería el valor límite, y el servidor no podría analizar la solicitud correctamente.

    Eso es. Ahora puede usar $resource para save() objetos que contienen tanto campos de datos estándar como archivos.

    ADVERTENCIA Esto tiene algunas limitaciones:

    1. No funciona en navegadores más antiguos. Lo siento 🙁
    2. Si su modelo tiene documentos “incrustados”, como

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

      entonces necesita refactorizar la función transformRequest en consecuencia. Podría, por ejemplo, JSON.stringify los objetos nesteds, siempre que pueda analizarlos en el otro extremo

    3. El inglés no es mi idioma principal, así que si mi explicación es oscura dígame y trataré de reformularla 🙂

    4. Esto es solo un ejemplo. Puede ampliar esto dependiendo de lo que su aplicación necesite hacer.

    Espero que esto ayude, ¡salud!

    EDITAR:

    Como señaló @david , una solución menos invasiva sería definir este comportamiento solo para aquellos $resource que realmente lo necesitan, y no para transformar todas y cada una de las solicitudes hechas por AngularJS. Puedes hacer eso creando tu $resource como este:

     $resource('http://localhost:3000/images/:id', {id: "@_id"}, { save: { method: 'POST', transformRequest: '', headers: '' } }); 

    En cuanto al encabezado, debe crear uno que satisfaga sus requisitos. Lo único que debe especificar es la propiedad 'Content-Type' al establecerlo como undefined .

    La solución más mínima y menos invasiva para enviar $resource solicitudes de $resource con FormData descubrí que es esta:

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

    Tenga en cuenta que este método no funcionará en 1.4.0+. Para obtener más información, consulte el registro de cambios de AngularJS (busque $http: due to 5da1256 ) y este problema . Esto fue en realidad un comportamiento involuntario (y por lo tanto eliminado) en AngularJS.

    Se me ocurrió esta funcionalidad para convertir (o anexar) datos de formulario en un objeto FormData. Probablemente podría usarse como un servicio.

    La siguiente lógica debe estar dentro de una configuración transformRequest , o dentro de $httpProvider , o podría usarse como un servicio. En cualquier caso, el encabezado Content-Type debe establecerse en NULL, y hacerlo difiere según el contexto en el que coloque esta lógica. Por ejemplo, dentro de una opción transformRequest al configurar un recurso, usted hace:

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

    o si configura $httpProvider , puede usar el método indicado en la respuesta anterior.

    En el siguiente ejemplo, la lógica se coloca dentro de un método transformRequest para un recurso.

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

    Entonces con esto, puedes hacer lo siguiente sin problemas:

     var data = { foo: "Bar", foobar: { baz: true }, fooFile: someFile // instance of File or FileList } SomeResource.save(data);