JavaScript/jQuery to download file via POST with JSON data

Viewed 406123

I have a jquery-based single-page webapp. It communicates with a RESTful web service via AJAX calls.

I'm trying to accomplish the following:

  1. Submit a POST that contains JSON data to a REST url.
  2. If the request specifies a JSON response, then JSON is returned.
  3. If the request specifies a PDF/XLS/etc response, then a downloadable binary is returned.

I have 1 & 2 working now, and the client jquery app displays the returned data in the web page by creating DOM elements based on the JSON data. I also have #3 working from the web-service point of view, meaning it will create and return a binary file if given the correct JSON parameters. But I'm unsure the best way to deal with #3 in the client javascript code.

Is it possible to get a downloadable file back from an ajax call like this? How do I get the browser to download and save the file?

$.ajax({
    type: "POST",
    url: "/services/test",
    contentType: "application/json",
    data: JSON.stringify({category: 42, sort: 3, type: "pdf"}),
    dataType: "json",
    success: function(json, status){
        if (status != "success") {
            log("Error loading data");
            return;
        }
        log("Data loaded!");
    },
    error: function(result, status, err) {
        log("Error loading data");
        return;
    }
});

The server responds with the following headers:

Content-Disposition:attachment; filename=export-1282022272283.pdf
Content-Length:5120
Content-Type:application/pdf
Server:Jetty(6.1.11)

Another idea is to generate the PDF and store it on the server and return JSON that includes a URL to the file. Then, issue another call in the ajax success handler to do something like the following:

success: function(json,status) {
    window.location.href = json.url;
}

But doing that means I would need to make more than one call to the server, and my server would need to build downloadable files, store them somewhere, then periodically clean up that storage area.

There must be a simpler way to accomplish this. Ideas?


EDIT: After reviewing the docs for $.ajax, I see that the response dataType can only be one of xml, html, script, json, jsonp, text, so I'm guessing there is no way to directly download a file using an ajax request, unless I embed the binary file in using Data URI scheme as suggested in the @VinayC answer (which is not something I want to do).

So I guess my options are:

  1. Not use ajax and instead submit a form post and embed my JSON data into the form values. Would probably need to mess with hidden iframes and such.

  2. Not use ajax and instead convert my JSON data into a query string to build a standard GET request and set window.location.href to this URL. May need to use event.preventDefault() in my click handler to keep browser from changing from the application URL.

  3. Use my other idea above, but enhanced with suggestions from the @naikus answer. Submit AJAX request with some parameter that lets web-service know this is being called via an ajax call. If the web service is called from an ajax call, simply return JSON with a URL to the generated resource. If the resource is called directly, then return the actual binary file.

The more I think about it, the more I like the last option. This way I can get information back about the request (time to generate, size of file, error messages, etc.) and I can act on that information before starting the download. The downside is extra file management on the server.

Any other ways to accomplish this? Any pros/cons to these methods I should be aware of?

17 Answers

Found it somewhere long time ago and it works perfectly!

let payload = {
  key: "val",
  key2: "val2"
};

let url = "path/to/api.php";
let form = $('<form>', {'method': 'POST', 'action': url}).hide();
$.each(payload, (k, v) => form.append($('<input>', {'type': 'hidden', 'name': k, 'value': v})) );
$('body').append(form);
form.submit();
form.remove();

Solution

Content-Disposition attachment seems to work for me:

self.set_header("Content-Type", "application/json")
self.set_header("Content-Disposition", 'attachment; filename=learned_data.json')

Workaround

application/octet-stream

I had something similar happening to me with a JSON, for me on the server side I was setting the header to self.set_header("Content-Type", "application/json") however when i changed it to:

self.set_header("Content-Type", "application/octet-stream")

It automatically downloaded it.

Also know that in order for the file to still keep the .json suffix you will need to it on filename header:

self.set_header("Content-Disposition", 'filename=learned_data.json')

The Problems with Making your own events

Many of the solutions proposed on this article have the JavaScript run asynchronously and create a link element then calling

const a = documet.createElement("a") 
a.click()

or creating a mouse event

new MouseEvent({/* ...some config */})

This would seem fine right? What could be wrong with this?

What is an Event-Sourcing?

