dotnet / dotnet/aspnetcore

Expose a `TypedResults` method to push a partial response

Open
#55,753 8 comments 3 reactions 0 assignees View on GitHub
api-needs-work area-minimal
Dominant language
C#
Stars
38.4k
Forks
10.9k
Avg merge
2d 6h
Merged PRs (30d)
290

Description

## 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

```diff
namespace Microsoft.AspNetCore.Http;

public static class TypedResults
{
+ ///
+ /// Allows writing directly to the response body.
+ ///
+ /// This supports range requests ( or
+ /// if the range is not satisfiable).
+ ///
+ ///
+ /// The callback that allows users to write directly to the HTTP response.
+ /// The total length of the file.
+ /// The Content-Type of the response. Defaults to application/octet-stream.
+ /// The the file name to be used in the Content-Disposition header.
+ /// The of when the file was last modified.
+ /// Used to configure the Last-Modified response header and perform conditional range requests.
+ /// The to be configure the ETag response header
+ /// and perform conditional requests.
+ /// Set to true to enable range requests processing.
+ /// The created for the response.
+ public static PushStreamHttpResult Stream(
+ Func callback,
+ long? fileLength = null,
+ string? contentType = null,
+ string? fileDownloadName = null,
+ DateTimeOffset? lastModified = null,
+ EntityTagHeaderValue? entityTag = null,
+ bool enableRangeProcessing = false);
```

## Usage Examples

```csharp
// 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, ...)`)
- 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` and wrap the `Func streamWriterCallback` as `(range, httpContext) => streamWriterCallback(httpContext.Response.Body)`.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.