DEV Community

Masui Masanori
Masui Masanori

Posted on

2

【ASP.NET Core】【TypeScript】File Download

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

{
    "dependencies": {
        "ts-loader": "^8.0.14",
        "tsc": "^1.20150623.0",
        "typescript": "^4.1.3",
        "webpack": "^5.14.0",
        "webpack-cli": "^4.3.1"
    }
}
Enter fullscreen mode Exit fullscreen mode

Search book data

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

Book.cs

using System;
namespace FileDownloadSample.Books.Models
{
    public record Book(int Id, string Name, Genre Genre, 
        DateTime? PublishDate, decimal? Price);
}
Enter fullscreen mode Exit fullscreen mode

Genre.cs

namespace FileDownloadSample.Books.Models
{
    public record Genre(int Id, string Name);
}
Enter fullscreen mode Exit fullscreen mode

IBooks.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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Books.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));
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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

...
    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());
        }
...
Enter fullscreen mode Exit fullscreen mode

I also can use "Expressions".

Books.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);
        }
...
Enter fullscreen mode Exit fullscreen mode

Create spreadsheets and ZIP archives

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

Create spreadsheets

DownloadFile.cs

namespace FileDownloadSample.Books
{
    public record DownloadFile(string Name, byte[] File, string ContentType);
}
Enter fullscreen mode Exit fullscreen mode

ISpreadsheetGenerator.cs

using System.Collections.Generic;
using FileDownloadSample.Books.Models;
namespace FileDownloadSample.Books
{
    public interface ISpreadsheetGenerator
    {
        DownloadFile Generate(List<Book> books);
    }
}
Enter fullscreen mode Exit fullscreen mode

SpreadsheetGenerator.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;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Create ZIP data

"ZipArchive" class can create ZIP archives.

SpreadsheetGenerator.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();
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

Return file data

I could create spreadsheets and ZIP archives.

Now I try returning them.

SpreadsheetGenerator.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");
        }
...
Enter fullscreen mode Exit fullscreen mode

IBookFileGenerator.cs

using System;
using System.Threading.Tasks;

namespace FileDownloadSample.Books
{
    public interface IBookFileGenerator
    {
        Task<DownloadFile> GetBookAsync(string? genre, 
            DateTime? publishDateFrom, DateTime? publishDateTo);
    }
}
Enter fullscreen mode Exit fullscreen mode

BookFileGenerator.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));
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

HomeController.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;
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

I also can create file data instead of "File(result.File, result.ContentType, result.Name)" by "FileContentResult".

return new FileContentResult(result.File, result.ContentType)
{
    FileDownloadName = result.Name
};
Enter fullscreen mode Exit fullscreen mode

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

<!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>
Enter fullscreen mode Exit fullscreen mode

searchResult.ts

export type SearchResult = {
    failed: boolean,
    errorMessage: string
};
Enter fullscreen mode Exit fullscreen mode

main.page.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;
}
Enter fullscreen mode Exit fullscreen mode

Handle result

I can get Content-Type from the response headers.
So I can separate operations by it.

main.page.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();
}
Enter fullscreen mode Exit fullscreen mode

No file names

Although I can download files.
But I have still had a problem.

The files don't have their names.
Alt Text

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

HomeController.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);
        }
...
Enter fullscreen mode Exit fullscreen mode

main.page.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();
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)

Qodo Takeover

Introducing Qodo Gen 1.0: Transform Your Workflow with Agentic AI

Rather than just generating snippets, our agents understand your entire project context, can make decisions, use tools, and carry out tasks autonomously.

Read full post

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay