From ea424f56063a940ed5d46914e029eb45156e880a Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Wed, 2 Jul 2025 16:31:31 +0200 Subject: [PATCH 1/6] Stream directly to output --- .../WebApi/LicensingController.cs | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/Particular.LicensingComponent/WebApi/LicensingController.cs b/src/Particular.LicensingComponent/WebApi/LicensingController.cs index 64119e1d7c..24ed9828dd 100644 --- a/src/Particular.LicensingComponent/WebApi/LicensingController.cs +++ b/src/Particular.LicensingComponent/WebApi/LicensingController.cs @@ -4,7 +4,9 @@ using System.Text.Json; using System.Threading; using Contracts; + using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; + using Microsoft.Net.Http.Headers; using Particular.LicensingComponent.Report; [ApiController] @@ -40,12 +42,14 @@ public async Task CanThroughputReportBeGenerated(Cancella [Route("report/file")] [HttpGet] - public async Task GetThroughputReportFile([FromQuery(Name = "spVersion")] string? spVersion, CancellationToken cancellationToken) + public async Task GetThroughputReportFile([FromQuery(Name = "spVersion")] string? spVersion, CancellationToken cancellationToken) { var reportStatus = await CanThroughputReportBeGenerated(cancellationToken); if (!reportStatus.ReportCanBeGenerated) { - return BadRequest($"Report cannot be generated - {reportStatus.Reason}"); + HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest; + await HttpContext.Response.WriteAsync($"Report cannot be generated – {reportStatus.Reason}", cancellationToken); + return; } var report = await throughputCollector.GenerateThroughputReport( @@ -55,16 +59,16 @@ public async Task GetThroughputReportFile([FromQuery(Name = "spVe var fileName = $"{report.ReportData.CustomerName}.throughput-report-{report.ReportData.EndTime:yyyyMMdd-HHmmss}"; - using var memoryStream = new MemoryStream(); - using (var archive = new ZipArchive(memoryStream, ZipArchiveMode.Create, true)) + HttpContext.Response.ContentType = "application/zip"; + HttpContext.Response.Headers[HeaderNames.ContentDisposition] = new ContentDispositionHeaderValue("attachment") { - var entry = archive.CreateEntry($"{fileName}.json"); - await using var entryStream = entry.Open(); - await JsonSerializer.SerializeAsync(entryStream, report, SerializationOptions.IndentedWithNoEscaping, cancellationToken); - } + FileName = fileName + }.ToString(); - memoryStream.Position = 0; - return File(memoryStream, "application/zip", fileDownloadName: $"{fileName}.zip"); + using var archive = new ZipArchive(HttpContext.Response.Body, ZipArchiveMode.Create, leaveOpen: true); + var entry = archive.CreateEntry($"{Path.GetFileNameWithoutExtension(fileName)}.json"); + await using var entryStream = entry.Open(); + await JsonSerializer.SerializeAsync(entryStream, report, SerializationOptions.IndentedWithNoEscaping, cancellationToken); } [Route("settings/info")] From db8c13f243439394fad973686445edbffcd0fc56 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Thu, 3 Jul 2025 11:01:24 +0200 Subject: [PATCH 2/6] Use the body writer instead --- src/Particular.LicensingComponent/WebApi/LicensingController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Particular.LicensingComponent/WebApi/LicensingController.cs b/src/Particular.LicensingComponent/WebApi/LicensingController.cs index 24ed9828dd..bccce69681 100644 --- a/src/Particular.LicensingComponent/WebApi/LicensingController.cs +++ b/src/Particular.LicensingComponent/WebApi/LicensingController.cs @@ -65,7 +65,7 @@ public async Task GetThroughputReportFile([FromQuery(Name = "spVersion")] string FileName = fileName }.ToString(); - using var archive = new ZipArchive(HttpContext.Response.Body, ZipArchiveMode.Create, leaveOpen: true); + using var archive = new ZipArchive(Response.BodyWriter.AsStream(), ZipArchiveMode.Create, leaveOpen: true); var entry = archive.CreateEntry($"{Path.GetFileNameWithoutExtension(fileName)}.json"); await using var entryStream = entry.Open(); await JsonSerializer.SerializeAsync(entryStream, report, SerializationOptions.IndentedWithNoEscaping, cancellationToken); From 5466f10f61813e321dd008e6c8b429371a3c264c Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Thu, 3 Jul 2025 11:26:16 +0200 Subject: [PATCH 3/6] Proper filename --- src/Particular.LicensingComponent/WebApi/LicensingController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Particular.LicensingComponent/WebApi/LicensingController.cs b/src/Particular.LicensingComponent/WebApi/LicensingController.cs index bccce69681..073c76604b 100644 --- a/src/Particular.LicensingComponent/WebApi/LicensingController.cs +++ b/src/Particular.LicensingComponent/WebApi/LicensingController.cs @@ -62,7 +62,7 @@ public async Task GetThroughputReportFile([FromQuery(Name = "spVersion")] string HttpContext.Response.ContentType = "application/zip"; HttpContext.Response.Headers[HeaderNames.ContentDisposition] = new ContentDispositionHeaderValue("attachment") { - FileName = fileName + FileName = $"{fileName}.zip" }.ToString(); using var archive = new ZipArchive(Response.BodyWriter.AsStream(), ZipArchiveMode.Create, leaveOpen: true); From c26fe1e77d14b51adfb6b2469aaa10b284aa8f4d Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Thu, 3 Jul 2025 11:41:05 +0200 Subject: [PATCH 4/6] Inner filename should also be correct --- src/Particular.LicensingComponent/WebApi/LicensingController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Particular.LicensingComponent/WebApi/LicensingController.cs b/src/Particular.LicensingComponent/WebApi/LicensingController.cs index 073c76604b..86fb954b48 100644 --- a/src/Particular.LicensingComponent/WebApi/LicensingController.cs +++ b/src/Particular.LicensingComponent/WebApi/LicensingController.cs @@ -66,7 +66,7 @@ public async Task GetThroughputReportFile([FromQuery(Name = "spVersion")] string }.ToString(); using var archive = new ZipArchive(Response.BodyWriter.AsStream(), ZipArchiveMode.Create, leaveOpen: true); - var entry = archive.CreateEntry($"{Path.GetFileNameWithoutExtension(fileName)}.json"); + var entry = archive.CreateEntry($"{fileName}.json"); await using var entryStream = entry.Open(); await JsonSerializer.SerializeAsync(entryStream, report, SerializationOptions.IndentedWithNoEscaping, cancellationToken); } From 8f0ca6586a2a5bf20bbb3ef6221593fa0e92336f Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Thu, 3 Jul 2025 13:17:20 +0200 Subject: [PATCH 5/6] Proper bad request encoding --- .../WebApi/LicensingController.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Particular.LicensingComponent/WebApi/LicensingController.cs b/src/Particular.LicensingComponent/WebApi/LicensingController.cs index 86fb954b48..b4fc391562 100644 --- a/src/Particular.LicensingComponent/WebApi/LicensingController.cs +++ b/src/Particular.LicensingComponent/WebApi/LicensingController.cs @@ -1,6 +1,7 @@ namespace Particular.LicensingComponent.WebApi { using System.IO.Compression; + using System.Text; using System.Text.Json; using System.Threading; using Contracts; @@ -48,7 +49,9 @@ public async Task GetThroughputReportFile([FromQuery(Name = "spVersion")] string if (!reportStatus.ReportCanBeGenerated) { HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest; - await HttpContext.Response.WriteAsync($"Report cannot be generated – {reportStatus.Reason}", cancellationToken); + HttpContext.Response.ContentType = "text/plain; charset=utf-8"; + + await HttpContext.Response.WriteAsync($"Report cannot be generated – {reportStatus.Reason}", Encoding.UTF8, cancellationToken); return; } From ea3745b55b5f4bc09256759f1020301ffd53e3b9 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Thu, 3 Jul 2025 13:17:44 +0200 Subject: [PATCH 6/6] Explanation with a comment --- .../WebApi/LicensingController.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Particular.LicensingComponent/WebApi/LicensingController.cs b/src/Particular.LicensingComponent/WebApi/LicensingController.cs index b4fc391562..0b094fbb94 100644 --- a/src/Particular.LicensingComponent/WebApi/LicensingController.cs +++ b/src/Particular.LicensingComponent/WebApi/LicensingController.cs @@ -68,6 +68,9 @@ public async Task GetThroughputReportFile([FromQuery(Name = "spVersion")] string FileName = $"{fileName}.zip" }.ToString(); + // The zip archive is written directly to the response body stream and has to remain open until the response is fully sent. + // This is done for performance reasons to avoid buffering the entire report in memory before sending it. + // The BodyWriter is used as a stream to avoid into synchronous IO operations that would be prevented by the ASP.NET Core pipeline. using var archive = new ZipArchive(Response.BodyWriter.AsStream(), ZipArchiveMode.Create, leaveOpen: true); var entry = archive.CreateEntry($"{fileName}.json"); await using var entryStream = entry.Open();