Thursday, July 25, 2019

Fighting File Downloads and Dinosaurs with NSwag (via ASP.Net Boilerplate)

Technically it was the dinosaurs, approximately 240 million years ago, that first solved downloading files from web servers.  So doing it with a modern tech stack with an auto-generated client-side proxy should be easy, right?

Embed from Getty Images

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.  And sometimes, when life is hard, you give up and write something godawful to teach life a lesson.  Make it take the lemons back.

This week, I won round 2 by solving the problem correctly.  Pure joy, I'm tellin' ya.  I just had to share.

Fellow humanoids: prepare to rejoice.

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 ASAP.Net Boilerplate (also check out this amazing ASP.Net Boilerplate Overview, then subscribe, the guy who produced it must be a genius).  But whatever, you should totally use that stack anyway because those four technologies were preordained by the gods as a path to eternal bliss.  That's a fact, the Buddha said it, go look it up.

Also, the API client proxy that NSwag generates is totes amazing -- 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 nuggetting (a real word, apparently, but not what you think) 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'.

Gah!  And what about specifying 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?!

NOOOOOOOOOOOOOOOOOO
😡😡😡😡😡😡😡😡😡😡😡😡😡😡😡😡😡😡😡😡😡😡😡😡😡
OOOOOOOOOOOOOOOOOO!

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.  To everyone I've interacted with since, I do apologize for the perpetual yelling.

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.  Which NSwag turns into this function:
productFiles(fileName: string): Observable<FileResponse> {

Which allows me to write this fancy little thang:
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 even awesomer 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.

Fantabulous.  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.

1 comment:

Manuel Grundner said...

Those are the problems I deal all the time. Development is figuring out the edge cases no body thought about!
Great post, goes on my consulting tool belt :)