diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8274a49..ad28a9f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,8 @@ on: env: COREPROJECTNAME: "ModEndpoints.Core" ENDPOINTSPROJECTNAME: "ModEndpoints" + REMOTESERVICESCOREPROJECTNAME: "ModEndpoints.RemoteServices.Core" + REMOTESERVICESPROJECTNAME: "ModEndpoints.RemoteServices" jobs: build: @@ -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 diff --git a/Directory.Build.props b/Directory.Build.props index 075e998..46358e6 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -15,6 +15,6 @@ README.md MIT - 0.2.0 + 0.3.0 \ No newline at end of file diff --git a/ModEndpoints.sln b/ModEndpoints.sln index 42e5421..8d1fbd9 100644 --- a/ModEndpoints.sln +++ b/ModEndpoints.sln @@ -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 @@ -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 @@ -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 diff --git a/README.md b/README.md index 5fe9c29..e0af483 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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. @@ -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 @@ -301,15 +301,6 @@ A MinimalEndpoint implementation, after handling request, returns the response m - MinimalEndpoint<TRequest, TResponse>: Has a request model, supports request validation and returns a response model. - MinimalEndpoint<TResponse>: 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<TValue> implementations. - -- ServiceResultEndpoint<TRequest, TResultValue>: Has a request model, supports request validation and returns a [Result<TResultValue>](https://github.com/modabas/ModResults) within HTTP 200 IResult. -- ServiceResultEndpoint<TRequest>: Has a request model, supports request validation and returns a [Result](https://github.com/modabas/ModResults) within HTTP 200 IResult. -- ServiceResultEndpointWithEmptyRequest<TResultValue>: Doesn't have a request model and returns a [Result<TResultValue>](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. @@ -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<TValue> implementations. + +- BusinessResultEndpoint<TRequest, TResultValue>: Has a request model, supports request validation and returns a [Result<TResultValue>](https://github.com/modabas/ModResults) within HTTP 200 IResult. +- BusinessResultEndpoint<TRequest>: Has a request model, supports request validation and returns a [Result](https://github.com/modabas/ModResults) within HTTP 200 IResult. +- BusinessResultEndpointWithEmptyRequest<TResultValue>: Doesn't have a request model and returns a [Result<TResultValue>](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<TRequest, TResultValue>: Has a request model, supports request validation and returns a [Result<TResultValue>](https://github.com/modabas/ModResults) within HTTP 200 IResult. +- ServiceEndpoint<TRequest>: 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<TRequest>) or IServiceRequest<TResultValue> (for ServiceEndpoint<TRequest, TResultValue>) +- 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(clientName, + (sp, client) => + { + client.BaseAddress = new Uri(baseAddress); + client.Timeout = TimeSpan.FromSeconds(5); + }) + .AddTransientHttpErrorPolicy( + policyBuilder => policyBuilder.CircuitBreakerAsync(5, TimeSpan.FromSeconds(30))); +builder.Services.AddRemoteServiceToExistingClient(clientName); +builder.Services.AddRemoteServiceToExistingClient(clientName); +builder.Services.AddRemoteServiceToExistingClient(clientName); +builder.Services.AddRemoteServiceToExistingClient(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(); + //send request over channel to remote service + var listResult = await channel.SendAsync(new ListStoresRequest(), ct); + +``` \ No newline at end of file diff --git a/samples/Client/Client.csproj b/samples/Client/Client.csproj index 19799e5..8dccd8b 100644 --- a/samples/Client/Client.csproj +++ b/samples/Client/Client.csproj @@ -6,7 +6,13 @@ - + + + + + + + diff --git a/samples/Client/ListStores.cs b/samples/Client/ListStores.cs deleted file mode 100644 index 392e7a2..0000000 --- a/samples/Client/ListStores.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Client; -public record ListStoresResponse(List Stores); -public record ListStoresResponseItem(Guid Id, string Name); diff --git a/samples/Client/Program.cs b/samples/Client/Program.cs index 0f405ff..a6b8e21 100644 --- a/samples/Client/Program.cs +++ b/samples/Client/Program.cs @@ -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(clientName, +// (sp, client) => +// { +// client.BaseAddress = new Uri(baseAddress); +// client.Timeout = TimeSpan.FromSeconds(5); +// }) +// .AddTransientHttpErrorPolicy( +// policyBuilder => policyBuilder.CircuitBreakerAsync(5, TimeSpan.FromSeconds(30))); +//builder.Services.AddRemoteServiceToExistingClient(clientName); +//builder.Services.AddRemoteServiceToExistingClient(clientName); +//builder.Services.AddRemoteServiceToExistingClient(clientName); +//builder.Services.AddRemoteServiceToExistingClient(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(default); - if (result.IsOk) + using IServiceScope serviceScope = hostProvider.CreateScope(); + IServiceProvider provider = serviceScope.ServiceProvider; + + //resolve service channel from DI + var channel = provider.GetRequiredService(); + //send request over channel to remote service + var listResult = await channel.SendAsync(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(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(); } diff --git a/samples/ShowcaseWebApi.FeatureContracts/Features/StoresWithServiceEndpoint/CreateStoreRequest.cs b/samples/ShowcaseWebApi.FeatureContracts/Features/StoresWithServiceEndpoint/CreateStoreRequest.cs new file mode 100644 index 0000000..5a4c954 --- /dev/null +++ b/samples/ShowcaseWebApi.FeatureContracts/Features/StoresWithServiceEndpoint/CreateStoreRequest.cs @@ -0,0 +1,6 @@ +using ModEndpoints.RemoteServices.Core; + +namespace ShowcaseWebApi.FeatureContracts.Features.StoresWithServiceEndpoint; + +public record CreateStoreRequest(string Name) : IServiceRequest; + diff --git a/samples/ShowcaseWebApi.FeatureContracts/Features/StoresWithServiceEndpoint/CreateStoreResponse.cs b/samples/ShowcaseWebApi.FeatureContracts/Features/StoresWithServiceEndpoint/CreateStoreResponse.cs new file mode 100644 index 0000000..ba67702 --- /dev/null +++ b/samples/ShowcaseWebApi.FeatureContracts/Features/StoresWithServiceEndpoint/CreateStoreResponse.cs @@ -0,0 +1,4 @@ +namespace ShowcaseWebApi.FeatureContracts.Features.StoresWithServiceEndpoint; + +public record CreateStoreResponse(Guid Id); + diff --git a/samples/ShowcaseWebApi.FeatureContracts/Features/StoresWithServiceEndpoint/DeleteStoreRequest.cs b/samples/ShowcaseWebApi.FeatureContracts/Features/StoresWithServiceEndpoint/DeleteStoreRequest.cs new file mode 100644 index 0000000..fe13b96 --- /dev/null +++ b/samples/ShowcaseWebApi.FeatureContracts/Features/StoresWithServiceEndpoint/DeleteStoreRequest.cs @@ -0,0 +1,5 @@ +using ModEndpoints.RemoteServices.Core; + +namespace ShowcaseWebApi.FeatureContracts.Features.StoresWithServiceEndpoint; + +public record DeleteStoreRequest(Guid Id) : IServiceRequest; diff --git a/samples/ShowcaseWebApi.FeatureContracts/Features/StoresWithServiceEndpoint/GetStoreByIdRequest.cs b/samples/ShowcaseWebApi.FeatureContracts/Features/StoresWithServiceEndpoint/GetStoreByIdRequest.cs new file mode 100644 index 0000000..8393abc --- /dev/null +++ b/samples/ShowcaseWebApi.FeatureContracts/Features/StoresWithServiceEndpoint/GetStoreByIdRequest.cs @@ -0,0 +1,6 @@ +using ModEndpoints.RemoteServices.Core; + +namespace ShowcaseWebApi.FeatureContracts.Features.StoresWithServiceEndpoint; + +public record GetStoreByIdRequest(Guid Id) : IServiceRequest; + diff --git a/samples/ShowcaseWebApi.FeatureContracts/Features/StoresWithServiceEndpoint/GetStoreByIdResponse.cs b/samples/ShowcaseWebApi.FeatureContracts/Features/StoresWithServiceEndpoint/GetStoreByIdResponse.cs new file mode 100644 index 0000000..146ffb6 --- /dev/null +++ b/samples/ShowcaseWebApi.FeatureContracts/Features/StoresWithServiceEndpoint/GetStoreByIdResponse.cs @@ -0,0 +1,4 @@ +namespace ShowcaseWebApi.FeatureContracts.Features.StoresWithServiceEndpoint; + +public record GetStoreByIdResponse(Guid Id, string Name); + diff --git a/samples/ShowcaseWebApi.FeatureContracts/Features/StoresWithServiceEndpoint/ListStoresRequest.cs b/samples/ShowcaseWebApi.FeatureContracts/Features/StoresWithServiceEndpoint/ListStoresRequest.cs new file mode 100644 index 0000000..96ca777 --- /dev/null +++ b/samples/ShowcaseWebApi.FeatureContracts/Features/StoresWithServiceEndpoint/ListStoresRequest.cs @@ -0,0 +1,5 @@ +using ModEndpoints.RemoteServices.Core; + +namespace ShowcaseWebApi.FeatureContracts.Features.StoresWithServiceEndpoint; + +public record ListStoresRequest() : IServiceRequest; diff --git a/samples/ShowcaseWebApi.FeatureContracts/Features/StoresWithServiceEndpoint/ListStoresResponse.cs b/samples/ShowcaseWebApi.FeatureContracts/Features/StoresWithServiceEndpoint/ListStoresResponse.cs new file mode 100644 index 0000000..0615ddb --- /dev/null +++ b/samples/ShowcaseWebApi.FeatureContracts/Features/StoresWithServiceEndpoint/ListStoresResponse.cs @@ -0,0 +1,3 @@ +namespace ShowcaseWebApi.FeatureContracts.Features.StoresWithServiceEndpoint; + +public record ListStoresResponse(List Stores); diff --git a/samples/ShowcaseWebApi.FeatureContracts/Features/StoresWithServiceEndpoint/ListStoresResponseItem.cs b/samples/ShowcaseWebApi.FeatureContracts/Features/StoresWithServiceEndpoint/ListStoresResponseItem.cs new file mode 100644 index 0000000..6703e37 --- /dev/null +++ b/samples/ShowcaseWebApi.FeatureContracts/Features/StoresWithServiceEndpoint/ListStoresResponseItem.cs @@ -0,0 +1,3 @@ +namespace ShowcaseWebApi.FeatureContracts.Features.StoresWithServiceEndpoint; + +public record ListStoresResponseItem(Guid Id, string Name); diff --git a/samples/ShowcaseWebApi.FeatureContracts/Features/StoresWithServiceEndpoint/UpdateStoreRequest.cs b/samples/ShowcaseWebApi.FeatureContracts/Features/StoresWithServiceEndpoint/UpdateStoreRequest.cs new file mode 100644 index 0000000..0ee0ad0 --- /dev/null +++ b/samples/ShowcaseWebApi.FeatureContracts/Features/StoresWithServiceEndpoint/UpdateStoreRequest.cs @@ -0,0 +1,5 @@ +using ModEndpoints.RemoteServices.Core; + +namespace ShowcaseWebApi.FeatureContracts.Features.StoresWithServiceEndpoint; + +public record UpdateStoreRequest(Guid Id, string Name) : IServiceRequest; diff --git a/samples/ShowcaseWebApi.FeatureContracts/Features/StoresWithServiceEndpoint/UpdateStoreResponse.cs b/samples/ShowcaseWebApi.FeatureContracts/Features/StoresWithServiceEndpoint/UpdateStoreResponse.cs new file mode 100644 index 0000000..bf0daa1 --- /dev/null +++ b/samples/ShowcaseWebApi.FeatureContracts/Features/StoresWithServiceEndpoint/UpdateStoreResponse.cs @@ -0,0 +1,3 @@ +namespace ShowcaseWebApi.FeatureContracts.Features.StoresWithServiceEndpoint; + +public record UpdateStoreResponse(Guid Id, string Name); diff --git a/samples/ShowcaseWebApi.FeatureContracts/ShowcaseWebApi.FeatureContracts.csproj b/samples/ShowcaseWebApi.FeatureContracts/ShowcaseWebApi.FeatureContracts.csproj new file mode 100644 index 0000000..adb8923 --- /dev/null +++ b/samples/ShowcaseWebApi.FeatureContracts/ShowcaseWebApi.FeatureContracts.csproj @@ -0,0 +1,11 @@ + + + + net8.0 + + + + + + + diff --git a/samples/ShowcaseWebApi/Features/Stores/CreateStore.cs b/samples/ShowcaseWebApi/Features/Stores/CreateStore.cs index d7777a0..1693e14 100644 --- a/samples/ShowcaseWebApi/Features/Stores/CreateStore.cs +++ b/samples/ShowcaseWebApi/Features/Stores/CreateStore.cs @@ -22,7 +22,7 @@ public CreateStoreRequestValidator() [RouteGroupMember(typeof(StoresRouteGroup))] internal class CreateStore(ServiceDbContext db) - : ServiceResultEndpoint + : BusinessResultEndpoint { protected override void Configure( IServiceProvider serviceProvider, diff --git a/samples/ShowcaseWebApi/Features/Stores/DeleteStore.cs b/samples/ShowcaseWebApi/Features/Stores/DeleteStore.cs index ca83ce5..cb4902a 100644 --- a/samples/ShowcaseWebApi/Features/Stores/DeleteStore.cs +++ b/samples/ShowcaseWebApi/Features/Stores/DeleteStore.cs @@ -19,7 +19,7 @@ public DeleteStoreRequestValidator() [RouteGroupMember(typeof(StoresRouteGroup))] internal class DeleteStore(ServiceDbContext db) - : ServiceResultEndpoint + : BusinessResultEndpoint { protected override void Configure( IServiceProvider serviceProvider, diff --git a/samples/ShowcaseWebApi/Features/Stores/GetStoreById.cs b/samples/ShowcaseWebApi/Features/Stores/GetStoreById.cs index 713ceba..98a1cf3 100644 --- a/samples/ShowcaseWebApi/Features/Stores/GetStoreById.cs +++ b/samples/ShowcaseWebApi/Features/Stores/GetStoreById.cs @@ -21,7 +21,7 @@ public GetStoreByIdRequestValidator() [RouteGroupMember(typeof(StoresRouteGroup))] internal class GetStoreById(ServiceDbContext db) - : ServiceResultEndpoint + : BusinessResultEndpoint { protected override void Configure( IServiceProvider serviceProvider, diff --git a/samples/ShowcaseWebApi/Features/Stores/ListStores.cs b/samples/ShowcaseWebApi/Features/Stores/ListStores.cs index e8eaf45..73d9789 100644 --- a/samples/ShowcaseWebApi/Features/Stores/ListStores.cs +++ b/samples/ShowcaseWebApi/Features/Stores/ListStores.cs @@ -12,7 +12,7 @@ public record ListStoresResponseItem(Guid Id, string Name); [RouteGroupMember(typeof(StoresRouteGroup))] internal class ListStores(ServiceDbContext db) - : ServiceResultEndpointWithEmptyRequest + : BusinessResultEndpointWithEmptyRequest { protected override void Configure( IServiceProvider serviceProvider, diff --git a/samples/ShowcaseWebApi/Features/Stores/UpdateStore.cs b/samples/ShowcaseWebApi/Features/Stores/UpdateStore.cs index 52bec69..8740470 100644 --- a/samples/ShowcaseWebApi/Features/Stores/UpdateStore.cs +++ b/samples/ShowcaseWebApi/Features/Stores/UpdateStore.cs @@ -25,7 +25,7 @@ public UpdateStoreRequestValidator() [RouteGroupMember(typeof(StoresRouteGroup))] internal class UpdateStore(ServiceDbContext db) - : ServiceResultEndpoint + : BusinessResultEndpoint { protected override void Configure( IServiceProvider serviceProvider, diff --git a/samples/ShowcaseWebApi/Features/StoresWithServiceEndpoint/Configuration/StoresWithServiceEndpointRouteGroup.cs b/samples/ShowcaseWebApi/Features/StoresWithServiceEndpoint/Configuration/StoresWithServiceEndpointRouteGroup.cs new file mode 100644 index 0000000..b58048f --- /dev/null +++ b/samples/ShowcaseWebApi/Features/StoresWithServiceEndpoint/Configuration/StoresWithServiceEndpointRouteGroup.cs @@ -0,0 +1,17 @@ +using ModEndpoints.Core; + +namespace ShowcaseWebApi.Features.StoresWithServiceEndpoint.Configuration; + +[RouteGroupMember(typeof(FeaturesRouteGroup))] +internal class StoresWithServiceEndpointRouteGroup : RouteGroupConfigurator +{ + protected override void Configure( + IServiceProvider serviceProvider, + IRouteGroupConfigurator? parentRouteGroup) + { + MapGroup("/storesWithServiceEndpoint") + .MapToApiVersion(1) + .MapToApiVersion(2) + .WithTags("/StoresWithServiceEndpoint"); + } +} diff --git a/samples/ShowcaseWebApi/Features/StoresWithServiceEndpoint/CreateStore.cs b/samples/ShowcaseWebApi/Features/StoresWithServiceEndpoint/CreateStore.cs new file mode 100644 index 0000000..8c311ce --- /dev/null +++ b/samples/ShowcaseWebApi/Features/StoresWithServiceEndpoint/CreateStore.cs @@ -0,0 +1,37 @@ +using FluentValidation; +using ModEndpoints; +using ModEndpoints.Core; +using ModResults; +using ShowcaseWebApi.Data; +using ShowcaseWebApi.FeatureContracts.Features.StoresWithServiceEndpoint; +using ShowcaseWebApi.Features.Stores.Data; +using ShowcaseWebApi.Features.StoresWithServiceEndpoint.Configuration; + +namespace ShowcaseWebApi.Features.StoresWithServiceEndpoint; +internal class CreateStoreRequestValidator : AbstractValidator +{ + public CreateStoreRequestValidator() + { + RuleFor(x => x.Name).NotEmpty(); + } +} + +[RouteGroupMember(typeof(StoresWithServiceEndpointRouteGroup))] +internal class CreateStore(ServiceDbContext db) + : ServiceEndpoint +{ + protected override async Task> HandleAsync( + CreateStoreRequest req, + CancellationToken ct) + { + var Store = new StoreEntity() + { + Name = req.Name + }; + + db.Stores.Add(Store); + await db.SaveChangesAsync(ct); + return Result.Ok(new CreateStoreResponse(Store.Id)); + } +} + diff --git a/samples/ShowcaseWebApi/Features/StoresWithServiceEndpoint/DeleteStore.cs b/samples/ShowcaseWebApi/Features/StoresWithServiceEndpoint/DeleteStore.cs new file mode 100644 index 0000000..9be7e2c --- /dev/null +++ b/samples/ShowcaseWebApi/Features/StoresWithServiceEndpoint/DeleteStore.cs @@ -0,0 +1,38 @@ +using FluentValidation; +using Microsoft.EntityFrameworkCore; +using ModEndpoints; +using ModEndpoints.Core; +using ModResults; +using ShowcaseWebApi.Data; +using ShowcaseWebApi.FeatureContracts.Features.StoresWithServiceEndpoint; +using ShowcaseWebApi.Features.StoresWithServiceEndpoint.Configuration; + +namespace ShowcaseWebApi.Features.StoresWithServiceEndpoint; +internal class DeleteStoreRequestValidator : AbstractValidator +{ + public DeleteStoreRequestValidator() + { + RuleFor(x => x.Id).NotEmpty(); + } +} + +[RouteGroupMember(typeof(StoresWithServiceEndpointRouteGroup))] +internal class DeleteStore(ServiceDbContext db) + : ServiceEndpoint +{ + protected override async Task HandleAsync( + DeleteStoreRequest req, + CancellationToken ct) + { + var entity = await db.Stores.FirstOrDefaultAsync(s => s.Id == req.Id, ct); + + if (entity is null) + { + return Result.NotFound(); + } + + db.Stores.Remove(entity); + var deleted = await db.SaveChangesAsync(ct); + return deleted > 0 ? Result.Ok() : Result.NotFound(); + } +} diff --git a/samples/ShowcaseWebApi/Features/StoresWithServiceEndpoint/GetStoreById.cs b/samples/ShowcaseWebApi/Features/StoresWithServiceEndpoint/GetStoreById.cs new file mode 100644 index 0000000..2c74d00 --- /dev/null +++ b/samples/ShowcaseWebApi/Features/StoresWithServiceEndpoint/GetStoreById.cs @@ -0,0 +1,39 @@ +using FluentValidation; +using Microsoft.EntityFrameworkCore; +using ModEndpoints; +using ModEndpoints.Core; +using ModResults; +using ShowcaseWebApi.Data; +using ShowcaseWebApi.FeatureContracts.Features.StoresWithServiceEndpoint; +using ShowcaseWebApi.Features.StoresWithServiceEndpoint.Configuration; + +namespace ShowcaseWebApi.Features.StoresWithServiceEndpoint; + +internal class GetStoreByIdRequestValidator : AbstractValidator +{ + public GetStoreByIdRequestValidator() + { + RuleFor(x => x.Id).NotEmpty(); + } +} + +[RouteGroupMember(typeof(StoresWithServiceEndpointRouteGroup))] +internal class GetStoreById(ServiceDbContext db) + : ServiceEndpoint +{ + protected override async Task> HandleAsync( + GetStoreByIdRequest req, + CancellationToken ct) + { + var entity = await db.Stores.FirstOrDefaultAsync(s => s.Id == req.Id, ct); + + var result = entity is null ? + Result.NotFound() : + Result.Ok(new GetStoreByIdResponse( + Id: entity.Id, + Name: entity.Name)); + + return result; + } +} + diff --git a/samples/ShowcaseWebApi/Features/StoresWithServiceEndpoint/ListStores.cs b/samples/ShowcaseWebApi/Features/StoresWithServiceEndpoint/ListStores.cs new file mode 100644 index 0000000..0cc97f3 --- /dev/null +++ b/samples/ShowcaseWebApi/Features/StoresWithServiceEndpoint/ListStores.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore; +using ModEndpoints; +using ModEndpoints.Core; +using ModResults; +using ShowcaseWebApi.Data; +using ShowcaseWebApi.FeatureContracts.Features.StoresWithServiceEndpoint; +using ShowcaseWebApi.Features.StoresWithServiceEndpoint.Configuration; + +namespace ShowcaseWebApi.Features.StoresWithServiceEndpoint; + +[RouteGroupMember(typeof(StoresWithServiceEndpointRouteGroup))] +internal class ListStores(ServiceDbContext db) + : ServiceEndpoint +{ + protected override async Task> HandleAsync( + ListStoresRequest req, + CancellationToken ct) + { + var stores = await db.Stores + .Select(b => new ListStoresResponseItem( + b.Id, + b.Name)) + .ToListAsync(ct); + + return new ListStoresResponse(Stores: stores); + } +} diff --git a/samples/ShowcaseWebApi/Features/StoresWithServiceEndpoint/StoresWithServiceEndpoint.http b/samples/ShowcaseWebApi/Features/StoresWithServiceEndpoint/StoresWithServiceEndpoint.http new file mode 100644 index 0000000..48e1e60 --- /dev/null +++ b/samples/ShowcaseWebApi/Features/StoresWithServiceEndpoint/StoresWithServiceEndpoint.http @@ -0,0 +1,54 @@ +# For more info on HTTP files go to https://aka.ms/vs/httpfile + +@BaseUrl =https://localhost:7012 + +###ListStores +POST {{BaseUrl}}/api/v1/storesWithServiceEndpoint/ShowcaseWebApi.FeatureContracts.Features.StoresWithServiceEndpoint.ListStoresRequest.Endpoint +Accept: application/json +Content-Type: application/json +Accept-Language: en-US + +{ +} + +###GetStoreById +POST {{BaseUrl}}/api/v1/storesWithServiceEndpoint/ShowcaseWebApi.FeatureContracts.Features.StoresWithServiceEndpoint.GetStoreByIdRequest.Endpoint +Accept: application/json +Content-Type: application/json +Accept-Language: en-US + +{ + "id": "00000000-0000-0000-0000-000000000001" +} + +###CreateStore +POST {{BaseUrl}}/api/v1/storesWithServiceEndpoint/ShowcaseWebApi.FeatureContracts.Features.StoresWithServiceEndpoint.CreateStoreRequest.Endpoint +Accept: application/json +Content-Type: application/json +Accept-Language: en-US + +{ + "name": "Big Bad Store" +} + +###UpdateStore +POST {{BaseUrl}}/api/v1/storesWithServiceEndpoint/ShowcaseWebApi.FeatureContracts.Features.StoresWithServiceEndpoint.UpdateStoreRequest.Endpoint +Accept: application/json +Content-Type: application/json +Accept-Language: en-US + +{ + "id": "00000000-0000-0000-0000-000000000001", + "name": "Big Bad Store" +} + +###DeleteStore +POST {{BaseUrl}}/api/v1/storesWithServiceEndpoint/ShowcaseWebApi.FeatureContracts.Features.StoresWithServiceEndpoint.DeleteStoreRequest.Endpoint +Accept: application/json +Content-Type: application/json +Accept-Language: en-US + +{ + "id": "00000000-0000-0000-0000-000000000001", + "name": "Big Bad Store" +} diff --git a/samples/ShowcaseWebApi/Features/StoresWithServiceEndpoint/UpdateStore.cs b/samples/ShowcaseWebApi/Features/StoresWithServiceEndpoint/UpdateStore.cs new file mode 100644 index 0000000..f035c12 --- /dev/null +++ b/samples/ShowcaseWebApi/Features/StoresWithServiceEndpoint/UpdateStore.cs @@ -0,0 +1,45 @@ +using FluentValidation; +using Microsoft.EntityFrameworkCore; +using ModEndpoints; +using ModEndpoints.Core; +using ModResults; +using ShowcaseWebApi.Data; +using ShowcaseWebApi.FeatureContracts.Features.StoresWithServiceEndpoint; +using ShowcaseWebApi.Features.StoresWithServiceEndpoint.Configuration; + +namespace ShowcaseWebApi.Features.StoresWithServiceEndpoint; + +internal class UpdateStoreRequestValidator : AbstractValidator +{ + public UpdateStoreRequestValidator() + { + RuleFor(x => x.Id).NotEmpty(); + RuleFor(x => x.Name).NotEmpty(); + } +} + +[RouteGroupMember(typeof(StoresWithServiceEndpointRouteGroup))] +internal class UpdateStore(ServiceDbContext db) + : ServiceEndpoint +{ + protected override async Task> HandleAsync( + UpdateStoreRequest req, + CancellationToken ct) + { + var entity = await db.Stores.FirstOrDefaultAsync(s => s.Id == req.Id, ct); + + if (entity is null) + { + return Result.NotFound(); + } + + entity.Name = req.Name; + + var updated = await db.SaveChangesAsync(ct); + return updated > 0 ? + Result.Ok(new UpdateStoreResponse( + Id: req.Id, + Name: req.Name)) + : Result.NotFound(); + } +} diff --git a/samples/ShowcaseWebApi/ShowcaseWebApi.csproj b/samples/ShowcaseWebApi/ShowcaseWebApi.csproj index afc1134..815b1f5 100644 --- a/samples/ShowcaseWebApi/ShowcaseWebApi.csproj +++ b/samples/ShowcaseWebApi/ShowcaseWebApi.csproj @@ -15,5 +15,6 @@ + diff --git a/src/ModEndpoints.Core/DependencyInjectionExtensions.cs b/src/ModEndpoints.Core/DependencyInjectionExtensions.cs index b93bd63..c69be50 100644 --- a/src/ModEndpoints.Core/DependencyInjectionExtensions.cs +++ b/src/ModEndpoints.Core/DependencyInjectionExtensions.cs @@ -4,6 +4,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; +using ModEndpoints.RemoteServices.Core; namespace ModEndpoints.Core; public static class DependencyInjectionExtensions @@ -36,14 +37,18 @@ private static IServiceCollection AddRouteGroupsFromAssemblyCore( return services; } - private static IServiceCollection AddEndpointsFromAssemblyCore( + public static IServiceCollection AddEndpointsFromAssemblyCore( this IServiceCollection services, Assembly assembly) { - var serviceDescriptors = assembly - .DefinedTypes - .Where(type => type is { IsAbstract: false, IsInterface: false } && - type.IsAssignableTo(typeof(IEndpointConfigurator))) + var endpointTypes = assembly + .DefinedTypes + .Where(type => type is { IsAbstract: false, IsInterface: false } && + type.IsAssignableTo(typeof(IEndpointConfigurator))); + + CheckServiceEndpointRegistrations(endpointTypes); + + var serviceDescriptors = endpointTypes .Select(type => ServiceDescriptor.KeyedTransient(typeof(IEndpointConfigurator), type, type)) .ToArray(); @@ -52,6 +57,61 @@ private static IServiceCollection AddEndpointsFromAssemblyCore( return services; } + private static void CheckServiceEndpointRegistrations(IEnumerable endpointTypes) + { + var serviceEndpointTypes = endpointTypes.Where(type => IsAssignableFrom(type, typeof(BaseServiceEndpoint<,>))).ToList(); + foreach (var serviceEndpointType in serviceEndpointTypes) + { + var requestType = GetGenericArgumentsOfBase(serviceEndpointType, typeof(BaseServiceEndpoint<,>)).Single(type => type.IsAssignableTo(typeof(IServiceRequestMarker))); + if (ServiceEndpointRegistry.Instance.IsRegistered(requestType)) + { + throw new InvalidOperationException($"An endpoint for request type {requestType} is already registered."); + } + if (!ServiceEndpointRegistry.Instance.Register(requestType, serviceEndpointType)) + { + throw new InvalidOperationException($"An endpoint of type {serviceEndpointType} couldn't be registered for request type {requestType}."); + } + } + return; + } + + private static Type[] GetGenericArgumentsOfBase(Type derivedType, Type baseType) + { + while (derivedType.BaseType != null) + { + derivedType = derivedType.BaseType; + if (derivedType.IsGenericType && derivedType.GetGenericTypeDefinition() == baseType) + { + return derivedType.GetGenericArguments(); + } + } + throw new InvalidOperationException("Base type was not found"); + } + + private static bool IsAssignableFrom(Type extendType, Type baseType) + { + while (!baseType.IsAssignableFrom(extendType)) + { + if (extendType.Equals(typeof(object))) + { + return false; + } + if (extendType.IsGenericType && !extendType.IsGenericTypeDefinition) + { + extendType = extendType.GetGenericTypeDefinition(); + } + else + { + if (extendType.BaseType is null) + { + return false; + } + extendType = extendType.BaseType; + } + } + return true; + } + //Configuration processing order: //Group Configuration -> Endpoint Configuration -> Global Endpoint Configuration -> Endpoint Override -> Group Endpoint Override -> Group Override //Global overrides apply to all endpoints diff --git a/src/ModEndpoints.Core/ModEndpoints.Core.csproj b/src/ModEndpoints.Core/ModEndpoints.Core.csproj index a3cb557..f2ce6de 100644 --- a/src/ModEndpoints.Core/ModEndpoints.Core.csproj +++ b/src/ModEndpoints.Core/ModEndpoints.Core.csproj @@ -10,9 +10,16 @@ - + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + @@ -21,4 +28,8 @@ + + + + diff --git a/src/ModEndpoints.Core/[Configuration]/ServiceEndpointConfigurator.cs b/src/ModEndpoints.Core/[Configuration]/ServiceEndpointConfigurator.cs new file mode 100644 index 0000000..3f70fff --- /dev/null +++ b/src/ModEndpoints.Core/[Configuration]/ServiceEndpointConfigurator.cs @@ -0,0 +1,55 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; + +namespace ModEndpoints.Core; +public abstract class ServiceEndpointConfigurator : IEndpointConfigurator +{ + protected abstract Delegate ExecuteDelegate { get; } + + private RouteHandlerBuilder? _handlerBuilder; + + public Dictionary? PropertyBag { get; set; } + + public virtual Action? ConfigurationOverrides => null; + + /// + /// Entry point for endpoint configuration. Called by DI. + /// + /// + /// + /// + /// A that can be used to further customize the endpoint. + public RouteHandlerBuilder? Configure( + IServiceProvider serviceProvider, + IEndpointRouteBuilder builder, + IRouteGroupConfigurator? parentRouteGroup) + { + _handlerBuilder = MapEndpoint(serviceProvider, builder, parentRouteGroup); + Configure(serviceProvider, parentRouteGroup); + return _handlerBuilder; + } + + protected abstract RouteHandlerBuilder? MapEndpoint( + IServiceProvider serviceProvider, + IEndpointRouteBuilder builder, + IRouteGroupConfigurator? parentRouteGroup); + + /// + /// Called during application startup, while registering and configuring endpoints. + /// + /// + /// + protected virtual void Configure( + IServiceProvider serviceProvider, + IRouteGroupConfigurator? parentRouteGroup) + { + return; + } + + protected RouteHandlerBuilder GetBuilder() + { + return _handlerBuilder is null + ? throw new InvalidOperationException(string.Format(Constants.RouteBuilderIsNullForEndpointMessage, GetType())) + : _handlerBuilder; + } +} diff --git a/src/ModEndpoints.Core/[Configuration]/ServiceEndpointRegistry.cs b/src/ModEndpoints.Core/[Configuration]/ServiceEndpointRegistry.cs new file mode 100644 index 0000000..31e8c16 --- /dev/null +++ b/src/ModEndpoints.Core/[Configuration]/ServiceEndpointRegistry.cs @@ -0,0 +1,29 @@ +using System.Collections.Concurrent; + +namespace ModEndpoints.Core; +public class ServiceEndpointRegistry +{ + private readonly ConcurrentDictionary _registry; + + private static readonly Lazy _instance = + new Lazy( + () => new ServiceEndpointRegistry(), + LazyThreadSafetyMode.ExecutionAndPublication); + + public static ServiceEndpointRegistry Instance => _instance.Value; + + private ServiceEndpointRegistry() + { + _registry = new(); + } + + public bool IsRegistered(Type requestType) + { + return _registry.ContainsKey(requestType); + } + + internal bool Register(Type requestType, Type endpointType) + { + return _registry.TryAdd(requestType, endpointType); + } +} diff --git a/src/ModEndpoints.Core/[Endpoints]/BaseServiceResultEndpoint.cs b/src/ModEndpoints.Core/[Endpoints]/BaseBusinessResultEndpoint.cs similarity index 88% rename from src/ModEndpoints.Core/[Endpoints]/BaseServiceResultEndpoint.cs rename to src/ModEndpoints.Core/[Endpoints]/BaseBusinessResultEndpoint.cs index 453d191..e1bd045 100644 --- a/src/ModEndpoints.Core/[Endpoints]/BaseServiceResultEndpoint.cs +++ b/src/ModEndpoints.Core/[Endpoints]/BaseBusinessResultEndpoint.cs @@ -4,8 +4,8 @@ using Microsoft.Extensions.DependencyInjection; namespace ModEndpoints.Core; -public abstract class BaseServiceResultEndpoint - : EndpointConfigurator, IServiceResultEndpoint +public abstract class BaseBusinessResultEndpoint + : EndpointConfigurator, IBusinessResultEndpoint where TRequest : notnull { protected sealed override Delegate ExecuteDelegate => ExecuteAsync; @@ -15,7 +15,7 @@ private async Task ExecuteAsync( HttpContext context) { var baseHandler = context.RequestServices.GetRequiredKeyedService(typeof(IEndpointConfigurator), GetType()); - var handler = baseHandler as BaseServiceResultEndpoint + var handler = baseHandler as BaseBusinessResultEndpoint ?? throw new InvalidOperationException(Constants.RequiredServiceIsInvalidMessage); var ct = context.RequestAborted; @@ -59,8 +59,8 @@ protected abstract ValueTask HandleInvalidValidationResultAsync( CancellationToken ct); } -public abstract class BaseServiceResultEndpoint - : EndpointConfigurator, IServiceResultEndpoint +public abstract class BaseBusinessResultEndpoint + : EndpointConfigurator, IBusinessResultEndpoint { protected sealed override Delegate ExecuteDelegate => ExecuteAsync; @@ -68,7 +68,7 @@ private async Task ExecuteAsync( HttpContext context) { var baseHandler = context.RequestServices.GetRequiredKeyedService(typeof(IEndpointConfigurator), GetType()); - var handler = baseHandler as BaseServiceResultEndpoint + var handler = baseHandler as BaseBusinessResultEndpoint ?? throw new InvalidOperationException(Constants.RequiredServiceIsInvalidMessage); var ct = context.RequestAborted; diff --git a/src/ModEndpoints.Core/[Endpoints]/BaseServiceEndpoint.cs b/src/ModEndpoints.Core/[Endpoints]/BaseServiceEndpoint.cs new file mode 100644 index 0000000..e581f28 --- /dev/null +++ b/src/ModEndpoints.Core/[Endpoints]/BaseServiceEndpoint.cs @@ -0,0 +1,62 @@ +using FluentValidation; +using FluentValidation.Results; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using ModEndpoints.RemoteServices.Core; + +namespace ModEndpoints.Core; +public abstract class BaseServiceEndpoint + : ServiceEndpointConfigurator, IServiceEndpoint + where TRequest : IServiceRequestMarker +{ + protected sealed override Delegate ExecuteDelegate => ExecuteAsync; + + private async Task ExecuteAsync( + [FromBody] TRequest req, + HttpContext context) + { + var baseHandler = context.RequestServices.GetRequiredKeyedService(typeof(IEndpointConfigurator), GetType()); + var handler = baseHandler as BaseServiceEndpoint + ?? throw new InvalidOperationException(Constants.RequiredServiceIsInvalidMessage); + var ct = context.RequestAborted; + + //Request validation + var validator = context.RequestServices.GetService>(); + if (validator is not null) + { + var validationResult = await validator.ValidateAsync(req, ct); + if (!validationResult.IsValid) + { + return await HandleInvalidValidationResultAsync(validationResult, context, ct); + } + } + + //Handler + var result = await handler.HandleAsync(req, ct); + return result; + } + + /// + /// Contains endpoint's logic to handle request. Input validation is completed before this method is called. + /// + /// + /// + /// + /// + protected abstract Task HandleAsync( + TRequest req, + CancellationToken ct); + + /// + /// This method is called if request validation fails, and is responsible for mapping to . + /// + /// + /// + /// + /// Endpoint's type validation failed response to caller. + protected abstract ValueTask HandleInvalidValidationResultAsync( + ValidationResult validationResult, + HttpContext context, + CancellationToken ct); +} diff --git a/src/ModEndpoints.Core/[Endpoints]/IBusinessResultEndpoint.cs b/src/ModEndpoints.Core/[Endpoints]/IBusinessResultEndpoint.cs new file mode 100644 index 0000000..51a504e --- /dev/null +++ b/src/ModEndpoints.Core/[Endpoints]/IBusinessResultEndpoint.cs @@ -0,0 +1,5 @@ +namespace ModEndpoints.Core; + +public interface IBusinessResultEndpoint +{ +} diff --git a/src/ModEndpoints.Core/[Endpoints]/IServiceEndpoint.cs b/src/ModEndpoints.Core/[Endpoints]/IServiceEndpoint.cs new file mode 100644 index 0000000..0c8c416 --- /dev/null +++ b/src/ModEndpoints.Core/[Endpoints]/IServiceEndpoint.cs @@ -0,0 +1,5 @@ +namespace ModEndpoints.Core; + +public interface IServiceEndpoint +{ +} diff --git a/src/ModEndpoints.Core/[Endpoints]/IServiceResultEndpoint.cs b/src/ModEndpoints.Core/[Endpoints]/IServiceResultEndpoint.cs deleted file mode 100644 index e3ca115..0000000 --- a/src/ModEndpoints.Core/[Endpoints]/IServiceResultEndpoint.cs +++ /dev/null @@ -1,5 +0,0 @@ -namespace ModEndpoints.Core; - -public interface IServiceResultEndpoint -{ -} diff --git a/src/ModEndpoints.RemoteServices.Core/IServiceRequest.cs b/src/ModEndpoints.RemoteServices.Core/IServiceRequest.cs new file mode 100644 index 0000000..b206273 --- /dev/null +++ b/src/ModEndpoints.RemoteServices.Core/IServiceRequest.cs @@ -0,0 +1,11 @@ +namespace ModEndpoints.RemoteServices.Core; +public interface IServiceRequest : IServiceRequestMarker +{ +} + +public interface IServiceRequest : IServiceRequestMarker +{ +} + +public interface IServiceRequestMarker { } + diff --git a/src/ModEndpoints.RemoteServices.Core/ModEndpoints.RemoteServices.Core.csproj b/src/ModEndpoints.RemoteServices.Core/ModEndpoints.RemoteServices.Core.csproj new file mode 100644 index 0000000..4142bae --- /dev/null +++ b/src/ModEndpoints.RemoteServices.Core/ModEndpoints.RemoteServices.Core.csproj @@ -0,0 +1,24 @@ + + + + netstandard2.0;net8.0;net9.0 + latest + + + + Base abstractions for request models of ServiceEndpoints. + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/src/ModEndpoints.RemoteServices.Core/assets/README.md b/src/ModEndpoints.RemoteServices.Core/assets/README.md new file mode 100644 index 0000000..a7f8c6a --- /dev/null +++ b/src/ModEndpoints.RemoteServices.Core/assets/README.md @@ -0,0 +1,3 @@ +# ModEndpoints.RemoteServices.Core + +Base abstractions for request models of ServiceEndpoints. \ No newline at end of file diff --git a/src/ModEndpoints.RemoteServices/DefaultClientName.cs b/src/ModEndpoints.RemoteServices/DefaultClientName.cs new file mode 100644 index 0000000..07d9d78 --- /dev/null +++ b/src/ModEndpoints.RemoteServices/DefaultClientName.cs @@ -0,0 +1,28 @@ +using ModEndpoints.RemoteServices.Core; + +namespace ModEndpoints.RemoteServices; +internal class DefaultClientName +{ + public static string Resolve(IServiceRequestMarker request) + { + var requestType = request.GetType(); + return ResolveInternal(requestType); + } + + public static string Resolve() + where TRequest : IServiceRequestMarker + { + var requestType = typeof(TRequest); + return ResolveInternal(requestType); + } + + private static string ResolveInternal(Type requestType) + { + var requestName = requestType.AssemblyQualifiedName; + if (string.IsNullOrWhiteSpace(requestName)) + { + throw new ArgumentException("Request type should not be generic.", nameof(requestType)); + } + return $"{requestName}.Client"; + } +} diff --git a/src/ModEndpoints.RemoteServices/DependencyInjectionExtensions.cs b/src/ModEndpoints.RemoteServices/DependencyInjectionExtensions.cs new file mode 100644 index 0000000..6690099 --- /dev/null +++ b/src/ModEndpoints.RemoteServices/DependencyInjectionExtensions.cs @@ -0,0 +1,211 @@ +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using ModEndpoints.RemoteServices.Core; + +namespace ModEndpoints.RemoteServices; +public static class DependencyInjectionExtensions +{ + public static IHttpClientBuilder AddRemoteServiceWithNewClient( + this IServiceCollection services, + Action configureClient) + where TRequest : IServiceRequestMarker + { + var clientName = DefaultClientName.Resolve(); + return services.AddRemoteServiceWithNewClient(clientName, configureClient); + } + + public static IHttpClientBuilder AddRemoteServiceWithNewClient( + this IServiceCollection services, + string clientName, + Action configureClient) + where TRequest : IServiceRequestMarker + { + if (ServiceChannelRegistry.Instance.IsRegistered()) + { + throw new InvalidOperationException($"A channel for request type {typeof(TRequest)} is already registered."); + } + if (ServiceChannelRegistry.Instance.IsRegistered(clientName)) + { + throw new InvalidOperationException($"A channel with client name {clientName} is already registered."); + } + + services.AddRemoteServicesCore(); + + if (!ServiceChannelRegistry.Instance.Register(clientName)) + { + throw new InvalidOperationException($"Channel couldn't be registered for request type {typeof(TRequest)} and client name {clientName}."); + } + var clientBuilder = services.AddHttpClient(clientName); + clientBuilder.ConfigureHttpClient(configureClient); + return clientBuilder; + } + + public static IHttpClientBuilder AddRemoteServiceWithNewClient( + this IServiceCollection services, + string baseAddress, + TimeSpan timeout = default) + where TRequest : IServiceRequestMarker + { + var clientName = DefaultClientName.Resolve(); + return services.AddRemoteServiceWithNewClient(clientName, baseAddress, timeout); + } + + public static IHttpClientBuilder AddRemoteServiceWithNewClient( + this IServiceCollection services, + string clientName, + string baseAddress, + TimeSpan timeout = default) + where TRequest : IServiceRequestMarker + { + Action configureClient = (sp, client) => + { + client.BaseAddress = new Uri(baseAddress); + if (timeout != default) + { + client.Timeout = timeout; + } + }; + return services.AddRemoteServiceWithNewClient(clientName, configureClient); + } + + public static IServiceCollection AddRemoteServiceToExistingClient( + this IServiceCollection services, + string clientName) + where TRequest : IServiceRequestMarker + { + if (ServiceChannelRegistry.Instance.IsRegistered()) + { + throw new InvalidOperationException($"A channel for request type {typeof(TRequest)} is already registered."); + } + if (!ServiceChannelRegistry.Instance.IsRegistered(clientName)) + { + throw new InvalidOperationException($"A channel with client name {clientName} is not registered."); + } + if (!ServiceChannelRegistry.Instance.Register(clientName)) + { + throw new InvalidOperationException($"Channel couldn't be registered for request type {typeof(TRequest)} and client name {clientName}."); + } + return services; + } + + public static IHttpClientBuilder? AddRemoteServicesWithNewClient( + this IServiceCollection services, + Assembly fromAssembly, + string clientName, + Action configureClient, + Func? filterPredicate = null) + { + if (ServiceChannelRegistry.Instance.IsRegistered(clientName)) + { + throw new InvalidOperationException($"A channel with client name {clientName} is already registered."); + } + var requestTypes = fromAssembly + .DefinedTypes + .Where(type => type is { IsAbstract: false, IsInterface: false } && + type.IsAssignableTo(typeof(IServiceRequestMarker))); + + if (filterPredicate is not null) + { + requestTypes = requestTypes.Where(type => filterPredicate(type)); + } + + IHttpClientBuilder? clientBuilder = null; + var requestTypeList = requestTypes.ToList(); + for (var i = 0; i < requestTypeList.Count; i++) + { + //first element + if (i == 0) + { + clientBuilder = services.AddRemoteServiceWithNewClientInternal( + requestTypeList[i], + clientName, + configureClient); + } + //rest + else + { + services.AddRemoteServiceToExistingClientInternal( + requestTypeList[i], + clientName); + } + } + return clientBuilder; + } + + public static IServiceCollection AddRemoteServicesToExistingClient( + this IServiceCollection services, + Assembly fromAssembly, + string clientName, + Func? filterPredicate = null) + { + if (!ServiceChannelRegistry.Instance.IsRegistered(clientName)) + { + throw new InvalidOperationException($"A channel with client name {clientName} is not registered."); + } + var requestTypes = fromAssembly + .DefinedTypes + .Where(type => type is { IsAbstract: false, IsInterface: false } && + type.IsAssignableTo(typeof(IServiceRequestMarker))); + + if (filterPredicate is not null) + { + requestTypes = requestTypes.Where(type => filterPredicate(type)); + } + + foreach (var requestType in requestTypes) + { + services.AddRemoteServiceToExistingClientInternal( + requestType, + clientName); + } + return services; + } + + private static IHttpClientBuilder AddRemoteServiceWithNewClientInternal( + this IServiceCollection services, + Type requestType, + string clientName, + Action configureClient) + { + if (ServiceChannelRegistry.Instance.IsRegistered(requestType)) + { + throw new InvalidOperationException($"A channel for request type {requestType} is already registered."); + } + + services.AddRemoteServicesCore(); + + if (!ServiceChannelRegistry.Instance.Register(requestType, clientName)) + { + throw new InvalidOperationException($"Channel couldn't be registered for request type {requestType} and client name {clientName}."); + } + var clientBuilder = services.AddHttpClient(clientName); + clientBuilder.ConfigureHttpClient(configureClient); + return clientBuilder; + } + + private static IServiceCollection AddRemoteServiceToExistingClientInternal( + this IServiceCollection services, + Type requestType, + string clientName) + { + if (ServiceChannelRegistry.Instance.IsRegistered(requestType)) + { + throw new InvalidOperationException($"A channel for request type {requestType} is already registered."); + } + if (!ServiceChannelRegistry.Instance.Register(requestType, clientName)) + { + throw new InvalidOperationException($"Channel couldn't be registered for request type {requestType} and client name {clientName}."); + } + return services; + } + + private static IServiceCollection AddRemoteServicesCore( + this IServiceCollection services) + { + services.TryAddTransient(); + services.TryAddTransient(); + return services; + } + +} diff --git a/samples/Client/HttpResponseMessageExtensions.cs b/src/ModEndpoints.RemoteServices/HttpResponseMessageExtensions.cs similarity index 84% rename from samples/Client/HttpResponseMessageExtensions.cs rename to src/ModEndpoints.RemoteServices/HttpResponseMessageExtensions.cs index 8b64e9e..e7a423f 100644 --- a/samples/Client/HttpResponseMessageExtensions.cs +++ b/src/ModEndpoints.RemoteServices/HttpResponseMessageExtensions.cs @@ -2,11 +2,11 @@ using System.Text.Json.Serialization; using ModResults; -namespace Client; +namespace ModEndpoints.RemoteServices; public static class HttpResponseMessageExtensions { - private static readonly JsonSerializerOptions _jsonSerializerOptions = new() + private static readonly JsonSerializerOptions _defaultJsonSerializerOptions = new() { PropertyNameCaseInsensitive = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase, @@ -24,7 +24,8 @@ public static class HttpResponseMessageExtensions public static async Task> DeserializeResultAsync( this HttpResponseMessage response, - CancellationToken ct) + CancellationToken ct, + JsonSerializerOptions? jsonSerializerOptions = null) where T : notnull { if (!response.IsSuccessStatusCode) @@ -36,13 +37,14 @@ public static async Task> DeserializeResultAsync( response.ReasonPhrase)) .WithFact($"Instance: {response.RequestMessage?.Method} {response.RequestMessage?.RequestUri}"); } - var resultObject = await response.DeserializeResultInternalAsync>(ct); + var resultObject = await response.DeserializeResultInternalAsync>(jsonSerializerOptions, ct); return resultObject ?? Result.Error(DeserializationErrorMessage); } public static async Task DeserializeResultAsync( this HttpResponseMessage response, - CancellationToken ct) + CancellationToken ct, + JsonSerializerOptions? jsonSerializerOptions = null) { if (!response.IsSuccessStatusCode) { @@ -53,12 +55,13 @@ public static async Task DeserializeResultAsync( response.ReasonPhrase)) .WithFact($"Instance: {response.RequestMessage?.Method} {response.RequestMessage?.RequestUri}"); } - var resultObject = await response.DeserializeResultInternalAsync(ct); + var resultObject = await response.DeserializeResultInternalAsync(jsonSerializerOptions, ct); return resultObject ?? Result.Error(DeserializationErrorMessage); } private static async Task DeserializeResultInternalAsync( this HttpResponseMessage response, + JsonSerializerOptions? jsonSerializerOptions, CancellationToken ct) where TResult : IModResult { @@ -69,7 +72,7 @@ public static async Task DeserializeResultAsync( ct.ThrowIfCancellationRequested(); return await JsonSerializer.DeserializeAsync( contentStream, - _jsonSerializerOptions, + jsonSerializerOptions ?? _defaultJsonSerializerOptions, ct); } } diff --git a/src/ModEndpoints.RemoteServices/IServiceChannel.cs b/src/ModEndpoints.RemoteServices/IServiceChannel.cs new file mode 100644 index 0000000..4a2a24b --- /dev/null +++ b/src/ModEndpoints.RemoteServices/IServiceChannel.cs @@ -0,0 +1,24 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using ModEndpoints.RemoteServices.Core; +using ModResults; + +namespace ModEndpoints.RemoteServices; +public interface IServiceChannel +{ + Task> SendAsync( + TRequest req, + CancellationToken ct, + MediaTypeHeaderValue? mediaType = null, + JsonSerializerOptions? jsonSerializerOptions = null, + Action? configureRequestHeaders = null) + where TRequest : IServiceRequest + where TResponse : notnull; + Task SendAsync( + TRequest req, + CancellationToken ct, + MediaTypeHeaderValue? mediaType = null, + JsonSerializerOptions? jsonSerializerOptions = null, + Action? configureRequestHeaders = null) + where TRequest : IServiceRequest; +} diff --git a/src/ModEndpoints.RemoteServices/IServiceEndpointUriResolver.cs b/src/ModEndpoints.RemoteServices/IServiceEndpointUriResolver.cs new file mode 100644 index 0000000..05f4c36 --- /dev/null +++ b/src/ModEndpoints.RemoteServices/IServiceEndpointUriResolver.cs @@ -0,0 +1,10 @@ +using ModEndpoints.RemoteServices.Core; +using ModResults; + +namespace ModEndpoints.RemoteServices; +public interface IServiceEndpointUriResolver +{ + Result Resolve(IServiceRequestMarker req); + Result Resolve() + where TRequest : IServiceRequestMarker; +} diff --git a/src/ModEndpoints.RemoteServices/ModEndpoints.RemoteServices.csproj b/src/ModEndpoints.RemoteServices/ModEndpoints.RemoteServices.csproj new file mode 100644 index 0000000..6550900 --- /dev/null +++ b/src/ModEndpoints.RemoteServices/ModEndpoints.RemoteServices.csproj @@ -0,0 +1,32 @@ + + + + net8.0;net9.0 + + + + Contains ServiceEndpoint client implementation along with extensions required for its registration. + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + diff --git a/src/ModEndpoints.RemoteServices/ServiceChannel.cs b/src/ModEndpoints.RemoteServices/ServiceChannel.cs new file mode 100644 index 0000000..7a1fa7a --- /dev/null +++ b/src/ModEndpoints.RemoteServices/ServiceChannel.cs @@ -0,0 +1,86 @@ +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using ModEndpoints.RemoteServices.Core; +using ModResults; + +namespace ModEndpoints.RemoteServices; + +public class ServiceChannel( + IHttpClientFactory clientFactory, + IServiceEndpointUriResolver uriResolver) + : IServiceChannel +{ + public async Task> SendAsync( + TRequest req, + CancellationToken ct, + MediaTypeHeaderValue? mediaType = null, + JsonSerializerOptions? jsonSerializerOptions = null, + Action? configureRequestHeaders = null) + where TRequest : IServiceRequest + where TResponse : notnull + { + try + { + var requestUriResult = uriResolver.Resolve(req); + if (requestUriResult.IsFailed) + { + return Result.Fail(requestUriResult); + } + if (!ServiceChannelRegistry.Instance.IsRegistered(out var clientName)) + { + return Result.CriticalError($"No channel registration found for request type {typeof(TRequest)}"); + } + using (HttpRequestMessage httpReq = new(HttpMethod.Post, requestUriResult.Value)) + { + httpReq.Content = JsonContent.Create(req, mediaType, jsonSerializerOptions); + configureRequestHeaders?.Invoke(httpReq.Headers); + var client = clientFactory.CreateClient(clientName); + using (var httpResponse = await client.SendAsync(httpReq, HttpCompletionOption.ResponseHeadersRead, ct)) + { + return await httpResponse.DeserializeResultAsync(ct); + } + } + } + catch (Exception ex) + { + return ex; + } + } + + public async Task SendAsync( + TRequest req, + CancellationToken ct, + MediaTypeHeaderValue? mediaType = null, + JsonSerializerOptions? jsonSerializerOptions = null, + Action? configureRequestHeaders = null) + where TRequest : IServiceRequest + { + try + { + var requestUriResult = uriResolver.Resolve(req); + if (requestUriResult.IsFailed) + { + return Result.Fail(requestUriResult); + } + if (!ServiceChannelRegistry.Instance.IsRegistered(out var clientName)) + { + return Result.CriticalError($"No channel registration found for request type {typeof(TRequest)}"); + } + using (HttpRequestMessage httpReq = new(HttpMethod.Post, requestUriResult.Value)) + { + httpReq.Content = JsonContent.Create(req, mediaType, jsonSerializerOptions); + configureRequestHeaders?.Invoke(httpReq.Headers); + var client = clientFactory.CreateClient(clientName); + using (var httpResponse = await client.SendAsync(httpReq, HttpCompletionOption.ResponseHeadersRead, ct)) + { + return await httpResponse.DeserializeResultAsync(ct); + } + } + } + catch (Exception ex) + { + return ex; + } + } +} diff --git a/src/ModEndpoints.RemoteServices/ServiceChannelRegistry.cs b/src/ModEndpoints.RemoteServices/ServiceChannelRegistry.cs new file mode 100644 index 0000000..0516270 --- /dev/null +++ b/src/ModEndpoints.RemoteServices/ServiceChannelRegistry.cs @@ -0,0 +1,54 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using ModEndpoints.RemoteServices.Core; + +namespace ModEndpoints.RemoteServices; +public class ServiceChannelRegistry +{ + private readonly ConcurrentDictionary _registry; + + private static Lazy _instance = + new Lazy( + () => new ServiceChannelRegistry(), + LazyThreadSafetyMode.ExecutionAndPublication); + + public static ServiceChannelRegistry Instance => _instance.Value; + + private ServiceChannelRegistry() + { + _registry = new(); + } + + public bool IsRegistered() + where TRequest : IServiceRequestMarker + { + return _registry.ContainsKey(typeof(TRequest)); + } + + public bool IsRegistered(Type requestType) + { + return _registry.ContainsKey(requestType); + } + + public bool IsRegistered(string clientName) + { + return _registry.Values.Any(c => c.Equals(clientName, StringComparison.OrdinalIgnoreCase)); + } + + internal bool Register(string clientName) + where TRequest : IServiceRequestMarker + { + return _registry.TryAdd(typeof(TRequest), clientName); + } + + internal bool Register(Type requestType, string clientName) + { + return _registry.TryAdd(requestType, clientName); + } + + public bool IsRegistered([NotNullWhen(true)] out string? clientName) + where TRequest : IServiceRequestMarker + { + return _registry.TryGetValue(typeof(TRequest), out clientName); + } +} diff --git a/src/ModEndpoints.RemoteServices/ServiceEndpointUriResolver.cs b/src/ModEndpoints.RemoteServices/ServiceEndpointUriResolver.cs new file mode 100644 index 0000000..d79bbf5 --- /dev/null +++ b/src/ModEndpoints.RemoteServices/ServiceEndpointUriResolver.cs @@ -0,0 +1,28 @@ +using ModEndpoints.RemoteServices.Core; +using ModResults; + +namespace ModEndpoints.RemoteServices; +public class ServiceEndpointUriResolver : IServiceEndpointUriResolver +{ + public Result Resolve(IServiceRequestMarker req) + { + var requestType = req.GetType(); + return ResolveInternal(requestType); + } + + public Result Resolve() where TRequest : IServiceRequestMarker + { + var requestType = typeof(TRequest); + return ResolveInternal(requestType); + } + + private Result ResolveInternal(Type requestType) + { + var requestName = requestType.FullName; + if (string.IsNullOrWhiteSpace(requestName)) + { + return Result.CriticalError("Cannot resolve request uri for service endpoint."); + } + return $"{requestName}.Endpoint"; + } +} diff --git a/src/ModEndpoints.RemoteServices/assets/README.md b/src/ModEndpoints.RemoteServices/assets/README.md new file mode 100644 index 0000000..eadd496 --- /dev/null +++ b/src/ModEndpoints.RemoteServices/assets/README.md @@ -0,0 +1,3 @@ +# ModEndpoints.RemoteServices + +Contains ServiceEndpoint client implementation along with extensions required for its registration. \ No newline at end of file diff --git a/src/ModEndpoints/DependencyInjectionExtensions.cs b/src/ModEndpoints/DependencyInjectionExtensions.cs index 0626320..fa88e4c 100644 --- a/src/ModEndpoints/DependencyInjectionExtensions.cs +++ b/src/ModEndpoints/DependencyInjectionExtensions.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using ModEndpoints.Core; +using ModEndpoints.RemoteServices; namespace ModEndpoints; public static class DependencyInjectionExtensions @@ -15,6 +16,7 @@ public static IServiceCollection AddModEndpointsFromAssembly( WebResultEndpointDefinitions.DefaultResultToResponseMapperName); services.TryAddScoped(); services.TryAddSingleton(); + services.TryAddTransient(); services.AddHttpContextAccessor(); return services.AddModEndpointsFromAssemblyCore(assembly); } diff --git a/src/ModEndpoints/ModEndpoints.csproj b/src/ModEndpoints/ModEndpoints.csproj index e2d8d63..09245c3 100644 --- a/src/ModEndpoints/ModEndpoints.csproj +++ b/src/ModEndpoints/ModEndpoints.csproj @@ -13,6 +13,13 @@ + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + @@ -20,6 +27,7 @@ + diff --git a/src/ModEndpoints/[ServiceResultEndpoint]/ServiceResultEndpoint.cs b/src/ModEndpoints/[BusinessResultEndpoint]/BusinessResultEndpoint.cs similarity index 77% rename from src/ModEndpoints/[ServiceResultEndpoint]/ServiceResultEndpoint.cs rename to src/ModEndpoints/[BusinessResultEndpoint]/BusinessResultEndpoint.cs index 7bfa8b6..8848169 100644 --- a/src/ModEndpoints/[ServiceResultEndpoint]/ServiceResultEndpoint.cs +++ b/src/ModEndpoints/[BusinessResultEndpoint]/BusinessResultEndpoint.cs @@ -5,8 +5,8 @@ using ModResults.FluentValidation; namespace ModEndpoints; -public abstract class ServiceResultEndpoint - : BaseServiceResultEndpoint> +public abstract class BusinessResultEndpoint + : BaseBusinessResultEndpoint> where TRequest : notnull where TResultValue : notnull { @@ -20,8 +20,8 @@ protected override ValueTask> HandleInvalidValidationResult } } -public abstract class ServiceResultEndpoint - : BaseServiceResultEndpoint +public abstract class BusinessResultEndpoint + : BaseBusinessResultEndpoint where TRequest : notnull { protected override ValueTask HandleInvalidValidationResultAsync( diff --git a/src/ModEndpoints/[BusinessResultEndpoint]/BusinessResultEndpointWithEmptyRequest.cs b/src/ModEndpoints/[BusinessResultEndpoint]/BusinessResultEndpointWithEmptyRequest.cs new file mode 100644 index 0000000..bda2b3b --- /dev/null +++ b/src/ModEndpoints/[BusinessResultEndpoint]/BusinessResultEndpointWithEmptyRequest.cs @@ -0,0 +1,14 @@ +using ModEndpoints.Core; +using ModResults; + +namespace ModEndpoints; +public abstract class BusinessResultEndpointWithEmptyRequest + : BaseBusinessResultEndpoint> + where TResultValue : notnull +{ +} + +public abstract class BusinessResultEndpointWithEmptyRequest + : BaseBusinessResultEndpoint +{ +} diff --git a/src/ModEndpoints/[ServiceEndpoint]/ServiceEndpoint.cs b/src/ModEndpoints/[ServiceEndpoint]/ServiceEndpoint.cs new file mode 100644 index 0000000..0cff2cb --- /dev/null +++ b/src/ModEndpoints/[ServiceEndpoint]/ServiceEndpoint.cs @@ -0,0 +1,68 @@ +using FluentValidation.Results; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using ModEndpoints.Core; +using ModEndpoints.RemoteServices; +using ModEndpoints.RemoteServices.Core; +using ModResults; +using ModResults.FluentValidation; + +namespace ModEndpoints; +public abstract class ServiceEndpoint + : BaseServiceEndpoint> + where TRequest : IServiceRequest + where TResultValue : notnull +{ + protected override ValueTask> HandleInvalidValidationResultAsync( + ValidationResult validationResult, + HttpContext context, + CancellationToken ct) + { + return new ValueTask>( + validationResult.ToInvalidResult()); + } + + protected sealed override RouteHandlerBuilder? MapEndpoint( + IServiceProvider serviceProvider, + IEndpointRouteBuilder builder, + IRouteGroupConfigurator? parentRouteGroup) + { + var uriResolver = serviceProvider.GetRequiredService(); + var patternResult = uriResolver.Resolve(); + if (patternResult.IsOk) + { + return builder.MapPost(patternResult.Value, ExecuteDelegate); + } + return null; + } +} + +public abstract class ServiceEndpoint + : BaseServiceEndpoint + where TRequest : IServiceRequest +{ + protected override ValueTask HandleInvalidValidationResultAsync( + ValidationResult validationResult, + HttpContext context, + CancellationToken ct) + { + return new ValueTask( + validationResult.ToInvalidResult()); + } + + protected sealed override RouteHandlerBuilder? MapEndpoint( + IServiceProvider serviceProvider, + IEndpointRouteBuilder builder, + IRouteGroupConfigurator? parentRouteGroup) + { + var uriResolver = serviceProvider.GetRequiredService(); + var patternResult = uriResolver.Resolve(); + if (patternResult.IsOk) + { + return builder.MapPost(patternResult.Value, ExecuteDelegate); + } + return null; + } +} diff --git a/src/ModEndpoints/[ServiceResultEndpoint]/ServiceResultEndpointWithEmptyRequest.cs b/src/ModEndpoints/[ServiceResultEndpoint]/ServiceResultEndpointWithEmptyRequest.cs deleted file mode 100644 index ad2f6eb..0000000 --- a/src/ModEndpoints/[ServiceResultEndpoint]/ServiceResultEndpointWithEmptyRequest.cs +++ /dev/null @@ -1,14 +0,0 @@ -using ModEndpoints.Core; -using ModResults; - -namespace ModEndpoints; -public abstract class ServiceResultEndpointWithEmptyRequest - : BaseServiceResultEndpoint> - where TResultValue : notnull -{ -} - -public abstract class ServiceResultEndpointWithEmptyRequest - : BaseServiceResultEndpoint -{ -} diff --git a/src/ModEndpoints/assets/README.md b/src/ModEndpoints/assets/README.md index 2addd82..14b5804 100644 --- a/src/ModEndpoints/assets/README.md +++ b/src/ModEndpoints/assets/README.md @@ -1,3 +1,3 @@ # ModEndpoints -WebResultEndpoints and ServiceResultEndpoints organize ASP.NET Core Minimal Apis in REPR format endpoints and are integrated with result pattern (using ModResults) out of box. +WebResultEndpoints and BusinessResultEndpoints organize ASP.NET Core Minimal Apis in REPR format endpoints and are integrated with result pattern (using ModResults) out of box.