Skip to content

Commit a1440b9

Browse files
committed
Implement the Mvc PushFileStreamResult API
Fixes #39383
1 parent fdd6d10 commit a1440b9

File tree

7 files changed

+412
-0
lines changed

7 files changed

+412
-0
lines changed

src/Mvc/Mvc.Core/src/ControllerBase.cs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1705,6 +1705,65 @@ public virtual PhysicalFileResult PhysicalFile(string physicalPath, string conte
17051705
EnableRangeProcessing = enableRangeProcessing,
17061706
};
17071707
}
1708+
1709+
/// <summary>
1710+
/// Writes the file directly to the response body with the specified <paramref name="streamWriterCallback" /> (<see cref="StatusCodes.Status200OK"/>), and the
1711+
/// specified <paramref name="contentType" /> as the Content-Type.
1712+
/// </summary>
1713+
/// <param name="streamWriterCallback">The callback that allows users to write directly to the response body.</param>
1714+
/// <param name="contentType">The Content-Type of the file.</param>
1715+
/// <returns>The created <see cref="PushFileStreamResult"/> for the response.</returns>
1716+
[NonAction]
1717+
public virtual PushFileStreamResult File(Func<Stream, Task> streamWriterCallback, string contentType)
1718+
=> File(streamWriterCallback, contentType, fileDownloadName: null);
1719+
1720+
/// <summary>
1721+
/// Writes the file directly to the response body with the specified <paramref name="streamWriterCallback" /> (<see cref="StatusCodes.Status200OK"/>), the
1722+
/// specified <paramref name="contentType" /> as the Content-Type, and the specified <paramref name="fileDownloadName" /> as the suggested file name.
1723+
/// </summary>
1724+
/// <param name="streamWriterCallback">The callback that allows users to write directly to the response body.</param>
1725+
/// <param name="contentType">The Content-Type of the file.</param>
1726+
/// <param name="fileDownloadName">The suggested file name.</param>
1727+
/// <returns>The created <see cref="PushFileStreamResult"/> for the response.</returns>
1728+
[NonAction]
1729+
public virtual PushFileStreamResult File(Func<Stream, Task> streamWriterCallback, string contentType, string? fileDownloadName)
1730+
=> new PushFileStreamResult(streamWriterCallback, contentType) { FileDownloadName = fileDownloadName };
1731+
1732+
/// <summary>
1733+
/// Writes the file directly to the response body with the specified <paramref name="streamWriterCallback" /> (<see cref="StatusCodes.Status200OK"/>), and the
1734+
/// specified <paramref name="contentType" /> as the Content-Type.
1735+
/// </summary>
1736+
/// <param name="streamWriterCallback">The callback that allows users to write directly to the response body.</param>
1737+
/// <param name="contentType">The Content-Type of the file.</param>
1738+
/// <param name="lastModified">The <see cref="DateTimeOffset"/> of when the file was last modified.</param>
1739+
/// <param name="entityTag">The <see cref="EntityTagHeaderValue"/> associated with the file.</param>
1740+
/// <returns>The created <see cref="PushFileStreamResult"/> for the response.</returns>
1741+
[NonAction]
1742+
public virtual PushFileStreamResult File(Func<Stream, Task> streamWriterCallback, string contentType, DateTimeOffset? lastModified, EntityTagHeaderValue entityTag)
1743+
=> new PushFileStreamResult(streamWriterCallback, contentType)
1744+
{
1745+
LastModified = lastModified,
1746+
EntityTag = entityTag,
1747+
};
1748+
1749+
/// <summary>
1750+
/// Writes the file directly to the response body with the specified <paramref name="streamWriterCallback" /> (<see cref="StatusCodes.Status200OK"/>), the
1751+
/// specified <paramref name="contentType" /> as the Content-Type, and the specified <paramref name="fileDownloadName" /> as the suggested file name.
1752+
/// </summary>
1753+
/// <param name="streamWriterCallback">The callback that allows users to write directly to the response body.</param>
1754+
/// <param name="contentType">The Content-Type of the file.</param>
1755+
/// <param name="fileDownloadName">The suggested file name.</param>
1756+
/// <param name="lastModified">The <see cref="DateTimeOffset"/> of when the file was last modified.</param>
1757+
/// <param name="entityTag">The <see cref="EntityTagHeaderValue"/> associated with the file.</param>
1758+
/// <returns>The created <see cref="PushFileStreamResult"/> for the response.</returns>
1759+
[NonAction]
1760+
public virtual PushFileStreamResult File(Func<Stream, Task> streamWriterCallback, string contentType, string? fileDownloadName, DateTimeOffset? lastModified, EntityTagHeaderValue entityTag)
1761+
=> new PushFileStreamResult(streamWriterCallback, contentType)
1762+
{
1763+
LastModified = lastModified,
1764+
EntityTag = entityTag,
1765+
FileDownloadName = fileDownloadName,
1766+
};
17081767
#endregion
17091768

