Skip to content

Commit

Permalink
Implement the Mvc PushFileStreamResult API
Browse files Browse the repository at this point in the history
Fixes #39383
  • Loading branch information
0xced committed Sep 30, 2024
1 parent fdd6d10 commit a1440b9
Show file tree
Hide file tree
Showing 7 changed files with 412 additions and 0 deletions.
59 changes: 59 additions & 0 deletions src/Mvc/Mvc.Core/src/ControllerBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1705,6 +1705,65 @@ public virtual PhysicalFileResult PhysicalFile(string physicalPath, string conte
EnableRangeProcessing = enableRangeProcessing,
};
}

/// <summary>
/// Writes the file directly to the response body with the specified <paramref name="streamWriterCallback" /> (<see cref="StatusCodes.Status200OK"/>), and the
/// specified <paramref name="contentType" /> as the Content-Type.
/// </summary>
/// <param name="streamWriterCallback">The callback that allows users to write directly to the response body.</param>
/// <param name="contentType">The Content-Type of the file.</param>
/// <returns>The created <see cref="PushFileStreamResult"/> for the response.</returns>
[NonAction]
public virtual PushFileStreamResult File(Func<Stream, Task> streamWriterCallback, string contentType)
=> File(streamWriterCallback, contentType, fileDownloadName: null);

/// <summary>
/// Writes the file directly to the response body with the specified <paramref name="streamWriterCallback" /> (<see cref="StatusCodes.Status200OK"/>), the
/// specified <paramref name="contentType" /> as the Content-Type, and the specified <paramref name="fileDownloadName" /> as the suggested file name.
/// </summary>
/// <param name="streamWriterCallback">The callback that allows users to write directly to the response body.</param>
/// <param name="contentType">The Content-Type of the file.</param>
/// <param name="fileDownloadName">The suggested file name.</param>
/// <returns>The created <see cref="PushFileStreamResult"/> for the response.</returns>
[NonAction]
public virtual PushFileStreamResult File(Func<Stream, Task> streamWriterCallback, string contentType, string? fileDownloadName)
=> new PushFileStreamResult(streamWriterCallback, contentType) { FileDownloadName = fileDownloadName };

/// <summary>
/// Writes the file directly to the response body with the specified <paramref name="streamWriterCallback" /> (<see cref="StatusCodes.Status200OK"/>), and the
/// specified <paramref name="contentType" /> as the Content-Type.
/// </summary>
/// <param name="streamWriterCallback">The callback that allows users to write directly to the response body.</param>
/// <param name="contentType">The Content-Type of the file.</param>
/// <param name="lastModified">The <see cref="DateTimeOffset"/> of when the file was last modified.</param>
/// <param name="entityTag">The <see cref="EntityTagHeaderValue"/> associated with the file.</param>
/// <returns>The created <see cref="PushFileStreamResult"/> for the response.</returns>
[NonAction]
public virtual PushFileStreamResult File(Func<Stream, Task> streamWriterCallback, string contentType, DateTimeOffset? lastModified, EntityTagHeaderValue entityTag)
=> new PushFileStreamResult(streamWriterCallback, contentType)
{
LastModified = lastModified,
EntityTag = entityTag,
};

/// <summary>
/// Writes the file directly to the response body with the specified <paramref name="streamWriterCallback" /> (<see cref="StatusCodes.Status200OK"/>), the
/// specified <paramref name="contentType" /> as the Content-Type, and the specified <paramref name="fileDownloadName" /> as the suggested file name.
/// </summary>
/// <param name="streamWriterCallback">The callback that allows users to write directly to the response body.</param>
/// <param name="contentType">The Content-Type of the file.</param>
/// <param name="fileDownloadName">The suggested file name.</param>
/// <param name="lastModified">The <see cref="DateTimeOffset"/> of when the file was last modified.</param>
/// <param name="entityTag">The <see cref="EntityTagHeaderValue"/> associated with the file.</param>
/// <returns>The created <see cref="PushFileStreamResult"/> for the response.</returns>
[NonAction]
public virtual PushFileStreamResult File(Func<Stream, Task> streamWriterCallback, string contentType, string? fileDownloadName, DateTimeOffset? lastModified, EntityTagHeaderValue entityTag)
=> new PushFileStreamResult(streamWriterCallback, contentType)
{
LastModified = lastModified,
EntityTag = entityTag,
FileDownloadName = fileDownloadName,
};
#endregion

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ internal static void AddMvcCoreServices(IServiceCollection services)
services.TryAddSingleton<IActionResultExecutor<PhysicalFileResult>, PhysicalFileResultExecutor>();
services.TryAddSingleton<IActionResultExecutor<VirtualFileResult>, VirtualFileResultExecutor>();
services.TryAddSingleton<IActionResultExecutor<FileStreamResult>, FileStreamResultExecutor>();
services.TryAddSingleton<IActionResultExecutor<PushFileStreamResult>, PushFileStreamResultExecutor>();
services.TryAddSingleton<IActionResultExecutor<FileContentResult>, FileContentResultExecutor>();
services.TryAddSingleton<IActionResultExecutor<RedirectResult>, RedirectResultExecutor>();
services.TryAddSingleton<IActionResultExecutor<LocalRedirectResult>, LocalRedirectResultExecutor>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Net.Http.Headers;

