diff --git a/src/MediatR.CommandQuery.Endpoints/DispatcherEndpoint.cs b/src/MediatR.CommandQuery.Endpoints/DispatcherEndpoint.cs new file mode 100644 index 00000000..e7469c42 --- /dev/null +++ b/src/MediatR.CommandQuery.Endpoints/DispatcherEndpoint.cs @@ -0,0 +1,51 @@ +using System.Security.Claims; + +using MediatR.CommandQuery.Dispatcher; + +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Options; + +namespace MediatR.CommandQuery.Endpoints; + +public class DispatcherEndpoint : IFeatureEndpoint +{ + private readonly ISender _sender; + private readonly DispatcherOptions _dispatcherOptions; + + public DispatcherEndpoint(ISender sender, IOptions dispatcherOptions) + { + _sender = sender; + _dispatcherOptions = dispatcherOptions.Value; + } + + public void AddRoutes(IEndpointRouteBuilder app) + { + var group = app + .MapGroup(_dispatcherOptions.RoutePrefix); + + group + .MapPost(_dispatcherOptions.SendRoute, Send) + .ExcludeFromDescription(); + } + + protected virtual async Task Send( + [FromBody] DispatchRequest dispatchRequest, + ClaimsPrincipal? user = default, + CancellationToken cancellationToken = default) + { + try + { + var request = dispatchRequest.Request; + var result = await _sender.Send(request, cancellationToken); + return Results.Ok(result); + } + catch (Exception ex) + { + var details = ex.ToProblemDetails(); + return Results.Problem(details); + } + } +} diff --git a/src/MediatR.CommandQuery.Endpoints/FeatureEndpointExtensions.cs b/src/MediatR.CommandQuery.Endpoints/FeatureEndpointExtensions.cs index 625f97ab..bf9177df 100644 --- a/src/MediatR.CommandQuery.Endpoints/FeatureEndpointExtensions.cs +++ b/src/MediatR.CommandQuery.Endpoints/FeatureEndpointExtensions.cs @@ -5,6 +5,13 @@ namespace MediatR.CommandQuery.Endpoints; public static class FeatureEndpointExtensions { + public static IServiceCollection AddFeatureEndpoints(this IServiceCollection services) + { + services.Add(ServiceDescriptor.Transient()); + + return services; + } + public static IEndpointRouteBuilder MapFeatureEndpoints(this IEndpointRouteBuilder builder) { var features = builder.ServiceProvider.GetServices(); diff --git a/src/MediatR.CommandQuery.Endpoints/ProblemDetailsCustomizer.cs b/src/MediatR.CommandQuery.Endpoints/ProblemDetailsCustomizer.cs index 57b8ed33..e8110dd7 100644 --- a/src/MediatR.CommandQuery.Endpoints/ProblemDetailsCustomizer.cs +++ b/src/MediatR.CommandQuery.Endpoints/ProblemDetailsCustomizer.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -85,4 +86,53 @@ private static void AddValidationErrors(ProblemDetailsContext context, IDictiona context.ProblemDetails.Extensions.Add("errors", errors); #endif } + + public static ProblemDetails ToProblemDetails(this Exception exception) + { + var problemDetails = new ProblemDetails(); + switch (exception) + { + case FluentValidation.ValidationException fluentException: + { + var errors = fluentException.Errors + .GroupBy(x => x.PropertyName) + .ToDictionary(g => g.Key, g => g.Select(x => x.ErrorMessage).ToArray()); + + problemDetails.Title = "One or more validation errors occurred."; + problemDetails.Status = StatusCodes.Status400BadRequest; + problemDetails.Extensions.Add("errors", errors); + break; + } + case System.ComponentModel.DataAnnotations.ValidationException validationException: + { + var errors = new Dictionary(); + + if (validationException.ValidationResult.ErrorMessage != null) + foreach (var memberName in validationException.ValidationResult.MemberNames) + errors[memberName] = [validationException.ValidationResult.ErrorMessage]; + + problemDetails.Title = "One or more validation errors occurred."; + problemDetails.Status = StatusCodes.Status400BadRequest; + problemDetails.Extensions.Add("errors", errors); + break; + } + case MediatR.CommandQuery.DomainException domainException: + { + problemDetails.Title = "Internal Server Error."; + problemDetails.Status = domainException.StatusCode; + break; + } + default: + { + problemDetails.Title = "Internal Server Error."; + problemDetails.Status = 500; + break; + } + } + + problemDetails.Detail = exception?.Message; + problemDetails.Extensions.Add("exception", exception?.ToString()); + + return problemDetails; + } } diff --git a/src/MediatR.CommandQuery/Dispatcher/DispatchRequest.cs b/src/MediatR.CommandQuery/Dispatcher/DispatchRequest.cs new file mode 100644 index 00000000..4c62b091 --- /dev/null +++ b/src/MediatR.CommandQuery/Dispatcher/DispatchRequest.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +using MediatR.CommandQuery.Converters; + +namespace MediatR.CommandQuery.Dispatcher; + +public class DispatchRequest +{ + [JsonConverter(typeof(PolymorphicConverter))] + public IBaseRequest Request { get; set; } = null!; +} diff --git a/src/MediatR.CommandQuery/Dispatcher/DispatcherOptions.cs b/src/MediatR.CommandQuery/Dispatcher/DispatcherOptions.cs new file mode 100644 index 00000000..35cdd993 --- /dev/null +++ b/src/MediatR.CommandQuery/Dispatcher/DispatcherOptions.cs @@ -0,0 +1,9 @@ +namespace MediatR.CommandQuery.Dispatcher; + +public class DispatcherOptions +{ + public string RoutePrefix { get; set; } = "/api"; + + public string SendRoute { get; set; } = "/dispatcher"; + +} diff --git a/src/MediatR.CommandQuery/Dispatcher/IDispatcher.cs b/src/MediatR.CommandQuery/Dispatcher/IDispatcher.cs new file mode 100644 index 00000000..51bf4e77 --- /dev/null +++ b/src/MediatR.CommandQuery/Dispatcher/IDispatcher.cs @@ -0,0 +1,6 @@ +namespace MediatR.CommandQuery.Dispatcher; + +public interface IDispatcher +{ + Task Send(IRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/MediatR.CommandQuery/Dispatcher/MediatorDispatcher.cs b/src/MediatR.CommandQuery/Dispatcher/MediatorDispatcher.cs new file mode 100644 index 00000000..27030425 --- /dev/null +++ b/src/MediatR.CommandQuery/Dispatcher/MediatorDispatcher.cs @@ -0,0 +1,16 @@ +namespace MediatR.CommandQuery.Dispatcher; + +public class MediatorDispatcher : IDispatcher +{ + private readonly ISender _sender; + + public MediatorDispatcher(ISender sender) + { + _sender = sender; + } + + public async Task Send(IRequest request, CancellationToken cancellationToken = default) + { + return await _sender.Send(request, cancellationToken); + } +} diff --git a/src/MediatR.CommandQuery/Dispatcher/RemoteDispatcher.cs b/src/MediatR.CommandQuery/Dispatcher/RemoteDispatcher.cs new file mode 100644 index 00000000..9b231758 --- /dev/null +++ b/src/MediatR.CommandQuery/Dispatcher/RemoteDispatcher.cs @@ -0,0 +1,90 @@ +using System.Net.Http.Json; +using System.Text.Json; + +using MediatR.CommandQuery.Models; + +using Microsoft.Extensions.Options; + +namespace MediatR.CommandQuery.Dispatcher; + +public class RemoteDispatcher : IDispatcher +{ + private readonly HttpClient _httpClient; + private readonly JsonSerializerOptions _serializerOptions; + private readonly DispatcherOptions _dispatcherOptions; + + public RemoteDispatcher(HttpClient httpClient, JsonSerializerOptions serializerOptions, IOptions dispatcherOptions) + { + _httpClient = httpClient; + _serializerOptions = serializerOptions; + _dispatcherOptions = dispatcherOptions.Value; + } + + public async Task Send(IRequest request, CancellationToken cancellationToken = default) + { + var requestUri = Combine(_dispatcherOptions.RoutePrefix, _dispatcherOptions.SendRoute); + + var dispatchRequest = new DispatchRequest { Request = request }; + + var responseMessage = await _httpClient.PostAsJsonAsync( + requestUri: requestUri, + value: dispatchRequest, + options: _serializerOptions, + cancellationToken: cancellationToken); + + await EnsureSuccessStatusCode(responseMessage, cancellationToken); + + return await responseMessage.Content.ReadFromJsonAsync( + options: _serializerOptions, + cancellationToken: cancellationToken); + } + + private async Task EnsureSuccessStatusCode(HttpResponseMessage responseMessage, CancellationToken cancellationToken = default) + { + if (responseMessage.IsSuccessStatusCode) + return; + + var message = $"Response status code does not indicate success: {responseMessage.StatusCode} ({responseMessage.ReasonPhrase})."; + + var mediaType = responseMessage.Content.Headers.ContentType?.MediaType; + if (!string.Equals(mediaType, "application/problem+json", StringComparison.OrdinalIgnoreCase)) + throw new HttpRequestException(message, inner: null, responseMessage.StatusCode); + + var problemDetails = await responseMessage.Content.ReadFromJsonAsync( + options: _serializerOptions, + cancellationToken: cancellationToken); + + if (problemDetails == null) + throw new HttpRequestException(message, inner: null, responseMessage.StatusCode); + + var status = (System.Net.HttpStatusCode?)problemDetails.Status; + status ??= responseMessage.StatusCode; + + var problemMessage = problemDetails.Title + ?? responseMessage.ReasonPhrase + ?? "Internal Server Error"; + + if (!string.IsNullOrEmpty(problemDetails.Detail)) + problemMessage = $"{problemMessage} {problemDetails.Detail}"; + + throw new HttpRequestException( + message: problemMessage, + inner: null, + statusCode: status); + } + + private static string Combine(string first, string second) + { + if (string.IsNullOrEmpty(first)) + return second; + + if (string.IsNullOrEmpty(second)) + return first; + + bool hasSeparator = first[^1] == '/' || second[0] == '/'; + + return hasSeparator + ? string.Concat(first, second) + : $"{first}/{second}"; + } +} diff --git a/src/MediatR.CommandQuery/MediatR.CommandQuery.csproj b/src/MediatR.CommandQuery/MediatR.CommandQuery.csproj index 873dc7b3..dc7c92da 100644 --- a/src/MediatR.CommandQuery/MediatR.CommandQuery.csproj +++ b/src/MediatR.CommandQuery/MediatR.CommandQuery.csproj @@ -11,6 +11,7 @@ + diff --git a/src/MediatR.CommandQuery/MediatorJsonContext.cs b/src/MediatR.CommandQuery/MediatorJsonContext.cs new file mode 100644 index 00000000..0c83f7f8 --- /dev/null +++ b/src/MediatR.CommandQuery/MediatorJsonContext.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; + +using MediatR.CommandQuery.Dispatcher; +using MediatR.CommandQuery.Models; + +namespace MediatR.CommandQuery; + +[JsonSourceGenerationOptions( + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase +)] +[JsonSerializable(typeof(DispatchRequest))] +[JsonSerializable(typeof(ProblemDetails))] +public partial class MediatorJsonContext : JsonSerializerContext; diff --git a/src/MediatR.CommandQuery/MediatorServiceExtensions.cs b/src/MediatR.CommandQuery/MediatorServiceExtensions.cs index 6bb46ddb..74744f2c 100644 --- a/src/MediatR.CommandQuery/MediatorServiceExtensions.cs +++ b/src/MediatR.CommandQuery/MediatorServiceExtensions.cs @@ -1,5 +1,6 @@ using FluentValidation; +using MediatR.CommandQuery.Dispatcher; using MediatR.NotificationPublishers; using Microsoft.Extensions.DependencyInjection; @@ -39,4 +40,20 @@ public static IServiceCollection AddValidatorsFromAssembly(this IServiceColle return services; } + + public static IServiceCollection AddRemoteDispatcher(this IServiceCollection services) + { + services.TryAddTransient(sp => sp.GetRequiredService()); + services.AddOptions(); + + return services; + } + + public static IServiceCollection AddServerDispatcher(this IServiceCollection services) + { + services.TryAddTransient(); + services.AddOptions(); + + return services; + } } diff --git a/src/MediatR.CommandQuery/Models/ProblemDetails.cs b/src/MediatR.CommandQuery/Models/ProblemDetails.cs new file mode 100644 index 00000000..9a022970 --- /dev/null +++ b/src/MediatR.CommandQuery/Models/ProblemDetails.cs @@ -0,0 +1,79 @@ +using System.Text.Json.Serialization; + +namespace MediatR.CommandQuery.Models; + +/// +/// A machine-readable format for specifying errors in HTTP API responses based on https://tools.ietf.org/html/rfc7807. +/// +public class ProblemDetails +{ + /// + /// The content-type for a problem json response + /// + public const string ContentType = "application/problem+json"; + + /// + /// A URI reference [RFC3986] that identifies the problem type. This specification encourages that, when + /// dereferenced, it provide human-readable documentation for the problem type + /// (e.g., using HTML [W3C.REC-html5-20141028]). When this member is not present, its value is assumed to be + /// "about:blank". + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyOrder(-5)] + [JsonPropertyName("type")] + public string? Type { get; set; } + + /// + /// A short, human-readable summary of the problem type. It SHOULD NOT change from occurrence to occurrence + /// of the problem, except for purposes of localization(e.g., using proactive content negotiation; + /// see[RFC7231], Section 3.4). + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyOrder(-4)] + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// The HTTP status code([RFC7231], Section 6) generated by the origin server for this occurrence of the problem. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyOrder(-3)] + [JsonPropertyName("status")] + public int? Status { get; set; } + + /// + /// A human-readable explanation specific to this occurrence of the problem. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyOrder(-2)] + [JsonPropertyName("detail")] + public string? Detail { get; set; } + + /// + /// A URI reference that identifies the specific occurrence of the problem. It may or may not yield further information if dereferenced. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + [JsonPropertyOrder(-1)] + [JsonPropertyName("instance")] + public string? Instance { get; set; } + + /// + /// Gets the validation errors associated with this instance of problem details + /// + [JsonPropertyName("errors")] + public IDictionary Errors { get; set; } = new Dictionary(StringComparer.Ordinal); + + /// + /// Gets the for extension members. + /// + /// Problem type definitions MAY extend the problem details object with additional members. Extension members appear in the same namespace as + /// other members of a problem type. + /// + /// + /// + /// The round-tripping behavior for is determined by the implementation of the Input \ Output formatters. + /// In particular, complex types or collection types may not round-trip to the original type when using the built-in JSON or XML formatters. + /// + [JsonExtensionData] + public IDictionary Extensions { get; set; } = new Dictionary(StringComparer.Ordinal); +}