17101769
/// <summary>

src/Mvc/Mvc.Core/src/DependencyInjection/MvcCoreServiceCollectionExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@ internal static void AddMvcCoreServices(IServiceCollection services)
236236
services.TryAddSingleton<IActionResultExecutor<PhysicalFileResult>, PhysicalFileResultExecutor>();
237237
services.TryAddSingleton<IActionResultExecutor<VirtualFileResult>, VirtualFileResultExecutor>();
238238
services.TryAddSingleton<IActionResultExecutor<FileStreamResult>, FileStreamResultExecutor>();
239+
services.TryAddSingleton<IActionResultExecutor<PushFileStreamResult>, PushFileStreamResultExecutor>();
239240
services.TryAddSingleton<IActionResultExecutor<FileContentResult>, FileContentResultExecutor>();
240241
services.TryAddSingleton<IActionResultExecutor<RedirectResult>, RedirectResultExecutor>();
241242
services.TryAddSingleton<IActionResultExecutor<LocalRedirectResult>, LocalRedirectResultExecutor>();
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Diagnostics;
5+
using Microsoft.Extensions.Logging;
6+
using Microsoft.Net.Http.Headers;
7+
8+
namespace Microsoft.AspNetCore.Mvc.Infrastructure;
9+
10+
/// <summary>
11+
/// A <see cref="IActionResultExecutor{PushFileStreamResult}"/> for <see cref="PushFileStreamResult"/>.
12+
/// </summary>
13+
public partial class PushFileStreamResultExecutor : FileResultExecutorBase, IActionResultExecutor<PushFileStreamResult>
14+
{
15+
/// <summary>
16+
/// Initializes a new <see cref="PushFileStreamResultExecutor"/>.
17+
/// </summary>
18+
/// <param name="loggerFactory">The factory used to create loggers.</param>
19+
public PushFileStreamResultExecutor(ILoggerFactory loggerFactory)
20+
: base(CreateLogger<PushFileStreamResultExecutor>(loggerFactory))
21+
{
22+
}
23+
24+
/// <inheritdoc />
25+
public virtual async Task ExecuteAsync(ActionContext context, PushFileStreamResult result)
26+
{
27+
ArgumentNullException.ThrowIfNull(context);
28+
ArgumentNullException.ThrowIfNull(result);
29+
30+
Log.ExecutingFileResult(Logger, result);
31+
32+
var (range, rangeLength, serveBody) = SetHeadersAndLog(
33+
context,
34+
result,
35+
fileLength: null,
36+
result.EnableRangeProcessing,
37+
result.LastModified,
38+
result.EntityTag);
39+
40+
if (!serveBody)
41+
{
42+
return;
43+
}
44+
45+
await WriteFileAsync(context, result, range, rangeLength);
46+
}
47+
48+
/// <summary>
49+
/// Write the contents of the PushFileStreamResult to the response body.
50+
/// </summary>
51+
/// <param name="context">The <see cref="ActionContext"/>.</param>
52+
/// <param name="result">The PushFileStreamResult to write.</param>
53+
/// <param name="range">The <see cref="RangeItemHeaderValue"/>.</param>
54+
/// <param name="rangeLength">The range length.</param>
55+
protected virtual async Task WriteFileAsync(
56+
ActionContext context,
57+
PushFileStreamResult result,
58+
RangeItemHeaderValue? range,
59+
long rangeLength)
60+
{
61+
ArgumentNullException.ThrowIfNull(context);
62+
ArgumentNullException.ThrowIfNull(result);
63+
64+
Debug.Assert(range == null);
65+
Debug.Assert(rangeLength == 0);
66+
67+
await result.StreamWriterCallback(context.HttpContext.Response.Body);
68+
}
69+
70+
private static partial class Log
71+
{
72+
public static void ExecutingFileResult(ILogger logger, FileResult fileResult)
73+
{
74+
if (logger.IsEnabled(LogLevel.Information))
75+
{
76+
var fileResultType = fileResult.GetType().Name;
77+
ExecutingFileResultWithNoFileName(logger, fileResultType, fileResult.FileDownloadName);
78+
}
79+
}
80+
81+
[LoggerMessage(1, LogLevel.Information, "Executing {FileResultType}, sending file with download name '{FileDownloadName}' ...", EventName = "ExecutingFileResultWithNoFileName", SkipEnabledCheck = true)]
82+
private static partial void ExecutingFileResultWithNoFileName(ILogger logger, string fileResultType, string fileDownloadName);
83+
}
84+
}