namespace Microsoft.AspNetCore.Mvc.Infrastructure;

/// <summary>
/// A <see cref="IActionResultExecutor{PushFileStreamResult}"/> for <see cref="PushFileStreamResult"/>.
/// </summary>
public partial class PushFileStreamResultExecutor : FileResultExecutorBase, IActionResultExecutor<PushFileStreamResult>
{
/// <summary>
/// Initializes a new <see cref="PushFileStreamResultExecutor"/>.
/// </summary>
/// <param name="loggerFactory">The factory used to create loggers.</param>
public PushFileStreamResultExecutor(ILoggerFactory loggerFactory)
: base(CreateLogger<PushFileStreamResultExecutor>(loggerFactory))
{
}

/// <inheritdoc />
public virtual async Task ExecuteAsync(ActionContext context, PushFileStreamResult result)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(result);

Log.ExecutingFileResult(Logger, result);

var (range, rangeLength, serveBody) = SetHeadersAndLog(
context,
result,
fileLength: null,
result.EnableRangeProcessing,
result.LastModified,
result.EntityTag);

if (!serveBody)
{
return;
}

await WriteFileAsync(context, result, range, rangeLength);
}

/// <summary>
/// Write the contents of the PushFileStreamResult to the response body.
/// </summary>
/// <param name="context">The <see cref="ActionContext"/>.</param>
/// <param name="result">The PushFileStreamResult to write.</param>
/// <param name="range">The <see cref="RangeItemHeaderValue"/>.</param>
/// <param name="rangeLength">The range length.</param>
protected virtual async Task WriteFileAsync(
ActionContext context,
PushFileStreamResult result,
RangeItemHeaderValue? range,
long rangeLength)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(result);

Debug.Assert(range == null);
Debug.Assert(rangeLength == 0);

await result.StreamWriterCallback(context.HttpContext.Response.Body);
}

private static partial class Log
{
public static void ExecutingFileResult(ILogger logger, FileResult fileResult)
{
if (logger.IsEnabled(LogLevel.Information))
{
var fileResultType = fileResult.GetType().Name;
ExecutingFileResultWithNoFileName(logger, fileResultType, fileResult.FileDownloadName);
}
}

[LoggerMessage(1, LogLevel.Information, "Executing {FileResultType}, sending file with download name '{FileDownloadName}' ...", EventName = "ExecutingFileResultWithNoFileName", SkipEnabledCheck = true)]
private static partial void ExecutingFileResultWithNoFileName(ILogger logger, string fileResultType, string fileDownloadName);
}
}
13 changes: 13 additions & 0 deletions src/Mvc/Mvc.Core/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
#nullable enable
*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!
*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!
Microsoft.AspNetCore.Mvc.Infrastructure.PushFileStreamResultExecutor
Microsoft.AspNetCore.Mvc.Infrastructure.PushFileStreamResultExecutor.PushFileStreamResultExecutor(Microsoft.Extensions.Logging.ILoggerFactory! loggerFactory) -> void
Microsoft.AspNetCore.Mvc.PushFileStreamResult
Microsoft.AspNetCore.Mvc.PushFileStreamResult.PushFileStreamResult(System.Func<System.IO.Stream!, System.Threading.Tasks.Task!>! streamWriterCallback, string! contentType) -> void
Microsoft.AspNetCore.Mvc.PushFileStreamResult.StreamWriterCallback.get -> System.Func<System.IO.Stream!, System.Threading.Tasks.Task!>!
Microsoft.AspNetCore.Mvc.PushFileStreamResult.StreamWriterCallback.set -> void
override Microsoft.AspNetCore.Mvc.PushFileStreamResult.ExecuteResultAsync(Microsoft.AspNetCore.Mvc.ActionContext! context) -> System.Threading.Tasks.Task!
virtual Microsoft.AspNetCore.Mvc.ControllerBase.File(System.Func<System.IO.Stream!, System.Threading.Tasks.Task!>! streamWriterCallback, string! contentType) -> Microsoft.AspNetCore.Mvc.PushFileStreamResult!
virtual Microsoft.AspNetCore.Mvc.ControllerBase.File(System.Func<System.IO.Stream!, System.Threading.Tasks.Task!>! streamWriterCallback, string! contentType, string? fileDownloadName) -> Microsoft.AspNetCore.Mvc.PushFileStreamResult!
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!
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!
virtual Microsoft.AspNetCore.Mvc.ControllerBase.Problem(string? detail, string? instance, int? statusCode, string? title, string? type) -> Microsoft.AspNetCore.Mvc.ObjectResult!
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!
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!
Expand All @@ -9,3 +20,5 @@ Microsoft.AspNetCore.Mvc.Infrastructure.DefaultProblemDetailsFactory
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
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!
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!
virtual Microsoft.AspNetCore.Mvc.Infrastructure.PushFileStreamResultExecutor.ExecuteAsync(Microsoft.AspNetCore.Mvc.ActionContext! context, Microsoft.AspNetCore.Mvc.PushFileStreamResult! result) -> System.Threading.Tasks.Task!
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!
40 changes: 40 additions & 0 deletions src/Mvc/Mvc.Core/src/PushFileStreamResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.DependencyInjection;

