【ASP.NET Core】【TypeScript】File Download
Tags: aspnetcore, typescript, csharp
Published: 2021-01-16 15:35:48

## Intro
In this time, I try creating and downloading files in these conditions.

1. The appliction searchs book data.
2. It creates spreadsheets from the result.
3. It creates a spreadsheet per genres.
4. It will return as a spreadsheet file if it creates only one file.
5. It will return as a ZIP archive if it creates two or more files.
6. If it doesn't create any files, an alert will be shown in client-side.

### Environments
* .NET ver.5.0.101
* ClosedXML ver.0.95.4
* Newtonsoft.Json ver.12.0.3

#### package.json
```json
{
    "dependencies": {
        "ts-loader": "^8.0.14",
        "tsc": "^1.20150623.0",
        "typescript": "^4.1.3",
        "webpack": "^5.14.0",
        "webpack-cli": "^4.3.1"
    }
}
```

## Search book data
In this samples, I just create some sample data and search them.

#### Book.cs
```cs
using System;
namespace FileDownloadSample.Books.Models
{
    public record Book(int Id, string Name, Genre Genre, 
        DateTime? PublishDate, decimal? Price);
}
```
#### Genre.cs
```cs
namespace FileDownloadSample.Books.Models
{
    public record Genre(int Id, string Name);
}
```
#### IBooks.cs
```cs
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using FileDownloadSample.Books.Models;
namespace FileDownloadSample.Books
{
    public interface IBooks
    {
        Task<List<Book>> GetBooksAsync(string? genre,
            DateTime? publishDateFrom, DateTime? publishDateTo);
    }
}
```

#### Books.cs
```cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
using FileDownloadSample.Books.Models;

namespace FileDownloadSample.Books
{
    public class Books: IBooks
    {
        private readonly List<Book> allBooks;
        public Books()
        {
            allBooks = new List<Book>();
            SetBooks();
        }
        public async Task<List<Book>> GetBooksAsync(string? genre,
            DateTime? publishDateFrom, DateTime? publishDateTo)
        {
            // TODO: Implement
        }
        private void SetBooks()
        {
            var programming = new Genre(0, "Programming");
            var manga = new Genre(1, "Manga");
            var novels = new Genre(2, "Novels");
            allBooks.Add(new Book(0, "Programming C# 8.0", programming,
                DateTime.Parse("2019-11-26"), 6851m));
            allBooks.Add(new Book(1, "Programming C# 5.0", programming,
                DateTime.Parse("2012-11-13"), 2216m));
            allBooks.Add(new Book(2, "SHY", manga,
                DateTime.Parse("2019-12-6"), 237m));
        }
    }
}
```
### Dynamically change search criteria
If "genre" value isn't null or empty, I want to add it for search criteria.

I can add multiple where clauses.

#### Books.cs
```cs
...
    public class Books: IBooks
    {
...
        public async Task<List<Book>> GetBooksAsync(string? genre,
            DateTime? publishDateFrom, DateTime? publishDateTo)
        {
            if(string.IsNullOrEmpty(genre) &&
                publishDateFrom == null &&
                publishDateTo == null)
            {
                return allBooks;
            }
            var queryable = allBooks.AsQueryable();
            if(string.IsNullOrEmpty(genre) == false)
            {
                queryable = queryable.Where(b => b.Genre.Name == genre);
            }
            if(publishDateFrom != null)
            {
                queryable = queryable.Where(b => b.PublishDate != null &&
                    b.PublishDate >= publishDateFrom);
            }
            if(publishDateTo != null)
            {
                queryable = queryable.Where(b => b.PublishDate != null &&
                    b.PublishDate <= publishDateTo);
            }
            return await Task.FromResult(queryable.ToList());
        }
...
```

