Search code examples
angularasp.net-core-webapi

Angular large file download from POST ASP.NET Core Web API endpoint using response stream on Response.Complete event body is null


We have an Angular App that allow user to download a list of image through their ids in a database. The ASP.NET Core Web API uses the response stream to create the image zip file and transfer it while appending image to the archive.

The problem is that for large archive files (my test is 2.5 Gb with around 2500 images), the response body (client side) is always null. No problem for smaller archive files.

Some relevant code:

First, on the client side, this is the trigger in the component:

modalInstance.componentInstance.onConfirmEvent.subscribe(res => {
      
        this.downloadInProgress = true;
        // workingList contains a list of image ids
        this.prepareDownloadByObject(workingList, res.createFileTree, res.createMetadata)
    })

In the download service, we use HttpClient to post an object which has a list of image ids, then check the download progress with handleDownloadProgress. Once the event type is Response, we use the body to create an object url and download the file (this method is everywhere in SO):

prepareDownloadByObject(workingList, createFileTree, createMetadata) {
    
   this.downloadByWorkingList(workingList, createFileTree, createMetadata).subscribe(
      res => this.handleDownloadProgress(res, workingList),
      async err => this.handleDownloadError(err)
    )
  }

downloadByWorkingList(workingListDto: IWorkingList,createTreeDirectory:boolean, createMetadata: boolean): Observable<any> {
    
   return this.http.post(`${this.baseUrl}WorkingList/DownloadWorkingList/${createTreeDirectory}/${createMetadata}`,workingListDto,
    {
      observe: 'events',
      responseType: "arraybuffer",
      reportProgress: true
    });
  }

handleDownloadProgress(event, workingList?: IWorkingList) {
   
   if (event.type === HttpEventType.DownloadProgress) {
      //[... UI update ...]
   } else if (event.type === HttpEventType.Response) {
      this.downloadFile(event, workingList);
      //[... UI update ...]
   } else {
      console.log("Unknown event type", event);
   }

downloadFile(data: any, workingList?: IWorkingList) {
    // true
    if (data.body == null) {
      return;
    }

    const downloadedFile = new Blob([data.body], { type: data.body.type });
    const a = document.createElement("a");
    a.setAttribute("style", "display:none;");
    document.body.appendChild(a);    
    a.download = "workingList" +"_"+ this.getFileDateFormat() + ".zip";
    a.href = URL.createObjectURL(downloadedFile);
    a.target = "_blank";
    a.click();
    document.body.removeChild(a);
}

API side - controller:

[HttpPost("{createTreeDirectory}/{createMetadata}")]
public async Task<IActionResult> DownloadWorkingList(WorkingListDto workingListDto, bool createTreeDirectory, bool createMetadata, CancellationToken token)
{
    try
    {
        if (workingListDto.ImageIds == null || workingListDto.ImageIds.Count == 0)
        {
            return BadRequest("You have to select images to download");
        }

        Response.ContentType = "application/octet-stream";
        Response.Headers.Append("Content-Disposition", "attachment; filename=workingList.zip");
        await Response.StartAsync(token);
                
        await this._workingListAppService.DownloadWorkingList(workingListDto, createTreeDirectory, createMetadata, token, Response.BodyWriter.AsStream()).ConfigureAwait(false);
               
        await Response.CompleteAsync();

        return new EmptyResult();
    }
    catch (ArgumentException ex)
    {
        return this.BadRequest(new { ex.Message, ex.StackTrace });
    }
    catch (Exception ex)
    {
        return this.Problem(ex.Message, "WorkingList/DownloadAndSaveWorkingList", 500);
    }
}

WorkingListAppService gathers the images filepath (on an Azure fileshare) and transfers it to a specific service. This service will get the images byte content, write it as an entry into a zipArchive which is build directly into the response stream. This method is used to avoid writing the file on disk or load it fully in memory:

public async Task<Stream> CreateImageZipFileStream(List<Image> image, string user, CancellationToken token, Stream stream, bool createTreeDirectory = true, bool createMetadataFile = false)
{
    // In this case, stream is Response.BodyWriter.AsStream()
    using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, true))
    {
        foreach (var image in images)
        {
            await AddImageToArchive(archive, createTreeDirectory, createMetadataFile, image, token);
        }
    }

    // Irrelevant here
    return stream;
}

private async Task AddImageToArchive(ZipArchive archive, bool createTreeDirectory, bool createMetadata, Image imageItem, CancellationToken token)
{
    string entryName = imageItem.UserFriendlyName + Constantes.IMAGE_EXTENSION;

    if (createTreeDirectory)
    {
        // Change the entryName to add a subfolder in the archive
        [...]
    }
    
    var entry = archive.CreateEntry(entryName, CompressionLevel.NoCompression);

    using (var entryStream = entry.Open())
    {
        try
        {
            // This methods get the file bytes from an azure fileshare and write it to entryStream
            await this._imageFileShareClient.GetFileContent(imageMaterialFolder + "/" + imageItem.ApplicationCamera.SiteRef.SiteName, imageItem.FilePath, entryStream);
        }
        catch
        {
            try
            {
                entryStream.Position = 0;
                // This methods get a fallback file bytes from an azure fileshare and write it to entryStream
                await this._imageFileShareClient.GetFileContent(fisheyedImageFolder + "/" + imageItem.ApplicationCamera.SiteRef.SiteName, imageItem.FilePath, entryStream);
            }
            catch
            {
                this._logger.LogError("An image requested for download was missing from fileshare. ImageId: {0}", imageItem.Id);
                // Do not throw on error, Ignore missing image and continue
            }
        }
    }
}

Some more information: the request is completely done, data are transmitted to client: In the network tab of the console the request size is the size of the file.

We tried arraybuffer, blob request responseType with the same result.

We tried application/zip, application/octet-stream as sesponse Content-Type.

The library file saver got the same problem, event body being null.

Reminder of the problem

When the API reaches Response.CompleteAsync(), the client gets the event typed Response. So the Angular app tries to access the body of the response which is null. This is the main question.

Another subsidiary question which could work around the problem: is there a way to avoid loading the entire file in memory client side?


Solution

  • Thanks to @browsermator indications I managed to make the navigator handle the download.

    The main problem was authentication, which I bypassed with a token to allow the user on next download.

    For the POST, I generate a form like this:

    <form ngNoForm action="{{baseUrl + 'WorkingList/DownloadWorkingList/' +createFileTree + '/'+ createMetadata + '/' + token}}" method="post">
      <input *ngFor="let imageId of workingList.imageIds; let index = index" type="hidden" name="imageIds[]" [(ngModel)]="workingList.imageIds[index]"/>
      <button type="submit" class="btn btn-primary" rippleEffect>
        {{ "DOWNLOAD_MODAL.CONFIRM" | translate }}
      </button>
    </form>
    

    For the GET (not part of the question), I generate a URL the same way as the Form submit url, just adding the workingList Id as parameter.

    Server side I added a check on the token added in the query url, and added [FromForm] on the object parameter.

    This allow the navigator to handle the response as a file download and let him handle resources.

    This is probably not the best (authentication is by passed, and user auth has to be handled manually) but it solved my problem and we can deal with this authorization method.

    PS: I did not use the File() method in the controller because it close the stream too soon for the archive to be build