Downloading and saving data with fetch() from authenticated REST

17,993

Solution 1

I was forced to come back to this because we just ran into the 2MB limit in Chrome. Who knew? (This person, for one: https://craignicol.wordpress.com/2016/07/19/excellent-export-and-the-chrome-url-limit/ posted a couple months after my question here, and whose solution I implemented below.)

Anyhow, I will now attempt to answer my own questions since I have not seen an acceptable answer yet regarding the requested authentication requirement. (Although, Conrado did provide some useful links otherwise to that requirement.)

As to the questions of: Why I have to do it this way and is there not a more intrinsic way? The answers appear to be "just because" and "no" respectively. Not very good answers, I know. I wish I could explain it more... but I really can't without sticking my foot in my mouth somewhere. I'm not a network expert and certainly not the right person to explain it. It just is what it is from what I read. You just can't fake a stream as a file download while authenticating.

As to: What I am missing and what is the easier way? Well, there is no easier way. Sure, I could throw more libraries like FileSaver.js at the code base and let them hide some of this code for me. But, I don't like a larger tool set than I really need (I'm looking at you, you ridiculous 50MB sites). I could accomplish the same thing as those libraries by hiding my download() function elsewhere and importing it. No, that is not easier from my point of view. Less work maybe to grab a prebuilt function, but not easier in terms of amount of code executed to make a download happen. Sorry.

But... I was missing something: that thing that led to that 2MB limit in Chrome. At the time, I didn't really understand how this URI data hack was working that I was using. I found some code that worked, and used it. I get it now -- now that I've had more time to read deeper on that part of the problem. In short, I was missing the blob options versus the URI option. Sure, blobs have their own limitations with various browsers but, given that my use cases would not have been affected by any of those back in 2016, the blob option would have been a better route to take from the start... and it feels less hacky (and maybe a bit "easier" because of that alone).

Here is my current solution that tries a blob save before falling back to the URI data hack:

JS (React):

saveStreamCSV(filename, text) {
    this.setState({downloadingCSV: false})
    if(window.navigator.msSaveBlob) {
        // IE 10 and later, and Edge.
        var blobObject = new Blob([text], {type: 'text/csv'});
        window.navigator.msSaveBlob(blobObject, filename);
    } else {
        // Everthing else (except old IE).
        // Create a dummy anchor (with a download attribute) to click.
        var anchor = document.createElement('a');
        anchor.download = filename;
        if(window.URL.createObjectURL) {
            // Everything else new.
            var blobObject = new Blob([text], {type: 'text/csv'});
            anchor.href = window.URL.createObjectURL(blobObject);
        } else {
            // Fallback for older browsers (limited to 2MB on post-2010 Chrome).
            // Load up the data into the URI for "download."
            anchor.href = 'data:text/csv;charset=utf-8,' + encodeURIComponent(text);
        }
        // Now, click it.
        if (document.createEvent) {
            var event = document.createEvent('MouseEvents');
            event.initEvent('click', true, true);
            anchor.dispatchEvent(event);
        }
        else {
            anchor.click();
        }
    }
}
handleDownloadClick(e) {
    this.setState({downloadingCSV: true})
    fetch(`/api/admin/studies/${this.props.study.id}/csv`
    ,   {
            headers: {
                "Authorization": "Bearer " + this.props.authAPI.getToken()
            ,   "Accept": "text/csv"
            }
        }
    )
    .then((response) => response.text())
    .then((responseText) => this.saveStreamCSV(`study${this.props.study.id}.csv`, responseText))
    .catch((error) => {
        this.setState({downloadingCSV: false})
        console.error("CSV handleDownloadClick:", error)
    })
}

Note: I went this route only because I don't need to worry about all the use cases that FileSaver.js was built for (this is for a feature on the admin app only and not public-facing).

Solution 2

In reference to this answer, you can use FileSaver or download.js libraries.

Example:

var saveAs = require('file-saver');

fetch('/download/urf/file', {
  headers: {
    'Content-Type': 'text/csv'
  },
  responseType: 'blob'
}).then(response => response.blob())
  .then(blob => saveAs(blob, 'test.csv'));

Solution 3

Tell the browser how to handle your download by using the DOM. Inject the anchor tag with Node.appendChild and let the user click the link.

<a href="/api/admin/studies/1/csv" download="study1.csv">Download CSV</a>

What you are doing is downloading the file, inserting the already complete request into an anchor tag, and creating a second request by using pom.click() in your code.

Edit: I missed the authorization header. If you like this suggestion, you could put the token in the query string instead.

Share:
17,993
juanitogan
Author by

juanitogan

Currently working at Brown University when on planet Earth.

Updated on June 05, 2022

Comments

  • juanitogan
    juanitogan almost 2 years

    I have a React app working with a REST backend built in Python and Flask. I'm downloading data from a database and saving it as a CSV file through the browser. I have this working. What I don't understand, however, is why I had to go beyond the sources I've been reading and mash stuff up to get this to work. Why haven't I found this outlined better?

    Some say all I have to do is set the response header with mimetype and Content-Disposition: attachment; filename=something.csv:

    Yet, this, alone, was only working with plain links, and not with fetch() and authentication, so I had to go looking for ways to save client data to disk such as this:

    So, my question is either:

    • Why do I have to do it this way?, or
    • What am I missing -- what is the easier way?

    It appears that the answer to the first question is that I can't modify request headers (to add the auth token) except through XHR type work. This appears to be answered (non-answered, really) here:

    And, that for some reason, responses to XHR with Content-Disposition: attachment are meaningless. Is that true? Is there not a more intrinsic way to manage requests like this in modern browsers?

    I feel like I don't understand this enough and that bugs me.

    Anyhow, here is the working code I am looking to simplify, if possible:

    JS (React):

    // https://stackoverflow.com/a/18197511/680464
    download(filename, text) {
        var pom = document.createElement('a');
        pom.setAttribute('href', 'data:text/csv;charset=utf-8,' + encodeURIComponent(text));
        pom.setAttribute('download', filename);
    
        if (document.createEvent) {
            var event = document.createEvent('MouseEvents');
            event.initEvent('click', true, true);
            pom.dispatchEvent(event);
        }
        else {
            pom.click();
        }
    }
    downloadReport(studyID) {
        fetch(`/api/admin/studies/${studyID}/csv`
        ,   {
                headers: {
                    "Authorization": "Bearer " + this.props.authAPI.getToken()
                ,   "Accept": "text/csv"
                }
            }
        )
        .then(this.checkStatus.bind(this))
        .then((response) => response.text())
        .then((responseText) => this.download(`study${studyID}.csv`, responseText))
        .catch((error) => {
            console.error(this.props.location.pathname, error)
        })
    }
    

    Python (Flask):

    @app.route("/api/admin/studies/<int:study_id>/csv", methods=["GET"])
    @admin.login_required
    def admin_get_csv(study_id):
    
        test = [("1","2","3"),("4","5","6")]
        def generate():
            for row in test:
                yield ",".join(row) + "\n"
    
        return Response(
                    generate()
                ,   mimetype="text/csv"
                ,   headers={"Content-Disposition": "attachment; filename=study{0}.csv".format(study_id)}
                )