I also can use "Expressions".
#### Books.cs
```cs
...
    public class Books: IBooks
    {
...
        public async Task<List<Book>> GetBooksAsync(string? genre,
            DateTime? publishDateFrom, DateTime? publishDateTo)
        {
            if(string.IsNullOrEmpty(genre) &&
                publishDateFrom == null &&
                publishDateTo == null)
            {
                return allBooks;
            }
            return await Task.FromResult(
                allBooks.AsQueryable()
                .Where(GetWhereClauses(genre, publishDateFrom, publishDateTo))
                .ToList());
        }
        private Expression<Func<Book, bool>> GetWhereClauses(string? genre,
            DateTime? publishDateFrom, DateTime? publishDateTo)
        {
            // This parameters are required inside Lambda expreassions
            var param = Expression.Parameter(typeof(Book), "b");
            Expression? baseExpr = null;
            if(string.IsNullOrEmpty(genre) == false)
            {
                Expression<Func<Book, bool>> genreExpr = b => b.Genre.Name == genre;
                baseExpr = Expression.Invoke(genreExpr, param);
            }
            if(publishDateFrom != null)
            {
                Expression<Func<Book, bool>> dateFromExpr = b => b.PublishDate != null &&
                    b.PublishDate >= (DateTime)publishDateFrom;
                if(baseExpr == null)
                {
                    baseExpr = Expression.Invoke(dateFromExpr, param);
                }
                else
                {
                    baseExpr = Expression.AndAlso(baseExpr,
                        Expression.Invoke(dateFromExpr, param));
                }
            }
            if(publishDateTo != null)
            {
                Expression<Func<Book, bool>> dateToExpr = b => b.PublishDate != null &&
                    b.PublishDate <= (DateTime)publishDateTo;
                if(baseExpr == null)
                {
                    baseExpr = Expression.Invoke(dateToExpr, param);
                }
                else
                {
                    baseExpr = Expression.AndAlso(baseExpr, dateToExpr.Body);
                }
            }
            return Expression.Lambda<Func<Book, bool>>(baseExpr!, true, param);
        }
...
```

