I recently needed to produce a small report and serve it as a file download via a .NET API endpoint. The endpoint would be invoked from a React app, using Axios, and trigger the file download in the browser.
As the server needed to produce the filename with a timestamp, it made sense to make use of the FileStreamResult
to specify the content type and filename. What's great about FileStreamResult
is that it can take streams as parameters and will automatically include the Content-Disposition
header in the response.
Important: It is rarely a good idea to pass byte arrays around in a system. If you know the expected size of the bytes (like a key being used in asymmetric encryption), then that's ok. But any indeterminate length of data should be handled as a Stream.
[HttpGet("todo/{id}/download")]
public IActionResult Download(Guid id, CancellationToken cancellationToken)
{
var text = $"hello world - {id}";
var fileName = "example.txt";
var contentType = "text/plain";
var fileBytes = System.Text.Encoding.UTF8.GetBytes(text);
return File(fileBytes, contentType, fileName);
}
I could easily test the endpoint in my browser using Swagger; the browser saw the response as an attachment and downloaded the text file as expected.
But when I tried to implement this using Axios, I ran into problems:
Invoking the endpoint using Axios, would not trigger the browser's download dialogue.
Even though I received a response from the server using Axios, the
Content-Disposition
header was missing.
What's going on here? ๐ค
Triggering the download using Axios
Credit to this Medium article, but in essence: making a request via XHR (which Axios, fetch, etc. abstracts over), tells the browser that the response should be handled by JavaScript code. So we need to trigger the download using JS and a quick search reveals this rather popular gist below:
Including the Content-Disposition
Header in the AxiosResponse
This part was confusing; FileStreamResult
was including the header in the response, verifiable in the browser and the Network tab of my browser's DevTools. Why was it missing from the AxiosResponse
?
Turns out this was a CORS issue.
When a browser makes a cross-origin request, it first sends an HTTP OPTIONS
request to the server to check if the server allows the request. If the server allows the request, it sends a response with an Access-Control-Allow-Origin
header to indicate which origins are allowed to access the resource.
The Access-Control-Expose-Headers
header is used to specify which headers in the response can be exposed to the client, beyond the simple response headers that are automatically exposed
I needed to update the .NET WebApi's CORS settings
services.AddCors(o => o.AddPolicy("DefaultPolicy", builder =>
{
builder.AllowAnyOrigin()
.AllowAnyMethod()
.AllowAnyHeader()
.WithExposedHeaders("Content-Disposition");
}));
Now that the header was being exposed to the client, it appeared in the Axios response object.
And with some error checking and some filename assembly, we can now trigger the download in the browser with the filename supplied by the server.
const response = await axios({
method:'GET',
url:url,
responseType:'blob'
})
if (response.status === 200) {
const fname = response.headers['content-disposition'].split('filename=')[1].split('.')[0];
const ext = response.headers['content-disposition'].split('.')[1].split(';')[0];
const filename = `${fname.replace(`"`,``)}.${ext.replace(`"`,``)}`;
const href = URL.createObjectURL(response.data);
// create "a" HTML element with href to file & click
const link = document.createElement('a');
try {
link.href = href;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
}
finally {
// clean up "a" element & remove ObjectURL
document.body.removeChild(link);
URL.revokeObjectURL(href);
}
}
Happy downloading!