Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ on:
env:
COREPROJECTNAME: "ModEndpoints.Core"
ENDPOINTSPROJECTNAME: "ModEndpoints"
REMOTESERVICESCOREPROJECTNAME: "ModEndpoints.RemoteServices.Core"
REMOTESERVICESPROJECTNAME: "ModEndpoints.RemoteServices"

jobs:
build:
Expand All @@ -25,12 +27,24 @@ jobs:
9.0.x
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build ${{ env.REMOTESERVICESCOREPROJECTNAME }} --configuration Release --no-restore
working-directory: src
- name: Build
run: dotnet build ${{ env.REMOTESERVICESPROJECTNAME }} --configuration Release --no-restore
working-directory: src
- name: Build
run: dotnet build ${{ env.COREPROJECTNAME }} --configuration Release --no-restore
working-directory: src
- name: Build
run: dotnet build ${{ env.ENDPOINTSPROJECTNAME }} --configuration Release --no-restore
working-directory: src
- name: Package nuget remote services core
run: dotnet pack ${{ env.REMOTESERVICESCOREPROJECTNAME }} --configuration Release --no-build -o:package
working-directory: src
- name: Package nuget remote services
run: dotnet pack ${{ env.REMOTESERVICESPROJECTNAME }} --configuration Release --no-build -o:package
working-directory: src
- name: Package nuget endpoints core
run: dotnet pack ${{ env.COREPROJECTNAME }} --configuration Release --no-build -o:package
working-directory: src
Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,6 @@
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageLicenseExpression>MIT</PackageLicenseExpression>

