Fighting File Downloads and Dinosaurs with NSwag

Sadly, I've lived with this embarrassing hack to a rudimentary problem for months because a confluence of technologies that make my life easy for common actions make it hard for infrequent ones.  This week, I won round 2 by solving the problem correctly.

The Problem

My tech stack looks like this:

  • ASP.Net Core - for back end
  • Angular 7 - for front end (it requires a custom CORS policy, more on that later)
  • Swashbuckle - exposes a dynamically generated swagger json file
  • NSwag - consumes the swagger file and generates a client proxy

It happens to look like that because I use this excellent framework called ASP.Net Boilerplate (ABP, and if that doesn't sound familiar check out this ASP.Net Boilerplate Overview).  Regardless, it's a great tech stack independent of ABP.

The API client proxy that NSwag generates is great -- saves a huge amount of time and energy.  Unless, it turns out, you're trying to download a dynamically generated Excel file in TypeScript on button click and trigger a download.

A Naive Solution

After a brief web search one couldn't be blamed for installing EPPlus and writing an ASP.Net controller like this:

[Route("api/[controller]")]
public class ProductFilesController : AbpController
{
    [HttpPost]
    [Route("{filename}.xlsx")]
    public ActionResult Download(string fileName)
    {
        var fileMemoryStream = GenerateReportAndWriteToMemoryStream();
        return File(fileMemoryStream,
            "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
            fileName + ".xlsx");
    }
 
    private byte[] GenerateReportAndWriteToMemoryStream()
    {
        using (ExcelPackage package = new ExcelPackage())
        {
            ExcelWorksheet worksheet = package.Workbook.Worksheets.Add("Data");
            worksheet.Cells[1, 1].Value = "Hello World";
            return package.GetAsByteArray();
        }
    }
}

I took the approach above and naively expected Swashbuckle to generate a reasonable swagger.json file.  It generated this:

"/api/ProductFiles/{filename}.xlsx": {
    "post": {
        "tags": ["ProductFiles"],
            "operationId": "ApiProductFilesByFilename}.xlsxPost",
            "consumes": [],
            "produces": [],
            "parameters": [{
                "name": "fileName",
                "in": "path",
                "required": true,
                "type": "string"
            }],
            "responses": {
            "200": {
                "description": "Success"
            }
        }
    }
 
},

See the problem?  You're clearly smarter than me.  I ran NSwag and it generated this:

export class ApiServiceProxy {
    productFiles(fileName: string): Observable<void> {

Oh no.  No, Observable of void, is not going to work.  It needs to return something, anything.  Clearly I needed to be more explicit about the return type in the controller:

public ActionResult<FileContentResult> Download(string fileName) { ... }

And Swagger?

"/api/ProductFiles/{filename}.xlsx": {
    "post": {
        "tags": ["ProductFiles"],
            "operationId": "ApiProductFilesByFilename}.xlsxPost",
            "consumes": [],
            "produces": ["text/plain", "application/json", "text/json"],
            ...
            "200": {
                "description": "Success",
                    "schema": {
                    "$ref": "#/definitions/FileContentResult"
                }
            }

Perfect!  Swagger says a FileContentResult is the result and NSwag generates the exact code I was hoping for.  Everything looks peachy ... until you run it and the server says:

System.ArgumentException: Invalid type parameter 'Microsoft.AspNetCore.Mvc.FileContentResult' specified for 'ActionResult'.

And what about specifying just FileContentResult as the return type?  Fail.  It's back to void.

Ohai ProducesResponseType attribute.

[HttpPost]
[Route("{filename}.xlsx")]
[ProducesResponseType(typeof(FileContentResult), (int)HttpStatusCode.OK)]
 
public ActionResult Download(string fileName)

Swagger, do you like me now?  Yes.  NSwag?  Yes!  Serverside runtime you love me right?  Yup.  Finally NSwag you'll give me back that sweet FileContentResult if I'm friendly and sweet?

ERROR SyntaxError: Unexpected token P in JSON at position 0

inside the blobToText() function?!

NOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOOO!

I Give Up

It was a disaster.  blobToText()?  Grr.  At some point while fighting it I was even getting these red herring CORS errors that I can't reproduce now that I spent hours fighting.  All I know is if you see CORS errors don't bother with [EnableCors], just read the logs closely it's probably something else.

That was about six months ago.  It's taken me that long to calm down.

At the time I solved it by adding a hidden form tag, an ngNoForm, a target="_blank", and a bunch of hidden inputs.  I don't know how I slept at night.

But I was actually pretty close and with persistence found the path to enlightenment.

Less Complaining, More Solution

Ok, ok, I've dragged this on long enough.  On a good googlefu day I stumbled on the solution of telling Swashbuckle to map all instances of FileContentResult with "file" in startup.cs:

services.AddSwaggerGen(options =>
{
    options.MapType(() => new Schema
    {
        Type = "file"
    });

That generates this swagger file:

"/api/ProductFiles/{filename}.xlsx": {
    "post": {
        "tags": ["ProductFiles"],
            "operationId": "ApiProductFilesByFilename}.xlsxPost",
            "consumes": [],
            "produces": ["text/plain", "application/json", "text/json"],
            "parameters": [{
                "name": "fileName",
                "in": "path",
                "required": true,
                "type": "string"
            }],
            "responses": {
            "200": {
                "description": "Success",
                    "schema": {
                    "type": "file"
                }
            }
        }
    }
}

Type: file, yes of course.  Solved problems are always so simple.  NSwag turns it into this function:

productFiles(fileName: string): Observable<FileResponse> {

Which allows me to write this fancy little goodness:

public download() {
 const fileName = moment().format('YYYY-MM-DD');
 this.apiServiceProxy.productFiles(fileName)
.subscribe(fileResponse => {
 const a = document.createElement('a');
 a.href = URL.createObjectURL(fileResponse.data);
 a.download = fileName + '.xlsx';
 a.click();
});

}

So pretty, right?!  And it even works!!

What's better is if you add additional parameters like

public ActionResult Download(string fileName, [FromBody]ProductFileParamsDto paramsDto)

Then NSwag generates a ProductFileParamsDto and makes it a parameter.

Fantastic.  All the code is available in a nice tidy pull request for perusal.

Conclusion

I really think this issue is why the dinosaurs left.  But now hopefully, with some luck, you won't share their fate.