Expose a `TypedResults` method to push a partial response
ranma42 opened this issue · 1 comments
Background and Motivation
In a project I am developing I have the need to accept ranged requests and provide the client with partial responses (206).
The data for these responses is managed in a chunk-based store which is somewhat awkward to wrap in a Stream
.
For full responses, the PushStreamHttpResult
is ideal, but it currently does not support partial requests.
It is quite straightforward to implement an IResult
that does what I need, but it looks like I would need to re-implement HttpResultsHelper.WriteResultAsFileCore
as it is an internal
method.
Proposed API
namespace Microsoft.AspNetCore.Http;
public static class TypedResults
{
+ /// <summary>
+ /// Allows writing directly to the response body.
+ /// <para>
+ /// This supports range requests (<see cref="StatusCodes.Status206PartialContent"/> or
+ /// <see cref="StatusCodes.Status416RangeNotSatisfiable"/> if the range is not satisfiable).
+ /// </para>
+ /// </summary>
+ /// <param name="callback">The callback that allows users to write directly to the HTTP response.</param>
+ /// <param name="fileLength">The total length of the file.</param>
+ /// <param name="contentType">The <c>Content-Type</c> of the response. Defaults to <c>application/octet-stream</c>.</param>
+ /// <param name="fileDownloadName">The the file name to be used in the <c>Content-Disposition</c> header.</param>
+ /// <param name="lastModified">The <see cref="DateTimeOffset"/> of when the file was last modified.
+ /// Used to configure the <c>Last-Modified</c> response header and perform conditional range requests.</param>
+ /// <param name="entityTag">The <see cref="EntityTagHeaderValue"/> to be configure the <c>ETag</c> response header
+ /// and perform conditional requests.</param>
+ /// <param name="enableRangeProcessing">Set to <c>true</c> to enable range requests processing.</param>
+ /// <returns>The created <see cref="PushStreamHttpResult"/> for the response.</returns>
+ public static PushStreamHttpResult Stream(
+ Func<RangeItemHeaderValue?, HttpContext, Task> callback,
+ long? fileLength = null,
+ string? contentType = null,
+ string? fileDownloadName = null,
+ DateTimeOffset? lastModified = null,
+ EntityTagHeaderValue? entityTag = null,
+ bool enableRangeProcessing = false);
Usage Examples
// respond with a stream representing the selected part of a resource
// this avoids moving around any useless data
app.MapGet("/example1/{key}", async (string key, IStore store) =>
TypedResults.Stream(
async (range, httpContext) => {
using var stream = range is null
? await store.GetContent(key)
: await store.GetPartialContent(key, range);
await StreamCopyOperation.CopyToAsync(stream, httpContext.Response.Body, count: null, bufferSize: 64 * 1024, cancel: httpContext.RequestAborted);
},
fileLength: await store.GetSize(key)
);
// respond with chunks representing the selected part of a resource
// the chunks might be "wasting" (in this case they have a fixed size),
// but they might come from a local memory cache
app.MapGet("/example2/{key}", async (string key, IChunkStore store) => {
var size = await store.GetSize(key);
return TypedResults.Stream(
async (range, httpContext) =>
{
var chunkSize = store.GetChunkSize();
var to = (range?.To + 1) ?? size;
var start = range?.From ?? (size - to);
var end = range?.From is null ? size : to;
while (start < end)
{
var chunk = await store.GetChunk(start / chunkSize);
var slice = chunk.AsMemory().Slice(
(int)(start % chunkSize),
(int)Math.Min(chunkSize, end - start)
);
await httpContext.Response.Body.WriteAsync(slice);
start += slice.Length;
}
},
fileLength: size,
)
});
Alternative Designs
The proposed API exposes the range information as RangeItemHeaderValue?
.
There are several alternatives to represent the range; this type was proposed based on what is used internally/emitted by HttpResultsHelper.WriteResultAsFileCore
.
The proposed API returns a PushStreamHttpResult
, under the assumption that the same class can be used for both methods implementing responses-pushing-to-body.
This would involve changes to PushStreamHttpResult
; another approach would be to write a separate IResult
implementation in Microsoft.AspNetCore.Http.HttpResults
.
As mentioned in the background, it is possible to develop an independent IResult
implementation; this requires duplicating the functionality implemented in HttpResultsHelper.WriteResultAsFileCore
.
Another option is to wrap this as a Stream
and use the TypedResults.Stream(Stream stream, ...)
overload, but this is inconvenient for two reasons:
- this makes push-based operations harder to express (which I believe is the reason for the existence of
TypedResults.Stream(Func<Stream, Task> stream, ...)
) - the async creation
See https://gist.github.com/ranma42/a528555972f16c17ed1b840bfe7fbf5c for an example of the stream hack.
Risks
The API should be an extension of the existing API and AFAICT involves no breaking change.
I have experimented locally with an implementation that extends PushStreamHttpResult
; in order to reuse the same class, some minor changes are needed that should not result in performance regressions.
A straightforward implementation would simply replace the callback with a Func<RangeItemHeaderValue?, HttpContext, Task>
and wrap the Func<Stream, Task> streamWriterCallback
as (range, httpContext) => streamWriterCallback(httpContext.Response.Body)
.