* [System.Linq.Expressions Namespace | Microsoft Docs](https://docs.microsoft.com/en-us/dotnet/api/system.linq.expressions?view=net-5.0)
* [Expression Class (System.Linq.Expressions) | Microsoft Docs](https://docs.microsoft.com/en-us/dotnet/api/system.linq.expressions.expression?view=net-5.0)
* [How do I combine LINQ expressions into one? - Stack Overflow](https://stackoverflow.com/questions/1922497/how-do-i-combine-linq-expressions-into-one)
* [c# - Combining two expressions (Expression<Func<T, bool>>) - Stack Overflow](https://stackoverflow.com/questions/457316/combining-two-expressions-expressionfunct-bool)

## Create spreadsheets and ZIP archives
From the search results, I want to create spreadsheets and ZIP archives.

### Create spreadsheets
#### DownloadFile.cs
```cs
namespace FileDownloadSample.Books
{
    public record DownloadFile(string Name, byte[] File, string ContentType);
}
```
#### ISpreadsheetGenerator.cs
```cs
using System.Collections.Generic;
using FileDownloadSample.Books.Models;
namespace FileDownloadSample.Books
{
    public interface ISpreadsheetGenerator
    {
        DownloadFile Generate(List<Book> books);
    }
}
```
#### SpreadsheetGenerator.cs
```cs
using System.IO.Compression;
using System.Text;
using System.IO;
using System.Linq;
using System.Collections.Generic;
using FileDownloadSample.Books.Models;
using ClosedXML.Excel;
using Newtonsoft.Json;
using FileDownloadSample.OperationResults;
using Newtonsoft.Json.Serialization;

namespace FileDownloadSample.Books
{
    public class SpreadsheetGenerator: ISpreadsheetGenerator
    {
        private record SavedFile(string Name, byte[] FileData);
        
        public DownloadFile Generate(List<Book> books)
        {
            List<SavedFile> files = CreateSpreadsheet(books);
            // TODO: create return values.
        }
        private List<SavedFile> CreateSpreadsheet(List<Book> books)
        {
            var results = new List<SavedFile>();
            // Separate by genre
            var genres = books.Select(b => b.Genre.Name)
                .Distinct()
                .ToArray();
            foreach(var genre in genres)
            {
                using(var book = new XLWorkbook())
                {
                    var sheet = book.Worksheets.Add(genre);
                    sheet.Cell(1, 1).Value = "Genre";
                    sheet.Cell(1, 2).Value = genre;

                    var titleId = sheet.Cell(3, 1);
                    var titleName = sheet.Cell(3, 2);
                    var titlePublishDate = sheet.Cell(3, 3);
                    var titlePrice = sheet.Cell(3, 4);
                    titleId.Value = "ID";
                    titleId.Style.Fill.BackgroundColor = XLColor.LightBlue;
                    titleName.Value = "Name";
                    titleName.Style.Fill.BackgroundColor = XLColor.LightBlue;
                    titlePublishDate.Value = "PublishDate";
                    titlePublishDate.Style.Fill.BackgroundColor = XLColor.LightBlue;
                    titlePrice.Value = "Price";
                    titlePrice.Style.Fill.BackgroundColor = XLColor.LightBlue;

                    var row = 4;
                    foreach(var b in books.Where(b => b.Genre.Name == genre))
                    {
                        sheet.Cell(row, 1).Value = b.Id;
                        sheet.Cell(row, 2).Value = b.Name;
                        sheet.Cell(row, 3).Value = b.PublishDate?.ToString("yyyy-MM-dd") ?? "";
                        sheet.Cell(row, 4).Value = b.Price?.ToString() ?? "";
                        row += 1;
                    }
                    byte[] savedFileData;
                    using(var stream = new MemoryStream())
                    {
                        book.SaveAs(stream);
                        savedFileData = stream.ToArray();
                    }
                    results.Add(new SavedFile(genre, savedFileData));
                }
            }
            return results;
        }
    }
}
```
### Create ZIP data
"ZipArchive" class can create ZIP archives.

#### SpreadsheetGenerator.cs
```cs
...
    public class SpreadsheetGenerator: ISpreadsheetGenerator
    {
        private record SavedFile(string Name, byte[] FileData);
        
        public DownloadFile Generate(List<Book> books)
        {
            List<SavedFile> files = CreateSpreadsheet(books);
            byte[] zipArchive = CreateZip(files);
            // TODO: create return values.
        }
...
        private byte[] CreateZip(List<SavedFile> files)
        {
            using(MemoryStream stream = new MemoryStream())
            {
                using(ZipArchive archive = new ZipArchive(stream, ZipArchiveMode.Create, true))
                {
                    foreach(SavedFile file in files)
                    {
                        ZipArchiveEntry entry = archive.CreateEntry($"{file.Name}.xlsx");
                        using(Stream entryStream = entry.Open())
                        {
                            entryStream.Write(file.FileData, 0, file.FileData.Length);
                        }
                    }
                    // Don't do them here
                    // stream.Seek(0, SeekOrigin.Begin);
                    // return stream.ToArray();
                }
                stream.Seek(0, SeekOrigin.Begin);
                return stream.ToArray();
            }
        }
    }
}
```
One important thing is I can't get the byte data of ZipArchive before disposing the ZipArchive instance. 
If I do that, I only can get a broken data because the entries haven't be saved yet.

* [ZipArchive Class (System.IO.Compression) | Microsoft Docs](https://docs.microsoft.com/en-us/dotnet/api/system.io.compression.ziparchive?view=net-5.0)

### Return file data
I could create spreadsheets and ZIP archives.

Now I try returning them.

#### SpreadsheetGenerator.cs
```cs
...
    public class SpreadsheetGenerator: ISpreadsheetGenerator
    {
...
        public DownloadFile Generate(List<Book> books)
        {
            var fileData = CreateSpreadsheet(books);
            if(fileData.Count <= 0)
            {
                // use lower camel case
                var serializerSettings = new JsonSerializerSettings();
                serializerSettings.ContractResolver = new CamelCasePropertyNamesContractResolver();
                var noItems = JsonConvert.SerializeObject(new SearchResult(true, "No items"), serializerSettings);
                return new DownloadFile("SearchResult", Encoding.UTF8.GetBytes(noItems), "application/json");
            }
            if(fileData.Count == 1)
            {
                return new DownloadFile(fileData[0].Name, fileData[0].FileData, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
            }
            return new DownloadFile("SearchResult", CreateZip(fileData), "application/zip");
        }
...
```
* [Common MIME types - HTTP | MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types)

#### IBookFileGenerator.cs
```cs
using System;
using System.Threading.Tasks;

namespace FileDownloadSample.Books
{
    public interface IBookFileGenerator
    {
        Task<DownloadFile> GetBookAsync(string? genre, 
            DateTime? publishDateFrom, DateTime? publishDateTo);
    }
}
```
#### BookFileGenerator.cs
```cs
using System;
using System.Threading.Tasks;

namespace FileDownloadSample.Books
{
    public class BookFileGenerator: IBookFileGenerator
    {
        private readonly IBooks books;
        private readonly ISpreadsheetGenerator spreadsheets;
        public BookFileGenerator(IBooks books,
            ISpreadsheetGenerator spreadsheets)
        {
            this.books = books;
            this.spreadsheets = spreadsheets;
        }
        public async Task<DownloadFile> GetBookAsync(string? genre, 
            DateTime? publishDateFrom, DateTime? publishDateTo)
        {
            return spreadsheets.Generate(
                await books.GetBooksAsync(genre, publishDateFrom, publishDateTo));
        }
    }
}
```
#### HomeController.cs
```cs
using System;
using System.Threading.Tasks;
using FileDownloadSample.Books;
using Microsoft.AspNetCore.Mvc;

namespace FileDownloadSample.Controllers
{
    public class HomeConroller: Controller
    {
        private readonly IBookFileGenerator books;
        public HomeConroller(IBookFileGenerator books)
        {
            this.books = books;
        }
        [Route("")]
        public IActionResult Index()
        {
            return View("Views/Index.cshtml");
        }
        [Route("Download")]
        public async Task<IActionResult> Download(string? genre, string? dateFrom, string? dateTo)
        {
            DateTime? publishDateFrom = ConvertToDateTime(dateFrom);
            DateTime? publishDateTo = ConvertToDateTime(dateTo);
            
            DownloadFile result = await books.GetBookAsync(genre, publishDateFrom, publishDateTo);
            return File(result.File, result.ContentType, result.Name);
        }
        private DateTime? ConvertToDateTime(string? dateText)
        {
            if(string.IsNullOrEmpty(dateText) == false &&
                DateTime.TryParse(dateText, out var d))
            {
                return d;
            }
            return null;
        }
    }
}
```
I also can create file data instead of "File(result.File, result.ContentType, result.Name)" by "FileContentResult".

```cs
return new FileContentResult(result.File, result.ContentType)
{
    FileDownloadName = result.Name
};
```
## Download files (client-side)
Because I must show an alert if I can't find any book data, I can't download result files directly.
So I use "fetch" to get result first.

#### Index.cshtml
```html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <title>FileDownloadSample</title>
</head>
<body>
    <input type="text" id="search_genre">
    <button onclick="Page.downloadFile()">Download</button>
    <a id="download_target" href="#"></a>
    <script src="js/mainPage.js"></script>
</body>
</html>
```
#### searchResult.ts
```ts
export type SearchResult = {
    failed: boolean,
    errorMessage: string
};
```
#### main.page.ts
```ts
import { SearchResult } from "./searchResult";

export async function downloadFile() {
    const genreElement = document.getElementById('search_genre') as HTMLInputElement;
    await downloadAsync(genreElement.value);
}
async function downloadAsync(genre: string|null): Promise<SearchResult>
{
    let result: SearchResult = {
        failed: true,
        errorMessage: 'No data',
    };
    await fetch(`Download?genre=${genre}`,
    {
        method: 'GET',
        mode: 'cors'
    })
    .then(response => {
        // TODO: implement
    })
    .catch(error => console.error(error));
    return result;
}
```
### Handle result
I can get Content-Type from the response headers.
So I can separate operations by it.

#### main.page.ts
```ts
...
async function downloadAsync(genre: string|null): Promise<SearchResult>
{
    let result: SearchResult = {
        failed: true,
        errorMessage: 'No data',
    };
    await fetch(`Download?genre=${genre}`,
    {
        method: 'GET',
        mode: 'cors'
    })
    .then(async response => await handleResponse(response))
    .catch(error => console.error(error));
    return result;
}
async function handleResponse(response: Response) {
    if(response.ok === false){
        alert(response.statusText);
        return;
    }
    switch(response.headers.get("Content-Type")){
        case 'application/json':
            await handleResultAsJson(response);
            break;
        case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
        case 'application/zip':
            await handleResultAsDownloadFile(response);
        default:
            alert('invalid data');
            break;
    }
}
async function handleResultAsJson(response: Response){
    const json = await response.json();
    const result = JSON.parse(JSON.stringify(json)) as SearchResult;
    if(result == null) {
        alert('No results');
        return;
    }
    if(result.failed === true){
        alert(result.errorMessage);
        return;
    }
}
async function handleResultAsDownloadFile(response: Response){
    const blob = await response.blob();
    const target = document.getElementById('download_target') as HTMLAnchorElement;
    target.href = window.URL.createObjectURL(blob);
    target.click();
}
```
### No file names
Although I can download files.
But I have still had a problem.

The files don't have their names.
![Alt Text](https://dev-to-uploads.s3.amazonaws.com/i/sdshpb1f99fsqga21dvj.jpg)

So I add a response header item and set the file name.

#### HomeController.cs
```cs
...
    public class HomeConroller: Controller
    {
...
        [Route("Download")]
        public async Task<IActionResult> Download(string? genre, string? dateFrom, string? dateTo)
        {
            var publishDateFrom = ConvertToDateTime(dateFrom);
            var publishDateTo = ConvertToDateTime(dateTo);
            
            var result = await books.GetBookAsync(genre, publishDateFrom, publishDateTo);
            Response.Headers["File-Name"] = result.Name;
            return File(result.File, result.ContentType, result.Name);
        }
...
```
#### main.page.ts
```ts
...
async function handleResultAsDownloadFile(response: Response){
    const blob = await response.blob();
    const target = document.getElementById('download_target') as HTMLAnchorElement;
    target.href = window.URL.createObjectURL(blob);

    const fileName = response.headers.get('File-Name');
    target.download = (fileName == null)? 'sample': fileName;
    target.click();
}
```