Skip to content

Commit 81e7dcf

Browse files
authored
Service endpoint with streaming response (#25)
* - add implementation for ServiceEndpointWithStreamingResponse - add client side implementation for ServiceEndpointWithStreamingResponse - update ServiceEndpoints ListStores sample and sample client app * - get rid of new interfaces and classes for streamed response serializer and channel, instead add to existing interfaces and implementations. ** this is a breaking change * - change the response structure for service endpoints with streaming response to StreamingResponseItem * - code cleanup * - new conversions and implicit operators for StreamingResponseItem * Refactor remote service handling and response types - Renamed deserialization methods in `IServiceChannelSerializer` for consistency. - **breaking change** Removed `ServiceEndpointDefinitions.cs` and migrated its content to `RemoteServiceDefinitions.cs`. - Enhanced `StreamingResponseItem` records for better default handling. Added/removed some implicit operators - Introduced `RemoteServiceDefinitions` and `StreamingResponseItemDefinitions` for improved structure. * - `StreamingResponseItem` from using a primary constructor to regular ctor. ResponseType property getter is customized to return default value if left null during construction. * - rollback changes to StreamingResponseItem * - add docs for ServiceEndpointWithStreamingResponse * - bump version
1 parent 0022861 commit 81e7dcf

24 files changed

+609
-101
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.1</Version>
21+
<Version>1.1.0</Version>
2222
</PropertyGroup>
2323
</Project>

docs/EndpointTypes.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ A ServiceEndpoint implementation, similar to BusinessResultEntpoint, encapsulate
6666

6767
- **ServiceEndpoint&lt;TRequest, TResultValue&gt;**: Has a request model, supports request validation and returns a [Result&lt;TResultValue&gt;](https://github.com/modabas/ModResults) within HTTP 200 IResult.
6868
- **ServiceEndpoint&lt;TRequest&gt;**: Has a request model, supports request validation and returns a [Result](https://github.com/modabas/ModResults) within HTTP 200 IResult.
69+
- **ServiceEndpointWithStreamingResponse&lt;TRequest, TResultValue&gt;**: Has a request model, supports request validation and returns `IAsyncEnumerable<StreamingResponseItem<TResultValue>>`.
70+
- **ServiceEndpointWithStreamingResponse&lt;TRequest&gt;**: Has a request model, supports request validation and returns `IAsyncEnumerable<StreamingResponseItem>`.
71+
72+
>**Note**: `StreamingResponseItem` is a specialized type that contains a `Result` object and also Response Type and Id fields. It is used for streaming responses to allow clients to process each item as it arrives.
6973
7074
A ServiceEndpoint has following special traits and constraints:
7175
- A ServiceEndpoint is always registered as a POST method, and its bound pattern is determined accourding to its request type.

docs/IAsyncEnumerableResponse.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,29 @@ internal class ListCustomers(ServiceDbContext db)
3333
}
3434
}
3535
```
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.
36+
>**Note**: The CancellationToken passed to the `HandleAsync` method, is also used to cancel enumeration of the returned `IAsyncEnumerable<T>` by the internals of base endpoint.
37+
38+
Similarly, `ServiceEndpointWithStreamingResponse` can be used to implement service endpoints that return streaming responses.
39+
``` csharp
40+
internal class ListStores(ServiceDbContext db)
41+
: ServiceEndpointWithStreamingResponse<ListStoresRequest, ListStoresResponse>
42+
{
43+
protected override async IAsyncEnumerable<StreamingResponseItem<ListStoresResponse>> HandleAsync(
44+
ListStoresRequest req,
45+
[EnumeratorCancellation] CancellationToken ct)
46+
{
47+
var stores = db.Stores
48+
.Select(b => new ListStoresResponse(
49+
b.Id,
50+
b.Name))
51+
.AsAsyncEnumerable();
52+
53+
await foreach (var store in stores.WithCancellation(ct))
54+
{
55+
ct.ThrowIfCancellationRequested();
56+
yield return new StreamingResponseItem<ListStoresResponse>(store, "store");
57+
await Task.Delay(1000, ct);
58+
}
59+
}
60+
}
61+
```

samples/ServiceEndpointClient/Program.cs

Lines changed: 57 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -47,68 +47,73 @@ static async Task CallRemoteServicesAsync(IServiceProvider hostProvider)
4747

4848
//resolve service channel from DI
4949
var channel = provider.GetRequiredService<IServiceChannel>();
50+
List<ListStoresResponse> stores = new();
5051
//send request over channel to remote ServiceResultEndpoint
51-
var listResult = await channel.SendAsync(
52+
await foreach (var listStoreItem in channel.SendAsync(
5253
new ListStoresRequest(),
5354
"v1/storesWithServiceEndpoint/",
54-
default);
55+
default))
56+
{
57+
Console.WriteLine($"***Received streaming response item with ResponseType: {listStoreItem.ResponseType}");
58+
if (listStoreItem.Result.IsOk)
59+
{
60+
stores.Add(listStoreItem.Result.Value);
61+
}
62+
else
63+
{
64+
Console.WriteLine(listStoreItem.Result.DumpMessages());
65+
}
66+
}
5567

56-
if (listResult.IsOk)
68+
Console.WriteLine("***********************");
69+
Console.WriteLine($"ListStores complete. Total count: {stores.Count}");
70+
Console.WriteLine("***********************");
71+
var id = stores.FirstOrDefault()?.Id;
72+
if (id is not null)
5773
{
74+
//send request over channel to remote ServiceResultEndpoint
75+
var getResult = await channel.SendAsync(
76+
new GetStoreByIdRequest(Id: id.Value),
77+
"v1/storesWithServiceEndpoint/",
78+
default);
5879
Console.WriteLine("***********************");
59-
Console.WriteLine($"ListStores complete. Total count: {listResult.Value.Stores.Count}");
60-
Console.WriteLine("***********************");
61-
var id = listResult.Value.Stores.FirstOrDefault()?.Id;
62-
if (id is not null)
80+
Console.WriteLine("GetStoreById response BEFORE delete:");
81+
if (getResult.IsOk)
6382
{
64-
//send request over channel to remote ServiceResultEndpoint
65-
var getResult = await channel.SendAsync(
66-
new GetStoreByIdRequest(Id: id.Value),
67-
"v1/storesWithServiceEndpoint/",
68-
default);
69-
Console.WriteLine("***********************");
70-
Console.WriteLine("GetStoreById response BEFORE delete:");
71-
if (getResult.IsOk)
72-
{
73-
Console.WriteLine(getResult.Value);
74-
}
75-
else
76-
{
77-
Console.WriteLine(getResult.DumpMessages());
78-
}
79-
Console.WriteLine("***********************");
83+
Console.WriteLine(getResult.Value);
84+
}
85+
else
86+
{
87+
Console.WriteLine(getResult.DumpMessages());
88+
}
89+
Console.WriteLine("***********************");
8090

81-
//send request over channel to remote ServiceResultEndpoint
82-
var deleteResult = await channel.SendAsync(
83-
new DeleteStoreRequest(Id: id.Value),
84-
"v1/storesWithServiceEndpoint/",
85-
default);
86-
Console.WriteLine("***********************");
87-
Console.WriteLine("DeleteStore response:");
88-
Console.WriteLine(deleteResult.DumpMessages());
89-
Console.WriteLine("***********************");
91+
//send request over channel to remote ServiceResultEndpoint
92+
var deleteResult = await channel.SendAsync(
93+
new DeleteStoreRequest(Id: id.Value),
94+
"v1/storesWithServiceEndpoint/",
95+
default);
96+
Console.WriteLine("***********************");
97+
Console.WriteLine("DeleteStore response:");
98+
Console.WriteLine(deleteResult.DumpMessages());
99+
Console.WriteLine("***********************");
90100

91-
//send request over channel to remote ServiceResultEndpoint
92-
getResult = await channel.SendAsync(
93-
new GetStoreByIdRequest(Id: id.Value),
94-
"v1/storesWithServiceEndpoint/",
95-
default);
96-
Console.WriteLine("***********************");
97-
Console.WriteLine("GetStoreById response AFTER delete:");
98-
if (getResult.IsOk)
99-
{
100-
Console.WriteLine(getResult.Value);
101-
}
102-
else
103-
{
104-
Console.WriteLine(getResult.DumpMessages());
105-
}
106-
Console.WriteLine("***********************");
101+
//send request over channel to remote ServiceResultEndpoint
102+
getResult = await channel.SendAsync(
103+
new GetStoreByIdRequest(Id: id.Value),
104+
"v1/storesWithServiceEndpoint/",
105+
default);
106+
Console.WriteLine("***********************");
107+
Console.WriteLine("GetStoreById response AFTER delete:");
108+
if (getResult.IsOk)
109+
{
110+
Console.WriteLine(getResult.Value);
107111
}
108-
}
109-
else
110-
{
111-
Console.WriteLine(listResult.DumpMessages());
112+
else
113+
{
114+
Console.WriteLine(getResult.DumpMessages());
115+
}
116+
Console.WriteLine("***********************");
112117
}
113118

114119
Console.WriteLine();

samples/ShowcaseWebApi.FeatureContracts/Features/StoresWithServiceEndpoint/ListStoresRequest.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22

33
namespace ShowcaseWebApi.FeatureContracts.Features.StoresWithServiceEndpoint;
44

5-
public record ListStoresRequest() : IServiceRequest<ListStoresResponse>;
5+
public record ListStoresRequest() : IServiceRequestWithStreamingResponse<ListStoresResponse>;
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
namespace ShowcaseWebApi.FeatureContracts.Features.StoresWithServiceEndpoint;
22

3-
public record ListStoresResponse(List<ListStoresResponseItem> Stores);
3+
public record ListStoresResponse(Guid Id, string Name);

samples/ShowcaseWebApi.FeatureContracts/Features/StoresWithServiceEndpoint/ListStoresResponseItem.cs

Lines changed: 0 additions & 3 deletions
This file was deleted.
Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
using Microsoft.EntityFrameworkCore;
1+
using System.Runtime.CompilerServices;
2+
using Microsoft.EntityFrameworkCore;
23
using ModEndpoints;
34
using ModEndpoints.Core;
4-
using ModResults;
5+
using ModEndpoints.RemoteServices;
56
using ShowcaseWebApi.Data;
67
using ShowcaseWebApi.FeatureContracts.Features.StoresWithServiceEndpoint;
78
using ShowcaseWebApi.Features.StoresWithServiceEndpoint.Configuration;
@@ -10,18 +11,23 @@ namespace ShowcaseWebApi.Features.StoresWithServiceEndpoint;
1011

1112
[MapToGroup<StoresWithServiceEndpointRouteGroup>()]
1213
internal class ListStores(ServiceDbContext db)
13-
: ServiceEndpoint<ListStoresRequest, ListStoresResponse>
14+
: ServiceEndpointWithStreamingResponse<ListStoresRequest, ListStoresResponse>
1415
{
15-
protected override async Task<Result<ListStoresResponse>> HandleAsync(
16+
protected override async IAsyncEnumerable<StreamingResponseItem<ListStoresResponse>> HandleAsync(
1617
ListStoresRequest req,
17-
CancellationToken ct)
18+
[EnumeratorCancellation] CancellationToken ct)
1819
{
19-
var stores = await db.Stores
20-
.Select(b => new ListStoresResponseItem(
20+
var stores = db.Stores
21+
.Select(b => new ListStoresResponse(
2122
b.Id,
2223
b.Name))
23-
.ToListAsync(ct);
24+
.AsAsyncEnumerable();
2425

25-
return new ListStoresResponse(Stores: stores);
26+
await foreach (var store in stores.WithCancellation(ct))
27+
{
28+
ct.ThrowIfCancellationRequested();
29+
yield return new StreamingResponseItem<ListStoresResponse>(store, "store");
30+
await Task.Delay(1000, ct);
31+
}
2632
}
2733
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
using FluentValidation;
2+
using FluentValidation.Results;
3+
using Microsoft.AspNetCore.Http;
4+
using Microsoft.AspNetCore.Mvc;
5+
using Microsoft.Extensions.DependencyInjection;
6+
using ModEndpoints.RemoteServices.Core;
7+
8+
namespace ModEndpoints.Core;
9+
public abstract class BaseServiceEndpointWithStreamingResponse<TRequest, TResponse>
10+
: ServiceEndpointConfigurator, IServiceEndpoint
11+
where TRequest : IServiceRequestMarker
12+
{
13+
protected sealed override Delegate ExecuteDelegate => ExecuteAsync;
14+
15+
private async IAsyncEnumerable<TResponse> ExecuteAsync(
16+
[FromBody] TRequest req,
17+
HttpContext context)
18+
{
19+
var baseHandler = context.RequestServices.GetRequiredKeyedService(typeof(IEndpointConfigurator), GetType());
20+
var handler = baseHandler as BaseServiceEndpointWithStreamingResponse<TRequest, TResponse>
21+
?? throw new InvalidOperationException(Constants.RequiredServiceIsInvalidMessage);
22+
var ct = context.RequestAborted;
23+
24+
//Request validation
25+
var validator = context.RequestServices.GetService<IValidator<TRequest>>();
26+
if (validator is not null)
27+
{
28+
var validationResult = await validator.ValidateAsync(req, ct);
29+
if (!validationResult.IsValid)
30+
{
31+
yield return await HandleInvalidValidationResultAsync(validationResult, context, ct);
32+
yield break;
33+
}
34+
}
35+
36+
//Handler
37+
await foreach (var item in handler.HandleAsync(req, ct).WithCancellation(ct))
38+
{
39+
yield return item;
40+
}
41+
}
42+
43+
/// <summary>
44+
/// Contains endpoint's logic to handle request. Input validation is completed before this method is called.
45+
/// </summary>
46+
/// <param name="req"></param>
47+
/// <param name="ct"></param>
48+
/// <returns></returns>
49+
protected abstract IAsyncEnumerable<TResponse> HandleAsync(
50+
TRequest req,
51+
CancellationToken ct);
52+
53+
/// <summary>
54+
/// This method is called if request validation fails, and is responsible for mapping <see cref="ValidationResult"/> to <typeparamref name="TResponse"/>.
55+
/// </summary>
56+
/// <param name="validationResult"></param>
57+
/// <param name="context"></param>
58+
/// <param name="ct"></param>
59+
/// <returns>Endpoint's <typeparamref name="TResponse"/> type validation failed response to caller.</returns>
60+
protected abstract ValueTask<TResponse> HandleInvalidValidationResultAsync(
61+
ValidationResult validationResult,
62+
HttpContext context,
63+
CancellationToken ct);
64+
}
Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,5 @@
11
namespace ModEndpoints.RemoteServices.Core;
2-
public interface IServiceRequest<TResponse> : IServiceRequestMarker
3-
{
4-
}
2+
public interface IServiceRequest<TResponse> : IServiceRequestMarker;
53

6-
public interface IServiceRequest : IServiceRequestMarker
7-
{
8-
}
9-
10-
public interface IServiceRequestMarker { }
4+
public interface IServiceRequest : IServiceRequestMarker;
115

0 commit comments

Comments
 (0)