Skip to content

Commit 5dc41da

Browse files
authored
Async enumerable minimal endpoint (#22)
* - add MinimalEndpointWithStreamingResponse base class to handle IAsyncEnumerable<T> response types - add documentation - simplify and cleanup code - add more examples: for streaming response and file download * - add tests for MinimalEndpointWithStreamingResponse * - code cleanup * - update deps * - add WeatherForecastWebApi project info under Samples section in docs * - bump version
1 parent 8ec9ce4 commit 5dc41da

31 files changed

+540
-49
lines changed

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,6 @@
1818
<PackageLicenseExpression>MIT</PackageLicenseExpression>
1919
<PackageRequireLicenseAcceptance>True</PackageRequireLicenseAcceptance>
2020

21-
<Version>1.0.0</Version>
21+
<Version>1.0.1</Version>
2222
</PropertyGroup>
2323
</Project>

ModEndpoints.sln

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{9FD33E1B-1
5050
ProjectSection(SolutionItems) = preProject
5151
docs\DisablingComponents.md = docs\DisablingComponents.md
5252
docs\EndpointTypes.md = docs\EndpointTypes.md
53+
docs\HandlingFiles.md = docs\HandlingFiles.md
54+
docs\IAsyncEnumerableResponse.md = docs\IAsyncEnumerableResponse.md
5355
docs\ParameterBinding.md = docs\ParameterBinding.md
5456
docs\RouteGroups.md = docs\RouteGroups.md
5557
EndProjectSection

README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -222,26 +222,30 @@ internal class ListBooks(ServiceDbContext db)
222222
}
223223
```
224224

225-
### Advanced Topics
225+
### Further Reading
226226

227-
For more advanced examples, refer to the following:
227+
For more examples, refer to the following:
228228

229229
- [Parameter Binding](./docs/ParameterBinding.md)
230230
- [Route Groups](./docs/RouteGroups.md)
231+
- [IAsyncEnumerable Response](./docs/IAsyncEnumerableResponse.md)
232+
- [Handling Files](./docs/HandlingFiles.md)
231233
- [Disabling Components](./docs/DisablingComponents.md)
232234

233235
---
234236

235237
## 📚 Samples
236238

237-
[ShowcaseWebApi](./samples/ShowcaseWebApi) project demonstrates all endpoint types in action:
239+
[ShowcaseWebApi](./samples/ShowcaseWebApi) project demonstrates all endpoint types in action and also API documentation with Swagger and Swashbuckle:
238240
- `MinimalEnpoint` samples are in [Customers](./samples/ShowcaseWebApi/Features/Customers) subfolder,
239241
- `WebResultEndpoint` samples are in [Books](./samples/ShowcaseWebApi/Features/Books) subfolder,
240242
- `BusinessResultEndpoint` samples are in [Stores](./samples/ShowcaseWebApi/Features/Stores) subfolder,
241243
- `ServiceEndpoint` samples are in [StoresWithServiceEndpoint](./samples/ShowcaseWebApi/Features/StoresWithServiceEndpoint) subfolder.
242244

243245
[ServiceEndpointClient](./samples/ServiceEndpointClient) project demonstrates how to consume ServiceEndpoints.
244246

247+
[WeatherForecastWebApi](./samples/WeatherForecastWebApi) project demonstrates how GetWeatherForecast Minimal API in ASP.NET Core Web API project template can be written using MinimalEndpoints. Also demostrates API documentation with Scalar and OpenAPI.
248+
245249
---
246250

247251
## 📊 Performance

docs/HandlingFiles.md

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# Handling Files
2+
3+
## File Uploads
4+
`IFormFile` or `IFormFileCollection` in ASP.NET Core are used to handle file uploads with form parameter binding. The IFormFile interface represents a file sent with the HttpRequest, and IFormFileCollection is a collection of IFormFile objects.
5+
6+
``` csharp
7+
public record UploadBookRequest(string Title, [FromForm] string Author, IFormFile BookFile);
8+
9+
public record UploadBookResponse(string FileName, long FileSize);
10+
11+
internal class UploadBookRequestValidator : AbstractValidator<UploadBookRequest>
12+
{
13+
public UploadBookRequestValidator()
14+
{
15+
RuleFor(x => x.Title).NotEmpty();
16+
RuleFor(x => x.Author).NotEmpty();
17+
RuleFor(x => x.BookFile).NotEmpty();
18+
}
19+
}
20+
21+
[MapToGroup<BooksV2RouteGroup>()]
22+
internal class UploadBook
23+
: WebResultEndpoint<UploadBookRequest, UploadBookResponse>
24+
{
25+
protected override void Configure(
26+
IServiceProvider serviceProvider,
27+
IRouteGroupConfigurator? parentRouteGroup)
28+
{
29+
MapPost("/upload/{Title}")
30+
.DisableAntiforgery()
31+
.Produces<UploadBookResponse>();
32+
}
33+
34+
protected override Task<Result<UploadBookResponse>> HandleAsync(
35+
UploadBookRequest req,
36+
CancellationToken ct)
37+
{
38+
// Process file upload
39+
// ...
40+
//
41+
42+
return Task.FromResult(Result.Ok(new UploadBookResponse(
43+
req.BookFile.FileName,
44+
req.BookFile.Length)));
45+
}
46+
}
47+
```
48+
In this example, the `UploadBookRequest` record includes a file upload parameter `IFormFile BookFile`. The `UploadBook` endpoint handles the file upload and returns a response with the file name and size.
49+
50+
>**Note**: The `DisableAntiforgery` method is used to disable CSRF protection for this endpoint. This is necessary for file uploads, as the default behavior of ASP.NET Core is to require an antiforgery token for all POST requests. However, you should be cautious when disabling CSRF protection and ensure that your application is secure against CSRF attacks.
51+
52+
## File Downloads
53+
Returning `Results.File()` or `Results.Stream()` from a `MinimalEndpoint` can be used to return files from an endpoint. The file can be a physical file on disk or a stream of data.
54+
55+
``` csharp
56+
public record DownloadCustomersRequest(string FileName);
57+
58+
internal class DownloadCustomersRequestValidator : AbstractValidator<DownloadCustomersRequest>
59+
{
60+
public DownloadCustomersRequestValidator()
61+
{
62+
RuleFor(x => x.FileName)
63+
.NotEmpty()
64+
.Must(x => Path.GetExtension(x).Equals(".txt", StringComparison.OrdinalIgnoreCase))
65+
.WithMessage("{PropertyName} must have .txt extension.");
66+
}
67+
}
68+
69+
[MapToGroup<CustomersV1RouteGroup>()]
70+
internal class DownloadCustomers(ServiceDbContext db)
71+
: MinimalEndpoint<DownloadCustomersRequest, IResult>
72+
{
73+
protected override void Configure(
74+
IServiceProvider serviceProvider,
75+
IRouteGroupConfigurator? parentRouteGroup)
76+
{
77+
MapPost("/download/{FileName}");
78+
}
79+
80+
protected override async Task<IResult> HandleAsync(
81+
DownloadCustomersRequest req,
82+
CancellationToken ct)
83+
{
84+
await Task.CompletedTask; // Simulate some async work
85+
var customers = db.Customers.AsAsyncEnumerable();
86+
return Results.Stream(async stream =>
87+
{
88+
await foreach (var customer in customers.WithCancellation(ct))
89+
{
90+
var line = $"{customer.Id},{customer.FirstName},{customer.MiddleName},{customer.LastName}\n";
91+
var lineBytes = System.Text.Encoding.UTF8.GetBytes(line);
92+
await stream.WriteAsync(lineBytes, ct);
93+
}
94+
},
95+
fileDownloadName: Path.GetFileName(req.FileName));
96+
}
97+
}
98+
```
99+
In this example, the `DownloadCustomers` endpoint returns a stream of customer data as a text file. The `Results.Stream()` method is used to write the data to the response stream, and the `fileDownloadName` parameter specifies the name of the file to be downloaded.

docs/IAsyncEnumerableResponse.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# IAsyncEnumerable Response
2+
3+
`MinimalEndpointWithStreamingResponse` is a specialized base class designed to simplify the implementation of minimal APIs that return streaming responses in .NET. It provides a structured way to define endpoints that stream data asynchronously using `IAsyncEnumerable<T>`.
4+
5+
``` csharp
6+
public record ListCustomersResponse(
7+
Guid Id,
8+
string FirstName,
9+
string? MiddleName,
10+
string LastName);
11+
12+
internal class ListCustomers(ServiceDbContext db)
13+
: MinimalEndpointWithStreamingResponse<ListCustomersResponse>
14+
{
15+
protected override void Configure(
16+
IServiceProvider serviceProvider,
17+
IRouteGroupConfigurator? parentRouteGroup)
18+
{
19+
MapGet("/");
20+
}
21+
22+
protected override IAsyncEnumerable<ListCustomersResponse> HandleAsync(CancellationToken ct)
23+
{
24+
var customers = db.Customers
25+
.Select(c => new ListCustomersResponse(
26+
c.Id,
27+
c.FirstName,
28+
c.MiddleName,
29+
c.LastName))
30+
.AsAsyncEnumerable();
31+
32+
return customers;
33+
}
34+
}
35+
```
36+
>**Note**: The Cancellation Token is passed to the `HandleAsync` method, is also used to cancel enumeration of the returned `IAsyncEnumerable<T>` by the internals of base endpoint.

docs/ParameterBinding.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Parameter Binding
22

3-
Request model defined for an endpoint is bound with [AsParameters] attribute (except for ServiceEndpoints). Any field under request model can be bound from route, query, body, form, etc. with corresponding [From...] attribute (see [Minimal APIs Parameter Binding](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding?view=aspnetcore-8.0) for more information).
3+
Request model defined for an endpoint is bound with `[AsParameters]` attribute (except for `ServiceEndpoints`). Any field under request model can be bound from route, query, body, form, etc. with corresponding [From...] attribute (see [Minimal APIs Parameter Binding](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/parameter-binding?view=aspnetcore-8.0) for more information).
44

55
The following sample demonstrates route and body parameter binding.
66

samples/BenchmarkWebApi/BenchmarkWebApi.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
</PropertyGroup>
66

77
<ItemGroup>
8-
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
8+
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.0.0" />
99
</ItemGroup>
1010

1111
<ItemGroup>
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
using FluentValidation;
2+
using ModEndpoints.Core;
3+
using ShowcaseWebApi.Data;
4+
using ShowcaseWebApi.Features.Customers.Configuration;
5+
6+
namespace ShowcaseWebApi.Features.Customers;
7+
public record DownloadCustomersRequest(string FileName);
8+
9+
internal class DownloadCustomersRequestValidator : AbstractValidator<DownloadCustomersRequest>
10+
{
11+
public DownloadCustomersRequestValidator()
12+
{
13+
RuleFor(x => x.FileName)
14+
.NotEmpty()
15+
.Must(x => Path.GetExtension(x).Equals(".txt", StringComparison.OrdinalIgnoreCase))
16+
.WithMessage("{PropertyName} must have .txt extension.");
17+
}
18+
}
19+
20+
[MapToGroup<CustomersV1RouteGroup>()]
21+
internal class DownloadCustomers(ServiceDbContext db)
22+
: MinimalEndpoint<DownloadCustomersRequest, IResult>
23+
{
24+
protected override void Configure(
25+
IServiceProvider serviceProvider,
26+
IRouteGroupConfigurator? parentRouteGroup)
27+
{
28+
MapPost("/download/{FileName}");
29+
}
30+
31+
protected override async Task<IResult> HandleAsync(
32+
DownloadCustomersRequest req,
33+
CancellationToken ct)
34+
{
35+
await Task.CompletedTask; // Simulate some async work
36+
var customers = db.Customers.AsAsyncEnumerable();
37+
return Results.Stream(async stream =>
38+
{
39+
await foreach (var customer in customers.WithCancellation(ct))
40+
{
41+
var line = $"{customer.Id},{customer.FirstName},{customer.MiddleName},{customer.LastName}\n";
42+
var lineBytes = System.Text.Encoding.UTF8.GetBytes(line);
43+
await stream.WriteAsync(lineBytes, ct);
44+
}
45+
},
46+
fileDownloadName: Path.GetFileName(req.FileName));
47+
}
48+
}

samples/ShowcaseWebApi/Features/Customers/ListCustomers.cs

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,15 @@
55

66
namespace ShowcaseWebApi.Features.Customers;
77

8-
public record ListCustomersResponse(List<ListCustomersResponseItem> Customers);
9-
public record ListCustomersResponseItem(
8+
public record ListCustomersResponse(
109
Guid Id,
1110
string FirstName,
1211
string? MiddleName,
1312
string LastName);
1413

1514
[MapToGroup<CustomersV1RouteGroup>()]
1615
internal class ListCustomers(ServiceDbContext db)
17-
: MinimalEndpoint<ListCustomersResponse>
16+
: MinimalEndpointWithStreamingResponse<ListCustomersResponse>
1817
{
1918
protected override void Configure(
2019
IServiceProvider serviceProvider,
@@ -23,17 +22,16 @@ protected override void Configure(
2322
MapGet("/");
2423
}
2524

26-
protected override async Task<ListCustomersResponse> HandleAsync(
27-
CancellationToken ct)
25+
protected override IAsyncEnumerable<ListCustomersResponse> HandleAsync(CancellationToken ct)
2826
{
29-
var customers = await db.Customers
30-
.Select(c => new ListCustomersResponseItem(
27+
var customers = db.Customers
28+
.Select(c => new ListCustomersResponse(
3129
c.Id,
3230
c.FirstName,
3331
c.MiddleName,
3432
c.LastName))
35-
.ToListAsync(ct);
33+
.AsAsyncEnumerable();
3634

37-
return new ListCustomersResponse(Customers: customers);
35+
return customers;
3836
}
3937
}

samples/ShowcaseWebApi/ShowcaseWebApi.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@
77
<ItemGroup>
88
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.4" />
99
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.1" />
10-
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
10+
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.0.0" />
1111
<PackageReference Include="Asp.Versioning.Http" Version="8.1.0" />
1212
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
13-
<PackageReference Include="UUIDNext" Version="4.1.1" />
13+
<PackageReference Include="UUIDNext" Version="4.1.2" />
1414
</ItemGroup>
1515

1616
<ItemGroup>

0 commit comments

Comments
 (0)