<Version>0.2.0</Version>
<Version>0.3.0</Version>
</PropertyGroup>
</Project>
21 changes: 21 additions & 0 deletions ModEndpoints.sln
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModEndpoints.Core", "src\Mo
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModEndpoints", "src\ModEndpoints\ModEndpoints.csproj", "{0F89CC73-32D4-4347-B284-41804A8A54A9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModEndpoints.RemoteServices.Core", "src\ModEndpoints.RemoteServices.Core\ModEndpoints.RemoteServices.Core.csproj", "{80C3DA6D-EDBE-47A5-BC6B-93BC334BEF67}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModEndpoints.RemoteServices", "src\ModEndpoints.RemoteServices\ModEndpoints.RemoteServices.csproj", "{3CDE6A69-09BA-4714-8DCD-D934BA27EBEB}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ShowcaseWebApi.FeatureContracts", "samples\ShowcaseWebApi.FeatureContracts\ShowcaseWebApi.FeatureContracts.csproj", "{DE3AA974-14C3-402F-93F4-A4A5D3DC0131}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -57,6 +63,18 @@ Global
{0F89CC73-32D4-4347-B284-41804A8A54A9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0F89CC73-32D4-4347-B284-41804A8A54A9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0F89CC73-32D4-4347-B284-41804A8A54A9}.Release|Any CPU.Build.0 = Release|Any CPU
{80C3DA6D-EDBE-47A5-BC6B-93BC334BEF67}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{80C3DA6D-EDBE-47A5-BC6B-93BC334BEF67}.Debug|Any CPU.Build.0 = Debug|Any CPU
{80C3DA6D-EDBE-47A5-BC6B-93BC334BEF67}.Release|Any CPU.ActiveCfg = Release|Any CPU
{80C3DA6D-EDBE-47A5-BC6B-93BC334BEF67}.Release|Any CPU.Build.0 = Release|Any CPU
{3CDE6A69-09BA-4714-8DCD-D934BA27EBEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3CDE6A69-09BA-4714-8DCD-D934BA27EBEB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3CDE6A69-09BA-4714-8DCD-D934BA27EBEB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3CDE6A69-09BA-4714-8DCD-D934BA27EBEB}.Release|Any CPU.Build.0 = Release|Any CPU
{DE3AA974-14C3-402F-93F4-A4A5D3DC0131}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DE3AA974-14C3-402F-93F4-A4A5D3DC0131}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DE3AA974-14C3-402F-93F4-A4A5D3DC0131}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DE3AA974-14C3-402F-93F4-A4A5D3DC0131}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -68,5 +86,8 @@ Global
{78546E80-4971-4C23-B8CE-D64FCB34F1A1} = {74B81852-D3A9-49BA-A62F-A653FBB36665}
{3AE2AB1C-C9CE-4F1D-8D73-2BD13864F0D5} = {04A4BF42-A7C5-4D83-A137-90D6C3E68A00}
{0F89CC73-32D4-4347-B284-41804A8A54A9} = {04A4BF42-A7C5-4D83-A137-90D6C3E68A00}
{80C3DA6D-EDBE-47A5-BC6B-93BC334BEF67} = {04A4BF42-A7C5-4D83-A137-90D6C3E68A00}
{3CDE6A69-09BA-4714-8DCD-D934BA27EBEB} = {04A4BF42-A7C5-4D83-A137-90D6C3E68A00}
{DE3AA974-14C3-402F-93F4-A4A5D3DC0131} = {74B81852-D3A9-49BA-A62F-A653FBB36665}
EndGlobalSection
EndGlobal
89 changes: 76 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
[![Nuget](https://img.shields.io/nuget/dt/ModEndpoints)](https://www.nuget.org/packages/ModEndpoints/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/modabas/ModEndpoints/blob/main/LICENSE.txt)

WebResultEndpoints and ServiceResultEndpoints organize ASP.NET Core Minimal Apis in REPR format endpoints and are integrated with [result](https://github.com/modabas/ModResults) pattern out of box.
WebResultEndpoints and BusinessResultEndpoints organize ASP.NET Core Minimal Apis in REPR format endpoints and are integrated with [result](https://github.com/modabas/ModResults) pattern out of box.

# ModEndpoints.Core

Expand All @@ -14,7 +14,7 @@ Also contains core classes for ModEndpoints project.

## Introduction

The WebResultEndpoint and ServiceResultEndpoint abstractions are a structured approach to defining endpoints in ASP.NET Core applications. It extends the Minimal Api pattern with reusable, testable, and consistent components for request handling, validation, and response mapping.
The WebResultEndpoint and BusinessResultEndpoint abstractions are a structured approach to defining endpoints in ASP.NET Core applications. It extends the Minimal Api pattern with reusable, testable, and consistent components for request handling, validation, and response mapping.

## Key Features

Expand Down Expand Up @@ -280,7 +280,7 @@ See [test results](./samples/BenchmarkWebApi/BenchmarkFiles/inprocess_benchmark_

## Endpoint Types

WebResultEndpoint and ServiceResultEndpoint, the two abstract endpoint bases, have a 'HandleAsync' method which returns a strongly typed [business result](https://github.com/modabas/ModResults).
WebResultEndpoint and BusinessResultEndpoint, the two abstract endpoint bases, have a 'HandleAsync' method which returns a strongly typed [business result](https://github.com/modabas/ModResults).

These two endpoint types differ only in converting these business results into HTTP responses before sending response to client.

Expand All @@ -292,7 +292,7 @@ MinimalEndpoint within ModEndpoints.Core package, is closest to barebones Minima

Other features described previously are common for all of them.

Each type of andpoint has various implementations that accept a request model or not, that has a response model or not.
Each type of endpoint has various implementations that accept a request model or not, that has a response model or not.

### MinimalEndpoint

Expand All @@ -301,15 +301,6 @@ A MinimalEndpoint implementation, after handling request, returns the response m
- MinimalEndpoint&lt;TRequest, TResponse&gt;: Has a request model, supports request validation and returns a response model.
- MinimalEndpoint&lt;TResponse&gt;: Doesn't have a request model and returns a response model.

### ServiceResultEndpoint

A ServiceResultEndpoint implementation, after handling request, encapsulates the [business result](https://github.com/modabas/ModResults) of HandleAsync method in a HTTP 200 Minimal Api IResult and sends to client. The [business result](https://github.com/modabas/ModResults) returned may be in Ok or Failed state. This behaviour makes ServiceResultEndpoints more suitable for internal services, where clients are aware of Result or Result&lt;TValue&gt; implementations.

- ServiceResultEndpoint&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.
- ServiceResultEndpoint&lt;TRequest&gt;: Has a request model, supports request validation and returns a [Result](https://github.com/modabas/ModResults) within HTTP 200 IResult.
- ServiceResultEndpointWithEmptyRequest&lt;TResultValue&gt;: Doesn't have a request model and returns a [Result&lt;TResultValue&gt;](https://github.com/modabas/ModResults) within HTTP 200 IResult.
- ServiceResultEndpointWithEmptyRequest: Doesn't have a request model and returns a [Result](https://github.com/modabas/ModResults) within HTTP 200 IResult.

### WebResultEndpoint

A WebResultEndpoint implementation, after handling request, maps the [business result](https://github.com/modabas/ModResults) of HandleAsync method to a Minimal Api IResult depending on the business result type, state and failure type (if any). Mapping behaviour can be modified or replaced with a custom one.
Expand All @@ -336,3 +327,75 @@ It is also possible to implement a custom response mapping behaviour for a WebRe
- Create an IResultToResponseMapper implementation,
- Add it to dependency injection service collection with a string key during app startup,
- Apply ResultToResponseMapper attribute to endpoint classes that will be using custom mapper. Use service registration string key as Name property of attribute.

### BusinessResultEndpoint

A BusinessResultEndpoint implementation, after handling request, encapsulates the [business result](https://github.com/modabas/ModResults) of HandleAsync method in a HTTP 200 Minimal Api IResult and sends to client. The [business result](https://github.com/modabas/ModResults) returned may be in Ok or Failed state. This behaviour makes BusinessResultEndpoints more suitable for internal services, where clients are aware of Result or Result&lt;TValue&gt; implementations.

- BusinessResultEndpoint&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.
- BusinessResultEndpoint&lt;TRequest&gt;: Has a request model, supports request validation and returns a [Result](https://github.com/modabas/ModResults) within HTTP 200 IResult.
- BusinessResultEndpointWithEmptyRequest&lt;TResultValue&gt;: Doesn't have a request model and returns a [Result&lt;TResultValue&gt;](https://github.com/modabas/ModResults) within HTTP 200 IResult.
- BusinessResultEndpointWithEmptyRequest: Doesn't have a request model and returns a [Result](https://github.com/modabas/ModResults) within HTTP 200 IResult.


### ServiceEndpoint

This is a very specialized endpoint suitable for internal services. A ServiceEndpoint implementation, similar to BusinessResultEntpoint, encapsulates the response [business result](https://github.com/modabas/ModResults) of HandleAsync method in a HTTP 200 Minimal Api IResult and sends to client. The [business result](https://github.com/modabas/ModResults) returned may be in Ok or Failed state.

- 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.
- ServiceEndpoint&lt;TRequest&gt;: Has a request model, supports request validation and returns a [Result](https://github.com/modabas/ModResults) within HTTP 200 IResult.

A ServiceEndpoint has following special traits and constraints:
- A ServiceEndpoint is always registered with HttpMethod.Post method, and its bound pattern is determined accourding to its request type.
- A ServiceEndpoint's request must implement either IServiceRequest (for ServiceEndpoint&lt;TRequest&gt;) or IServiceRequest&lt;TResultValue&gt; (for ServiceEndpoint&lt;TRequest, TResultValue&gt;)
- A ServiceEndpoint's request is specific to that endpoint. Each endpoint must have its unique request type.
- To utilize the advantages of a ServiceEndpoint over other endpoint types, its request and response types has to be shared with clients and therefore has to be in a seperate class library.

These enable clients to call ServiceEndpoints by a specialized message channel resolved from dependency injection, which has to be registered at client application startup with only service base address and service request type information. No other knowledge about service or client implementation is required.

Have a look at [sample ServiceEndpoint implementations](https://github.com/modabas/ModEndpoints/tree/main/samples/ShowcaseWebApi/Features/StoresWithServiceEndpoints) along with [sample client implementation](https://github.com/modabas/ModEndpoints/tree/main/samples/Client) and [request/response model shared library](https://github.com/modabas/ModEndpoints/tree/main/samples/ShowcaseWebApi.FeatureContracts).

A client has to register remote services from requests in an assembly during application startup, which utilizes IHttpClientFactory and HttpClient underneath and can be configured similarly...
```csharp
var baseAddress = "https://...";
var clientName = "MyClient";
builder.Services.AddRemoteServicesWithNewClient(
typeof(ListStoresRequest).Assembly,
clientName,
(sp, client) =>
{
client.BaseAddress = new Uri(baseAddress);
client.Timeout = TimeSpan.FromSeconds(5);
})?.AddTransientHttpErrorPolicy(
policyBuilder => policyBuilder.CircuitBreakerAsync(5, TimeSpan.FromSeconds(30)));
```

or alternatively, register remote services one by one...
```csharp
var baseAddress = "https://...";
var clientName = "MyClient";
builder.Services.AddRemoteServiceWithNewClient<ListStoresRequest>(clientName,
(sp, client) =>
{
client.BaseAddress = new Uri(baseAddress);
client.Timeout = TimeSpan.FromSeconds(5);
})
.AddTransientHttpErrorPolicy(
policyBuilder => policyBuilder.CircuitBreakerAsync(5, TimeSpan.FromSeconds(30)));
builder.Services.AddRemoteServiceToExistingClient<GetStoreByIdRequest>(clientName);
builder.Services.AddRemoteServiceToExistingClient<DeleteStoreRequest>(clientName);
builder.Services.AddRemoteServiceToExistingClient<CreateStoreRequest>(clientName);
builder.Services.AddRemoteServiceToExistingClient<UpdateStoreRequest>(clientName);
```

Then call remote services with IServiceChannel instance resolved from DI...
```csharp
using IServiceScope serviceScope = hostProvider.CreateScope();
IServiceProvider provider = serviceScope.ServiceProvider;

//resolve service channel from DI
var channel = provider.GetRequiredService<IServiceChannel>();
//send request over channel to remote service
var listResult = await channel.SendAsync<ListStoresRequest, ListStoresResponse>(new ListStoresRequest(), ct);

```
8 changes: 7 additions & 1 deletion samples/Client/Client.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="ModResults" Version="0.2.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="9.0.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\ModEndpoints.RemoteServices\ModEndpoints.RemoteServices.csproj" />
<ProjectReference Include="..\ShowcaseWebApi.FeatureContracts\ShowcaseWebApi.FeatureContracts.csproj" />
</ItemGroup>

</Project>
3 changes: 0 additions & 3 deletions samples/Client/ListStores.cs

This file was deleted.

80 changes: 66 additions & 14 deletions samples/Client/Program.cs
Original file line number Diff line number Diff line change
@@ -1,24 +1,76 @@
// See https://aka.ms/new-console-template for more information

using Client;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using ModEndpoints.RemoteServices;
using ModResults;
using Polly;
using ShowcaseWebApi.FeatureContracts.Features.StoresWithServiceEndpoint;

using (var client = new HttpClient())
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);

var baseAddress = "https://localhost:7012/api/v1/storesWithServiceEndpoint/";
var clientName = "ShowcaseApi.Client";
//builder.Services.AddRemoteServiceWithNewClient<ListStoresRequest>(clientName,
// (sp, client) =>
// {
// client.BaseAddress = new Uri(baseAddress);
// client.Timeout = TimeSpan.FromSeconds(5);
// })
// .AddTransientHttpErrorPolicy(
// policyBuilder => policyBuilder.CircuitBreakerAsync(5, TimeSpan.FromSeconds(30)));
//builder.Services.AddRemoteServiceToExistingClient<GetStoreByIdRequest>(clientName);
//builder.Services.AddRemoteServiceToExistingClient<DeleteStoreRequest>(clientName);
//builder.Services.AddRemoteServiceToExistingClient<CreateStoreRequest>(clientName);
//builder.Services.AddRemoteServiceToExistingClient<UpdateStoreRequest>(clientName);
builder.Services.AddRemoteServicesWithNewClient(
typeof(ListStoresRequest).Assembly,
clientName,
(sp, client) =>
{
client.BaseAddress = new Uri(baseAddress);
client.Timeout = TimeSpan.FromSeconds(5);
})?.AddTransientHttpErrorPolicy(
policyBuilder => policyBuilder.CircuitBreakerAsync(5, TimeSpan.FromSeconds(30)));

using IHost host = builder.Build();

await CallRemoteServicesAsync(host.Services);

await host.RunAsync();

static async Task CallRemoteServicesAsync(IServiceProvider hostProvider)
{
var response = await client.GetAsync("http://localhost:5077/api/v1/stores");
var result = await response.DeserializeResultAsync<ListStoresResponse>(default);
if (result.IsOk)
using IServiceScope serviceScope = hostProvider.CreateScope();
IServiceProvider provider = serviceScope.ServiceProvider;

//resolve service channel from DI
var channel = provider.GetRequiredService<IServiceChannel>();
//send request over channel to remote service
var listResult = await channel.SendAsync<ListStoresRequest, ListStoresResponse>(new ListStoresRequest(), default);

if (listResult.IsOk)
{
Console.WriteLine($"ListStores complete. Total count: {result.Value.Stores.Count}");
Console.WriteLine($"ListStores complete. Total count: {listResult.Value.Stores.Count}");
var id = listResult.Value.Stores.FirstOrDefault()?.Id;
if (id is not null)
{
//send request over channel to remote service
var getResult = await channel.SendAsync<GetStoreByIdRequest, GetStoreByIdResponse>(new GetStoreByIdRequest(Id: id.Value), default);
if (getResult.IsOk)
{
Console.WriteLine(getResult.Value);
}
else
{
Console.WriteLine(getResult.DumpMessages());
}
}
}
else
{
if (result.Failure.Errors.Count > 0)
{
Console.WriteLine(string.Join(Environment.NewLine, result.Failure.Errors.Select(e => e.Message)));
}
else
{
Console.WriteLine($"ListStores failed.");
}
Console.WriteLine(listResult.DumpMessages());
}

Console.WriteLine();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
using ModEndpoints.RemoteServices.Core;

namespace ShowcaseWebApi.FeatureContracts.Features.StoresWithServiceEndpoint;

public record CreateStoreRequest(string Name) : IServiceRequest<CreateStoreResponse>;

Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
namespace ShowcaseWebApi.FeatureContracts.Features.StoresWithServiceEndpoint;

public record CreateStoreResponse(Guid Id);

Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
using ModEndpoints.RemoteServices.Core;

namespace ShowcaseWebApi.FeatureContracts.Features.StoresWithServiceEndpoint;

public record DeleteStoreRequest(Guid Id) : IServiceRequest;
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
using ModEndpoints.RemoteServices.Core;

namespace ShowcaseWebApi.FeatureContracts.Features.StoresWithServiceEndpoint;

public record GetStoreByIdRequest(Guid Id) : IServiceRequest<GetStoreByIdResponse>;

Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
namespace ShowcaseWebApi.FeatureContracts.Features.StoresWithServiceEndpoint;

public record GetStoreByIdResponse(Guid Id, string Name);

Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
using ModEndpoints.RemoteServices.Core;

namespace ShowcaseWebApi.FeatureContracts.Features.StoresWithServiceEndpoint;

public record ListStoresRequest() : IServiceRequest<ListStoresResponse>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace ShowcaseWebApi.FeatureContracts.Features.StoresWithServiceEndpoint;

public record ListStoresResponse(List<ListStoresResponseItem> Stores);
Loading