src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
11
#nullable enable
22
*REMOVED*virtual Microsoft.AspNetCore.Mvc.ControllerBase.Problem(string? detail = null, string? instance = null, int? statusCode = null, string? title = null, string? type = null) -> Microsoft.AspNetCore.Mvc.ObjectResult!
33
*REMOVED*virtual Microsoft.AspNetCore.Mvc.ControllerBase.ValidationProblem(string? detail = null, string? instance = null, int? statusCode = null, string? title = null, string? type = null, Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary? modelStateDictionary = null) -> Microsoft.AspNetCore.Mvc.ActionResult!
4+
Microsoft.AspNetCore.Mvc.Infrastructure.PushFileStreamResultExecutor
5+
Microsoft.AspNetCore.Mvc.Infrastructure.PushFileStreamResultExecutor.PushFileStreamResultExecutor(Microsoft.Extensions.Logging.ILoggerFactory! loggerFactory) -> void
6+
Microsoft.AspNetCore.Mvc.PushFileStreamResult
7+
Microsoft.AspNetCore.Mvc.PushFileStreamResult.PushFileStreamResult(System.Func<System.IO.Stream!, System.Threading.Tasks.Task!>! streamWriterCallback, string! contentType) -> void
8+
Microsoft.AspNetCore.Mvc.PushFileStreamResult.StreamWriterCallback.get -> System.Func<System.IO.Stream!, System.Threading.Tasks.Task!>!
9+
Microsoft.AspNetCore.Mvc.PushFileStreamResult.StreamWriterCallback.set -> void
10+
override Microsoft.AspNetCore.Mvc.PushFileStreamResult.ExecuteResultAsync(Microsoft.AspNetCore.Mvc.ActionContext! context) -> System.Threading.Tasks.Task!
11+
virtual Microsoft.AspNetCore.Mvc.ControllerBase.File(System.Func<System.IO.Stream!, System.Threading.Tasks.Task!>! streamWriterCallback, string! contentType) -> Microsoft.AspNetCore.Mvc.PushFileStreamResult!
12+
virtual Microsoft.AspNetCore.Mvc.ControllerBase.File(System.Func<System.IO.Stream!, System.Threading.Tasks.Task!>! streamWriterCallback, string! contentType, string? fileDownloadName) -> Microsoft.AspNetCore.Mvc.PushFileStreamResult!
13+
virtual Microsoft.AspNetCore.Mvc.ControllerBase.File(System.Func<System.IO.Stream!, System.Threading.Tasks.Task!>! streamWriterCallback, string! contentType, string? fileDownloadName, System.DateTimeOffset? lastModified, Microsoft.Net.Http.Headers.EntityTagHeaderValue! entityTag) -> Microsoft.AspNetCore.Mvc.PushFileStreamResult!
14+
virtual Microsoft.AspNetCore.Mvc.ControllerBase.File(System.Func<System.IO.Stream!, System.Threading.Tasks.Task!>! streamWriterCallback, string! contentType, System.DateTimeOffset? lastModified, Microsoft.Net.Http.Headers.EntityTagHeaderValue! entityTag) -> Microsoft.AspNetCore.Mvc.PushFileStreamResult!
415
virtual Microsoft.AspNetCore.Mvc.ControllerBase.Problem(string? detail, string? instance, int? statusCode, string? title, string? type) -> Microsoft.AspNetCore.Mvc.ObjectResult!
516
virtual Microsoft.AspNetCore.Mvc.ControllerBase.Problem(string? detail = null, string? instance = null, int? statusCode = null, string? title = null, string? type = null, System.Collections.Generic.IDictionary<string!, object?>? extensions = null) -> Microsoft.AspNetCore.Mvc.ObjectResult!
617
virtual Microsoft.AspNetCore.Mvc.ControllerBase.ValidationProblem(string? detail, string? instance, int? statusCode, string? title, string? type, Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary? modelStateDictionary) -> Microsoft.AspNetCore.Mvc.ActionResult!
@@ -9,3 +20,5 @@ Microsoft.AspNetCore.Mvc.Infrastructure.DefaultProblemDetailsFactory
920
Microsoft.AspNetCore.Mvc.Infrastructure.DefaultProblemDetailsFactory.DefaultProblemDetailsFactory(Microsoft.Extensions.Options.IOptions<Microsoft.AspNetCore.Mvc.ApiBehaviorOptions!>! options, Microsoft.Extensions.Options.IOptions<Microsoft.AspNetCore.Http.ProblemDetailsOptions!>? problemDetailsOptions = null) -> void
1021
override Microsoft.AspNetCore.Mvc.Infrastructure.DefaultProblemDetailsFactory.CreateProblemDetails(Microsoft.AspNetCore.Http.HttpContext! httpContext, int? statusCode = null, string? title = null, string? type = null, string? detail = null, string? instance = null) -> Microsoft.AspNetCore.Mvc.ProblemDetails!
1122
override Microsoft.AspNetCore.Mvc.Infrastructure.DefaultProblemDetailsFactory.CreateValidationProblemDetails(Microsoft.AspNetCore.Http.HttpContext! httpContext, Microsoft.AspNetCore.Mvc.ModelBinding.ModelStateDictionary! modelStateDictionary, int? statusCode = null, string? title = null, string? type = null, string? detail = null, string? instance = null) -> Microsoft.AspNetCore.Mvc.ValidationProblemDetails!
23+
virtual Microsoft.AspNetCore.Mvc.Infrastructure.PushFileStreamResultExecutor.ExecuteAsync(Microsoft.AspNetCore.Mvc.ActionContext! context, Microsoft.AspNetCore.Mvc.PushFileStreamResult! result) -> System.Threading.Tasks.Task!
24+
virtual Microsoft.AspNetCore.Mvc.Infrastructure.PushFileStreamResultExecutor.WriteFileAsync(Microsoft.AspNetCore.Mvc.ActionContext! context, Microsoft.AspNetCore.Mvc.PushFileStreamResult! result, Microsoft.Net.Http.Headers.RangeItemHeaderValue? range, long rangeLength) -> System.Threading.Tasks.Task!
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Microsoft.AspNetCore.Mvc.Infrastructure;
5+
using Microsoft.Extensions.DependencyInjection;
6+
7+
namespace Microsoft.AspNetCore.Mvc;
8+
9+
/// <summary>
10+
/// A <see cref="FileResult" /> that on execution writes the file using the specified stream writer callback.
11+
/// </summary>
12+
public class PushFileStreamResult : FileResult
13+
{
14+
/// <summary>
15+
/// The callback that writes the file to the provided stream.
16+
/// </summary>
17+
public Func<Stream, Task> StreamWriterCallback { get; set; }
18+
19+
/// <summary>
20+
/// Creates a new <see cref="PushFileStreamResult"/> instance with
21+
/// the provided <paramref name="streamWriterCallback"/> and the provided <paramref name="contentType"/>.
22+
/// </summary>
23+
/// <param name="streamWriterCallback">The callback that writes the file to the provided stream.</param>
24+
/// <param name="contentType">The Content-Type header of the response.</param>
25+
public PushFileStreamResult(Func<Stream, Task> streamWriterCallback, string contentType) : base(contentType)
26+
{
27+
ArgumentNullException.ThrowIfNull(streamWriterCallback);
28+
29+
StreamWriterCallback = streamWriterCallback;
30+
}
31+
32+
/// <inheritdoc />
33+
public override Task ExecuteResultAsync(ActionContext context)
34+
{
35+
ArgumentNullException.ThrowIfNull(context);
36+
37+
var executor = context.HttpContext.RequestServices.GetRequiredService<IActionResultExecutor<PushFileStreamResult>>();
38+
return executor.ExecuteAsync(context, this);
39+
}
40+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Microsoft.AspNetCore.Http;
5+
using Microsoft.AspNetCore.Internal;
6+
using Microsoft.AspNetCore.Mvc.Infrastructure;
7+
using Microsoft.Extensions.DependencyInjection;
8+
using Microsoft.Extensions.Logging;
9+
using Microsoft.Extensions.Logging.Abstractions;
10+
using Microsoft.Net.Http.Headers;
11+
12+
namespace Microsoft.AspNetCore.Mvc;
13+
14+
public class PushFileStreamResultTest : PushFileStreamResultTestBase
15+
{
16+
protected override Task ExecuteAsync(
17+
HttpContext httpContext,
18+
Func<Stream, Task> streamWriterCallback,
19+
string contentType,
20+
DateTimeOffset? lastModified = null,
21+
EntityTagHeaderValue entityTag = null)
22+
{
23+
httpContext.RequestServices = new ServiceCollection()
24+
.AddSingleton<ILoggerFactory, NullLoggerFactory>()
25+
.AddSingleton<IActionResultExecutor<PushFileStreamResult>, PushFileStreamResultExecutor>()
26+
.BuildServiceProvider();
27+
28+
var actionContext = new ActionContext(httpContext, new(), new());
29+
var fileStreamResult = new PushFileStreamResult(streamWriterCallback, contentType)
30+
{
31+
LastModified = lastModified,
32+
EntityTag = entityTag,
33+
};
34+
35+
return fileStreamResult.ExecuteResultAsync(actionContext);
36+
}
37+
38+
[Fact]
39+
public void Constructor_SetsContentType()
40+
{
41+
// Arrange
42+
var streamWriterCallback = (Stream _) => Task.CompletedTask;
43+
var contentType = "text/plain; charset=us-ascii; p1=p1-value";
44+
var expectedMediaType = contentType;
45+
46+
// Act
47+
var result = new PushFileStreamResult(streamWriterCallback, contentType);
48+
49+
// Assert
50+
Assert.Equal(expectedMediaType, result.ContentType);
51+
}
52+
53+
[Fact]
54+
public void Constructor_SetsLastModifiedAndEtag()
55+
{
56+
// Arrange
57+
var streamWriterCallback = (Stream _) => Task.CompletedTask;
58+
var contentType = "text/plain";
59+
var expectedMediaType = contentType;
60+
var lastModified = new DateTimeOffset();
61+
var entityTag = new EntityTagHeaderValue("\"Etag\"");
62+
63+
// Act
64+
var result = new PushFileStreamResult(streamWriterCallback, contentType)
65+
{
66+
LastModified = lastModified,
67+
EntityTag = entityTag,
68+
};
69+
70+
// Assert
71+
Assert.Equal(lastModified, result.LastModified);
72+
Assert.Equal(entityTag, result.EntityTag);
73+
Assert.Equal(expectedMediaType, result.ContentType);
74+
}
75+
}

0 commit comments

Comments
 (0)