namespace Microsoft.AspNetCore.Mvc;

/// <summary>
/// A <see cref="FileResult" /> that on execution writes the file using the specified stream writer callback.
/// </summary>
public class PushFileStreamResult : FileResult
{
/// <summary>
/// The callback that writes the file to the provided stream.
/// </summary>
public Func<Stream, Task> StreamWriterCallback { get; set; }

/// <summary>
/// Creates a new <see cref="PushFileStreamResult"/> instance with
/// the provided <paramref name="streamWriterCallback"/> and the provided <paramref name="contentType"/>.
/// </summary>
/// <param name="streamWriterCallback">The callback that writes the file to the provided stream.</param>
/// <param name="contentType">The Content-Type header of the response.</param>
public PushFileStreamResult(Func<Stream, Task> streamWriterCallback, string contentType) : base(contentType)
{
ArgumentNullException.ThrowIfNull(streamWriterCallback);

StreamWriterCallback = streamWriterCallback;
}

/// <inheritdoc />
public override Task ExecuteResultAsync(ActionContext context)
{
ArgumentNullException.ThrowIfNull(context);

var executor = context.HttpContext.RequestServices.GetRequiredService<IActionResultExecutor<PushFileStreamResult>>();
return executor.ExecuteAsync(context, this);
}
}
75 changes: 75 additions & 0 deletions src/Mvc/Mvc.Core/test/PushFileStreamResultTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Internal;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Net.Http.Headers;

namespace Microsoft.AspNetCore.Mvc;

public class PushFileStreamResultTest : PushFileStreamResultTestBase
{
protected override Task ExecuteAsync(
HttpContext httpContext,
Func<Stream, Task> streamWriterCallback,
string contentType,
DateTimeOffset? lastModified = null,
EntityTagHeaderValue entityTag = null)
{
httpContext.RequestServices = new ServiceCollection()
.AddSingleton<ILoggerFactory, NullLoggerFactory>()
.AddSingleton<IActionResultExecutor<PushFileStreamResult>, PushFileStreamResultExecutor>()
.BuildServiceProvider();

var actionContext = new ActionContext(httpContext, new(), new());
var fileStreamResult = new PushFileStreamResult(streamWriterCallback, contentType)
{
LastModified = lastModified,
EntityTag = entityTag,
};

return fileStreamResult.ExecuteResultAsync(actionContext);
}

[Fact]
public void Constructor_SetsContentType()
{
// Arrange
var streamWriterCallback = (Stream _) => Task.CompletedTask;
var contentType = "text/plain; charset=us-ascii; p1=p1-value";
var expectedMediaType = contentType;

// Act
var result = new PushFileStreamResult(streamWriterCallback, contentType);

// Assert
Assert.Equal(expectedMediaType, result.ContentType);
}

[Fact]
public void Constructor_SetsLastModifiedAndEtag()
{
// Arrange
var streamWriterCallback = (Stream _) => Task.CompletedTask;
var contentType = "text/plain";
var expectedMediaType = contentType;
var lastModified = new DateTimeOffset();
var entityTag = new EntityTagHeaderValue("\"Etag\"");

// Act
var result = new PushFileStreamResult(streamWriterCallback, contentType)
{
LastModified = lastModified,
EntityTag = entityTag,
};

// Assert
Assert.Equal(lastModified, result.LastModified);
Assert.Equal(entityTag, result.EntityTag);
Assert.Equal(expectedMediaType, result.ContentType);
}
}
Loading

0 comments on commit a1440b9

Please sign in to comment.