Event sourcing has a bunch of meanings across computing such as a system of pub sub in a cloud based architecture, or the browser api EventSource. In the context of a browser all events have a source and that source has hidden property that says who initiated this event (the user or the site).

Knowing this we can start to understand why two click events might not be treated the same

user click*          new MouseEvent()
-----------            -----------
| Event 1 |            | Event 2 |
-----------            -----------
     |                      |     
     |----------------------|
                 |
                 |
      ----------------------
      | Permissions Policy |    Available in chrome allows the server to control
      ----------------------    what features are going to be used by the JS
                 |
                 |
   ----------------------------
   | Browser Fraud Protection | The Browser REALLY doesnt like being told to pretend
   ---------------------------- to be a user. If you will remember back to the early
                 |              2000s when one click spun off 2000 pop ups. Well here
                 |              is where popups are blocked, fraudulent ad clicks are
                \ /             thrown out, and most importantly for our case stops 
                 v              fishy downloads
      JavaScript Event Fires

So I just Can't Download off A POST That's Dumb

No, of course you can. You just need to give the user a chance to create the event. Here are a number of patterns that you can use to create user flows that are obvious and convectional and will not be flagged as fraud. (using jsx sorry not sorry)

A Form can be used to navigate to a url with a post action.

const example = () => (
  <form
   method="POST"
   action="/super-api/stuff"
   onSubmit={(e) => {/* mutably change e form data but don't e.preventDetfault() */}}
  >
    {/* relevant input fields of your download */}
  </form>
)

Preloading If your download is non-configurable you may want to consider preloading the download into resp.blob() or new Blob(resp) this tells the browser that this is a file and we wont be doing any string operations on it. As with the other answers you can use window.URL.createObjectURL what is not mentioned is that

createObjectURL CAN MAKE A MEMORY LEAK IN JAVASCRIPTsource

If you don't want the C++ bully's to come make fun of you you must free this memory. Ahh but I'm just a hobbiest who loves his garbage collector. Have no fear this is very simple if you are working in most frameworks (for me react) you just register some sort of clean up effect on your component and your right as rain.

const preload = () => {
  const [payload, setPayload] = useState("")
  
  useEffect(() => {
    fetch("/super-api/stuff")
      .then((f) => f.blob())
      .then(window.URL.createObjectURL)
      .then(setPayload)

    return () => window.URL.revokeObjectURL(payload)
  }, [])


  return (<a href={payload} download disabled={payload === ""}>Download Me</a>)
}

I think I got close, but something is corrupting the file (Image), any way, maybe some one can disclose the problem of this approach

$.ajax({
            url: '/GenerateImageFile',
            type: 'POST',
            cache: false,
            data: obj,
            dataType: "text",
            success: function (data, status, xhr) {
                let blob = new Blob([data], { type: "image/jpeg" });

                let a = document.createElement('a');
                a.href = window.URL.createObjectURL(blob);
                a.download = "test.jpg";
                document.body.appendChild(a);
                a.click();
                document.body.removeChild(a);
                window.URL.removeObjectURL(a.href);
            },
            complete: function () {

            },
            beforeSend: function () {

            }
        });

I liked Frank's idea and decided to do my own twist to it. As trying to do it in one post is very complicated, I'm using the two post method but only hitting the database once and no need to save the file or clean up file when completed.

First I run the ajax request to retrieve the data but instead of returning the data from the controller I will return a GUID that is tied to a TempData storage of the records.

$.get("RetrieveData", { name: "myParam"} , function(results){
    window.location = "downloadFile?id=" + results
});

public string RetrieveData(string name)
{
    var data = repository.GetData(name);
    string id = Guid.NewGuid().ToString();
    var file = new KeyValuePair<string, MyDataModel>(name, data);
    TempData[id]=file;
    return id;
}

Then when I call the window.location I pass the Guid to the new method and get the data from TempData. After this method is executed TempData will be free.

public ActionResult DownloadFile(string id)
{
   var file = (KeyValuePair<string,MyDataModel>)TempData[id];
   var filename = file.Key;
   var data = file.Value;
   var byteArray = Encoding.UTF8.GetBytes(data);
   ...
   return File(byteArray, "text/csv", "myFile.csv");
}
Related