diff --git a/Directory.Build.props b/Directory.Build.props index 8a388ee..17dd053 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -18,6 +18,6 @@ MIT True - 0.5.2 + 0.6.0 \ No newline at end of file diff --git a/samples/Client/Client.csproj b/samples/Client/Client.csproj index ff828af..59f10ef 100644 --- a/samples/Client/Client.csproj +++ b/samples/Client/Client.csproj @@ -6,8 +6,8 @@ - - + + diff --git a/src/ModEndpoints.Core/ModEndpoints.Core.csproj b/src/ModEndpoints.Core/ModEndpoints.Core.csproj index 6e5c36b..429b4a8 100644 --- a/src/ModEndpoints.Core/ModEndpoints.Core.csproj +++ b/src/ModEndpoints.Core/ModEndpoints.Core.csproj @@ -22,7 +22,7 @@ - + diff --git a/src/ModEndpoints.Core/assets/README.md b/src/ModEndpoints.Core/assets/README.md index 1481f38..911c177 100644 --- a/src/ModEndpoints.Core/assets/README.md +++ b/src/ModEndpoints.Core/assets/README.md @@ -1,5 +1,15 @@ # ModEndpoints.Core -MinimalEndpoints is the barebone implementation for organizing ASP.NET Core Minimal Apis in REPR format endpoints. Does not come integrated with a result pattern like endpoints in ModEndpoints project. +[MinimalEndpoints](#minimalendpoint) are the barebone implementation for organizing ASP.NET Core Minimal Apis in REPR format endpoints. They have built-in input validation and their handler methods may return Minimal Api IResult based, string or T (any other type) response. Also contains core classes for ModEndpoints project. + +## Key Features + + - Organizes ASP.NET Core Minimal Apis in REPR pattern endpoints + - Encapsulates endpoint behaviors like request validation and request handling. + - Supports anything that Minimal Apis does. Configuration, parameter binding, authentication, Open Api tooling, filters, etc. are all Minimal Apis under the hood. + - Supports auto discovery and registration. + - Has built-in validation support with [FluentValidation](https://github.com/FluentValidation/FluentValidation). If a validator is registered for request model, request is automatically validated before being handled. + - Supports constructor dependency injection in endpoint implementations. + \ No newline at end of file diff --git a/src/ModEndpoints.RemoteServices/ServiceChannel.cs b/src/ModEndpoints.RemoteServices/DefaultServiceChannel.cs similarity index 59% rename from src/ModEndpoints.RemoteServices/ServiceChannel.cs rename to src/ModEndpoints.RemoteServices/DefaultServiceChannel.cs index e39140c..82fc44e 100644 --- a/src/ModEndpoints.RemoteServices/ServiceChannel.cs +++ b/src/ModEndpoints.RemoteServices/DefaultServiceChannel.cs @@ -1,13 +1,10 @@ -using System.Net.Http.Headers; -using System.Net.Http.Json; -using System.Text.Json; -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using ModEndpoints.RemoteServices.Core; using ModResults; namespace ModEndpoints.RemoteServices; -public class ServiceChannel( +public class DefaultServiceChannel( IHttpClientFactory clientFactory, IServiceProvider serviceProvider) : IServiceChannel @@ -18,10 +15,10 @@ public async Task> SendAsync( TRequest req, CancellationToken ct, string? endpointUriPrefix = null, - MediaTypeHeaderValue? mediaType = null, - JsonSerializerOptions? jsonSerializerOptions = null, - Action? configureRequestHeaders = null, - string? uriResolverName = null) + Func? processHttpRequest = null, + Func? processHttpResponse = null, + string? uriResolverName = null, + string? serializerName = null) where TRequest : IServiceRequest where TResponse : notnull { @@ -31,6 +28,8 @@ public async Task> SendAsync( { var uriResolver = scope.ServiceProvider.GetRequiredKeyedService( uriResolverName ?? ServiceEndpointDefinitions.DefaultUriResolverName); + var serializer = scope.ServiceProvider.GetRequiredKeyedService( + serializerName ?? ServiceEndpointDefinitions.DefaultSerializerName); var requestUriResult = uriResolver.Resolve(req); if (requestUriResult.IsFailed) { @@ -42,14 +41,21 @@ public async Task> SendAsync( } using (HttpRequestMessage httpReq = new( HttpMethod.Post, - ServiceChannel.Combine(endpointUriPrefix, requestUriResult.Value))) + Combine(endpointUriPrefix, requestUriResult.Value))) { - httpReq.Content = JsonContent.Create(req, mediaType, jsonSerializerOptions); - configureRequestHeaders?.Invoke(httpReq.Headers); + httpReq.Content = await serializer.CreateContentAsync(req, ct); + if (processHttpRequest is not null) + { + await processHttpRequest(scope.ServiceProvider, httpReq, ct); + } var client = clientFactory.CreateClient(clientName); - using (var httpResponse = await client.SendAsync(httpReq, HttpCompletionOption.ResponseHeadersRead, ct)) + using (var httpResponse = await client.SendAsync(httpReq, ct)) { - return await httpResponse.DeserializeResultAsync(ct); + if (processHttpResponse is not null) + { + await processHttpResponse(scope.ServiceProvider, httpResponse, ct); + } + return await serializer.DeserializeResultAsync(httpResponse, ct); } } } @@ -64,10 +70,10 @@ public async Task SendAsync( TRequest req, CancellationToken ct, string? endpointUriPrefix = null, - MediaTypeHeaderValue? mediaType = null, - JsonSerializerOptions? jsonSerializerOptions = null, - Action? configureRequestHeaders = null, - string? uriResolverName = null) + Func? processHttpRequest = null, + Func? processHttpResponse = null, + string? uriResolverName = null, + string? serializerName = null) where TRequest : IServiceRequest { try @@ -76,6 +82,8 @@ public async Task SendAsync( { var uriResolver = scope.ServiceProvider.GetRequiredKeyedService( uriResolverName ?? ServiceEndpointDefinitions.DefaultUriResolverName); + var serializer = scope.ServiceProvider.GetRequiredKeyedService( + serializerName ?? ServiceEndpointDefinitions.DefaultSerializerName); var requestUriResult = uriResolver.Resolve(req); if (requestUriResult.IsFailed) { @@ -87,14 +95,21 @@ public async Task SendAsync( } using (HttpRequestMessage httpReq = new( HttpMethod.Post, - ServiceChannel.Combine(endpointUriPrefix, requestUriResult.Value))) + Combine(endpointUriPrefix, requestUriResult.Value))) { - httpReq.Content = JsonContent.Create(req, mediaType, jsonSerializerOptions); - configureRequestHeaders?.Invoke(httpReq.Headers); + httpReq.Content = await serializer.CreateContentAsync(req, ct); + if (processHttpRequest is not null) + { + await processHttpRequest(scope.ServiceProvider, httpReq, ct); + } var client = clientFactory.CreateClient(clientName); - using (var httpResponse = await client.SendAsync(httpReq, HttpCompletionOption.ResponseHeadersRead, ct)) + using (var httpResponse = await client.SendAsync(httpReq, ct)) { - return await httpResponse.DeserializeResultAsync(ct); + if (processHttpResponse is not null) + { + await processHttpResponse(scope.ServiceProvider, httpResponse, ct); + } + return await serializer.DeserializeResultAsync(httpResponse, ct); } } } diff --git a/src/ModEndpoints.RemoteServices/HttpResponseMessageExtensions.cs b/src/ModEndpoints.RemoteServices/DefaultServiceChannelSerializer.cs similarity index 64% rename from src/ModEndpoints.RemoteServices/HttpResponseMessageExtensions.cs rename to src/ModEndpoints.RemoteServices/DefaultServiceChannelSerializer.cs index f936362..90992c2 100644 --- a/src/ModEndpoints.RemoteServices/HttpResponseMessageExtensions.cs +++ b/src/ModEndpoints.RemoteServices/DefaultServiceChannelSerializer.cs @@ -1,18 +1,14 @@ -using System.Text.Json; -using System.Text.Json.Serialization; +using System.Net.Http.Json; +using System.Text.Json; +using ModEndpoints.RemoteServices.Core; using ModResults; namespace ModEndpoints.RemoteServices; -public static class HttpResponseMessageExtensions +public class DefaultServiceChannelSerializer( + ServiceChannelSerializerOptions options) + : IServiceChannelSerializer { - private static readonly JsonSerializerOptions _defaultJsonSerializerOptions = new() - { - PropertyNameCaseInsensitive = true, - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - NumberHandling = JsonNumberHandling.AllowReadingFromString - }; - private const string DeserializationErrorMessage = "Cannot deserialize Result object from http response message."; @@ -25,15 +21,26 @@ public static class HttpResponseMessageExtensions private const string InstanceFactMessage = "Instance: {0} {1}"; - public static async Task> DeserializeResultAsync( - this HttpResponseMessage response, - CancellationToken ct, - JsonSerializerOptions? jsonSerializerOptions = null) - where T : notnull + public ValueTask CreateContentAsync( + TRequest request, + CancellationToken ct) + where TRequest : IServiceRequestMarker + { + return new ValueTask( + JsonContent.Create( + request, + null, + options.SerializationOptions)); + } + + public async Task> DeserializeResultAsync( + HttpResponseMessage response, + CancellationToken ct) + where TResponse : notnull { if (!response.IsSuccessStatusCode) { - return Result + return Result .CriticalError(string.Format( string.IsNullOrWhiteSpace(response.ReasonPhrase) ? ResponseNotSuccessfulErrorMessage : ResponseNotSuccessfulWithReasonErrorMessage, (int)response.StatusCode, @@ -43,8 +50,8 @@ public static async Task> DeserializeResultAsync( response.RequestMessage?.Method, response.RequestMessage?.RequestUri)); } - var resultObject = await response.DeserializeResultInternalAsync>(jsonSerializerOptions, ct); - return resultObject ?? Result + var resultObject = await DeserializeResultInternalAsync>(response, ct); + return resultObject ?? Result .CriticalError(DeserializationErrorMessage) .WithFact(string.Format( InstanceFactMessage, @@ -52,10 +59,9 @@ public static async Task> DeserializeResultAsync( response.RequestMessage?.RequestUri)); } - public static async Task DeserializeResultAsync( - this HttpResponseMessage response, - CancellationToken ct, - JsonSerializerOptions? jsonSerializerOptions = null) + public async Task DeserializeResultAsync( + HttpResponseMessage response, + CancellationToken ct) { if (!response.IsSuccessStatusCode) { @@ -69,7 +75,7 @@ public static async Task DeserializeResultAsync( response.RequestMessage?.Method, response.RequestMessage?.RequestUri)); } - var resultObject = await response.DeserializeResultInternalAsync(jsonSerializerOptions, ct); + var resultObject = await DeserializeResultInternalAsync(response, ct); return resultObject ?? Result .CriticalError(DeserializationErrorMessage) .WithFact(string.Format( @@ -78,9 +84,8 @@ public static async Task DeserializeResultAsync( response.RequestMessage?.RequestUri)); } - private static async Task DeserializeResultInternalAsync( - this HttpResponseMessage response, - JsonSerializerOptions? jsonSerializerOptions, + private async Task DeserializeResultInternalAsync( + HttpResponseMessage response, CancellationToken ct) where TResult : IModResult { @@ -91,7 +96,7 @@ public static async Task DeserializeResultAsync( ct.ThrowIfCancellationRequested(); return await JsonSerializer.DeserializeAsync( contentStream, - jsonSerializerOptions ?? _defaultJsonSerializerOptions, + options.DeserializationOptions, ct); } } diff --git a/src/ModEndpoints.RemoteServices/ServiceEndpointUriResolver.cs b/src/ModEndpoints.RemoteServices/DefaultServiceEndpointUriResolver.cs similarity index 91% rename from src/ModEndpoints.RemoteServices/ServiceEndpointUriResolver.cs rename to src/ModEndpoints.RemoteServices/DefaultServiceEndpointUriResolver.cs index 126a64b..3bdca2e 100644 --- a/src/ModEndpoints.RemoteServices/ServiceEndpointUriResolver.cs +++ b/src/ModEndpoints.RemoteServices/DefaultServiceEndpointUriResolver.cs @@ -2,7 +2,7 @@ using ModResults; namespace ModEndpoints.RemoteServices; -public class ServiceEndpointUriResolver : IServiceEndpointUriResolver +public class DefaultServiceEndpointUriResolver : IServiceEndpointUriResolver { private const string CannotResolveServiceEndpointUri = "Cannot resolve request uri for service endpoint."; public Result Resolve(IServiceRequestMarker req) diff --git a/src/ModEndpoints.RemoteServices/DependencyInjectionExtensions.cs b/src/ModEndpoints.RemoteServices/DependencyInjectionExtensions.cs index cf907e6..2594edb 100644 --- a/src/ModEndpoints.RemoteServices/DependencyInjectionExtensions.cs +++ b/src/ModEndpoints.RemoteServices/DependencyInjectionExtensions.cs @@ -27,7 +27,7 @@ public static IServiceCollection AddRemoteServiceWithNewClient( Action? configureClientBuilder = null) where TRequest : IServiceRequestMarker { - var clientName = DefaultClientName.Resolve(); + var clientName = ServiceClientNameResolver.GetDefaultName(); return services.AddRemoteServiceWithNewClient( clientName, baseAddress, @@ -81,7 +81,7 @@ public static IServiceCollection AddRemoteServiceWithNewClient( Action? configureClientBuilder = null) where TRequest : IServiceRequestMarker { - var clientName = DefaultClientName.Resolve(); + var clientName = ServiceClientNameResolver.GetDefaultName(); return services.AddRemoteServiceWithNewClient( clientName, configureClient, @@ -277,9 +277,19 @@ private static IServiceCollection AddClientInternal( private static IServiceCollection AddRemoteServicesCore( this IServiceCollection services) { - services.TryAddKeyedSingleton( + services.TryAddKeyedSingleton( ServiceEndpointDefinitions.DefaultUriResolverName); - services.TryAddTransient(); + services.AddKeyedTransient( + ServiceEndpointDefinitions.DefaultSerializerName, + (_, _) => + { + return new DefaultServiceChannelSerializer(new ServiceChannelSerializerOptions() + { + SerializationOptions = null, + DeserializationOptions = ServiceEndpointDefinitions.DefaultJsonDeserializationOptions + }); + }); + services.TryAddTransient(); return services; } diff --git a/src/ModEndpoints.RemoteServices/IServiceChannel.cs b/src/ModEndpoints.RemoteServices/IServiceChannel.cs index 8c6b9c6..4758628 100644 --- a/src/ModEndpoints.RemoteServices/IServiceChannel.cs +++ b/src/ModEndpoints.RemoteServices/IServiceChannel.cs @@ -1,6 +1,4 @@ -using System.Net.Http.Headers; -using System.Text.Json; -using ModEndpoints.RemoteServices.Core; +using ModEndpoints.RemoteServices.Core; using ModResults; namespace ModEndpoints.RemoteServices; @@ -18,19 +16,19 @@ public interface IServiceChannel /// Request to be sent. /// The to cancel operation. /// Path to append as prefix to resolved enpoint uri. Usually used to add path segments to configured client's base address. - /// The media type to use for the content. - /// Options to control the behavior during serialization. - /// Delegate to configure HTTP request headers. + /// Delegate to further configure created HTTP request message (headers, etc) before sending to ServiceEndpoint. + /// Delegate to process received HTTP response message of ServiceEndpoint before deserialization. /// name to be used to resolve ServiceEnpoint Uri. + /// name to be used to resolve ServiceEnpoint Uri. /// Response of remote service endpoint or failure result. Task> SendAsync( TRequest req, CancellationToken ct, string? endpointUriPrefix = null, - MediaTypeHeaderValue? mediaType = null, - JsonSerializerOptions? jsonSerializerOptions = null, - Action? configureRequestHeaders = null, - string? uriResolverName = null) + Func? processHttpRequest = null, + Func? processHttpResponse = null, + string? uriResolverName = null, + string? serializerName = null) where TRequest : IServiceRequest where TResponse : notnull; @@ -41,18 +39,18 @@ Task> SendAsync( /// Request to be sent. /// The to cancel operation. /// Path to append as prefix to resolved enpoint uri. Usually used to add path segments to configured client's base address. - /// The media type to use for the content. - /// Options to control the behavior during serialization. - /// Delegate to configure HTTP request headers. + /// Delegate to further configure created HTTP request message (headers, etc) before sending to ServiceEndpoint. + /// Delegate to process received HTTP response message of ServiceEndpoint before deserialization. /// name to be used to resolve ServiceEnpoint Uri. + /// name to be used to resolve ServiceEnpoint Uri. /// Response of remote service endpoint or failure result. Task SendAsync( TRequest req, CancellationToken ct, string? endpointUriPrefix = null, - MediaTypeHeaderValue? mediaType = null, - JsonSerializerOptions? jsonSerializerOptions = null, - Action? configureRequestHeaders = null, - string? uriResolverName = null) + Func? processHttpRequest = null, + Func? processHttpResponse = null, + string? uriResolverName = null, + string? serializerName = null) where TRequest : IServiceRequest; } diff --git a/src/ModEndpoints.RemoteServices/IServiceChannelSerializer.cs b/src/ModEndpoints.RemoteServices/IServiceChannelSerializer.cs new file mode 100644 index 0000000..13435e5 --- /dev/null +++ b/src/ModEndpoints.RemoteServices/IServiceChannelSerializer.cs @@ -0,0 +1,20 @@ +using ModEndpoints.RemoteServices.Core; +using ModResults; + +namespace ModEndpoints.RemoteServices; +public interface IServiceChannelSerializer +{ + Task DeserializeResultAsync( + HttpResponseMessage response, + CancellationToken ct); + + Task> DeserializeResultAsync( + HttpResponseMessage response, + CancellationToken ct) + where TResponse : notnull; + + ValueTask CreateContentAsync( + TRequest request, + CancellationToken ct) + where TRequest : IServiceRequestMarker; +} diff --git a/src/ModEndpoints.RemoteServices/ModEndpoints.RemoteServices.csproj b/src/ModEndpoints.RemoteServices/ModEndpoints.RemoteServices.csproj index 1a9c9d1..090162f 100644 --- a/src/ModEndpoints.RemoteServices/ModEndpoints.RemoteServices.csproj +++ b/src/ModEndpoints.RemoteServices/ModEndpoints.RemoteServices.csproj @@ -21,8 +21,8 @@ - - + + diff --git a/src/ModEndpoints.RemoteServices/ServiceChannelSerializerOptions.cs b/src/ModEndpoints.RemoteServices/ServiceChannelSerializerOptions.cs new file mode 100644 index 0000000..510ed7f --- /dev/null +++ b/src/ModEndpoints.RemoteServices/ServiceChannelSerializerOptions.cs @@ -0,0 +1,8 @@ +using System.Text.Json; + +namespace ModEndpoints.RemoteServices; +public class ServiceChannelSerializerOptions +{ + public JsonSerializerOptions? SerializationOptions { get; set; } + public JsonSerializerOptions? DeserializationOptions { get; set; } +} diff --git a/src/ModEndpoints.RemoteServices/DefaultClientName.cs b/src/ModEndpoints.RemoteServices/ServiceClientNameResolver.cs similarity index 63% rename from src/ModEndpoints.RemoteServices/DefaultClientName.cs rename to src/ModEndpoints.RemoteServices/ServiceClientNameResolver.cs index 187df78..87097ce 100644 --- a/src/ModEndpoints.RemoteServices/DefaultClientName.cs +++ b/src/ModEndpoints.RemoteServices/ServiceClientNameResolver.cs @@ -1,23 +1,23 @@ using ModEndpoints.RemoteServices.Core; namespace ModEndpoints.RemoteServices; -internal class DefaultClientName +internal class ServiceClientNameResolver { private const string InvalidRequestType = "Request type should not be generic type parameter."; - public static string Resolve(IServiceRequestMarker request) + public static string GetDefaultName(IServiceRequestMarker request) { var requestType = request.GetType(); - return ResolveInternal(requestType); + return GetDefaultNameInternal(requestType); } - public static string Resolve() + public static string GetDefaultName() where TRequest : IServiceRequestMarker { var requestType = typeof(TRequest); - return ResolveInternal(requestType); + return GetDefaultNameInternal(requestType); } - private static string ResolveInternal(Type requestType) + private static string GetDefaultNameInternal(Type requestType) { var requestName = requestType.AssemblyQualifiedName; if (string.IsNullOrWhiteSpace(requestName)) diff --git a/src/ModEndpoints.RemoteServices/ServiceEndpointDefinitions.cs b/src/ModEndpoints.RemoteServices/ServiceEndpointDefinitions.cs index 4967165..32ac097 100644 --- a/src/ModEndpoints.RemoteServices/ServiceEndpointDefinitions.cs +++ b/src/ModEndpoints.RemoteServices/ServiceEndpointDefinitions.cs @@ -1,5 +1,16 @@ -namespace ModEndpoints.RemoteServices; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ModEndpoints.RemoteServices; public static class ServiceEndpointDefinitions { - public const string DefaultUriResolverName = "DefaultUriResolver"; + public const string DefaultUriResolverName = "DefaultServiceEndpointUriResolver"; + public const string DefaultSerializerName = "DefaultServiceChannelSerializer"; + + internal static readonly JsonSerializerOptions DefaultJsonDeserializationOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + NumberHandling = JsonNumberHandling.AllowReadingFromString + }; } diff --git a/src/ModEndpoints/DependencyInjectionExtensions.cs b/src/ModEndpoints/DependencyInjectionExtensions.cs index a1e0b60..29cc983 100644 --- a/src/ModEndpoints/DependencyInjectionExtensions.cs +++ b/src/ModEndpoints/DependencyInjectionExtensions.cs @@ -19,7 +19,7 @@ public static IServiceCollection AddModEndpointsFromAssembly( services.TryAddSingleton(); //ServiceEndpoint components - services.TryAddKeyedSingleton( + services.TryAddKeyedSingleton( ServiceEndpointDefinitions.DefaultUriResolverName); services.TryAddSingleton(); diff --git a/src/ModEndpoints/ModEndpoints.csproj b/src/ModEndpoints/ModEndpoints.csproj index 6019101..6d0d89b 100644 --- a/src/ModEndpoints/ModEndpoints.csproj +++ b/src/ModEndpoints/ModEndpoints.csproj @@ -21,8 +21,8 @@ - - + + diff --git a/src/ModEndpoints/[WebResultEndpoint]/DefaultResultToResponseMapper.cs b/src/ModEndpoints/[WebResultEndpoint]/DefaultResultToResponseMapper.cs index e8022bc..5437b59 100644 --- a/src/ModEndpoints/[WebResultEndpoint]/DefaultResultToResponseMapper.cs +++ b/src/ModEndpoints/[WebResultEndpoint]/DefaultResultToResponseMapper.cs @@ -87,7 +87,7 @@ public async ValueTask ToResponseAsync( return result.ToResponse(SuccessfulResponseType.ResetContent); default: return result.ToResponse(); - }; + } } public async ValueTask ToResponseAsync(