How to download files in Angular.js

There are a few ways to download files with Angular. The simplist way to download a file is with html:

<a ng-href="{{downloadLink}}" target="_blank"></a>  

This is a perfectly good approach if the file is publicly accessible by everyone, but it's common for applications to protect files with user permissions. For example, your $scope.downloadLink might be corresponding to a document that you store in the backend, and you are fetching your file from server with a GET /documents/:documentId request that is protected with your own permissions.

Now that your file is protected, your server could respond with an error for file not found, permission denied, internal error, etc. However, using only an html approach to download does not catch such errors, and even worse, it breaks the user flow by opening another tab to show you the server error message. The ideal solution is to catch the error, and respond to the error message accordingly in frontend. Maybe you want to ask the user to retry later or show a toast message to indicate the file is corrupted. In order to do this, it's better to move download inside Angular, and handle the errors correctly.

Here's how:

<a ng-click="download(document)" target="_blank"></a>  

First, define a resouce for Document, and specify the responseType to be 'arrayBuffer'.

app.factory('DocumentResource', function($scope, $resource) {  
  $resource('document/:id", { id: "@id" }, {
    download: {
      method: 'GET',
      responseType: 'arraybuffer'
    }
  })
})

If response is successful, convert the data from ArrayBuffer into Blob, create a temporary url from the blob, and then programatically create a link to download.

app.controller('MainCtrl', function($scope, DocumentResource) {  
  $scope.download = function(document) {
    DocumentResource.download(document).$promise.then(function(data) {
      var url = URL.createObjectURL(new Blob([data]));
      var a = document.createElement('a');
      a.href = url;
      a.download = 'document_name';
      a.target = '_blank';
      a.click();
    })
    .catch(function(error) {
      // catching error here
    })
  }
})

However, there's one catch to doing it this way. The response data is now in ArrayBuffer, which means that when the server responds with a non-200 status, the json error data is automatically converted into ArrayBuffer. Ex:

  .catch(function(error) {
    console.log(error.data); // ArrayBuffer {}
  })

We can do the following to convert it back to json:

var arrayBufferToString = function(buff) {  
  var charCodeArray = Array.apply(null, new Uint8Array(buff));
  var result = '';
  for (i = 0, len = charCodeArray.length; i < len; i++) {
    code = charCodeArray[i];
     result += String.fromCharCode(code);
  }
  result;
}

errorResponse = angular.fromJson(arrayBufferToString(error.data));  

And that's all!

Putting everything together:

app.controller('MainCtrl', function($scope, DocumentResource, resourceError) {  
  $scope.download = function(document) {
    DocumentResource.download(document).$promise.then(function(result) {
      var url = URL.createObjectURL(new Blob([result.data]));
      var a = document.createElement('a');
      a.href = url;
      a.download = result.filename;
      a.target = '_blank';
      a.click();
    })
    .catch(resourceError)
    .catch(function(error) {
      console.log(error.data); // in JSON
    });
  }
});
app.factory('DocumentResource', function($scope, $resource, getHeaderFilename) {  
  $resource('document/:Id", { Id: "@Id" }, {
    download: {
      method: 'GET',
      responseType: 'arraybuffer',
      transformResponse: function(data, headers) {
        return {
          data: data,
          filename: parseHeaderFilename(headers)
        }
      }
    }
  });
});
app.service('getHeaderFilename', function() {  
  return function(headers) {
    var header = headers('content-disposition');
    var result = header.split(';')[1].trim().split('=')[1];
    return result.replace(/"/g, '');
  }
});
app.service('resourceError', function($q) {  
  var arrayBufferToString = function(buff) {
    var charCodeArray = Array.apply(null, new Uint8Array(buff));
    var result = '';
    for (i = 0, len = charCodeArray.length; i < len; i++) {
        code = charCodeArray[i];
       result += String.fromCharCode(code);
    }
    return result;
  }

  return function(error) {
    error.data = angular.fromJson(arrayBufferToString(error.data.data));
    return $q.reject(error);
  }
});

Thanks for reading! Feel free to suggest any better approach!

comments powered by Disqus