Skip to content

Commit

Permalink
add dispatcher feature
Browse files Browse the repository at this point in the history
  • Loading branch information
pwelter34 committed Oct 13, 2024
1 parent 334a4d3 commit 94740c0
Show file tree
Hide file tree
Showing 12 changed files with 351 additions and 0 deletions.
51 changes: 51 additions & 0 deletions src/MediatR.CommandQuery.Endpoints/DispatcherEndpoint.cs
Original file line number Diff line number Diff line change
@@ -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> 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<IResult> 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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ namespace MediatR.CommandQuery.Endpoints;

public static class FeatureEndpointExtensions
{
public static IServiceCollection AddFeatureEndpoints(this IServiceCollection services)
{
services.Add(ServiceDescriptor.Transient<IFeatureEndpoint, DispatcherEndpoint>());

return services;
}

public static IEndpointRouteBuilder MapFeatureEndpoints(this IEndpointRouteBuilder builder)
{
var features = builder.ServiceProvider.GetServices<IFeatureEndpoint>();
Expand Down
50 changes: 50 additions & 0 deletions src/MediatR.CommandQuery.Endpoints/ProblemDetailsCustomizer.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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<string, string[]>();

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;
}
}
11 changes: 11 additions & 0 deletions src/MediatR.CommandQuery/Dispatcher/DispatchRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using System.Text.Json.Serialization;

using MediatR.CommandQuery.Converters;

namespace MediatR.CommandQuery.Dispatcher;

public class DispatchRequest
{
[JsonConverter(typeof(PolymorphicConverter<IBaseRequest>))]
public IBaseRequest Request { get; set; } = null!;
}
9 changes: 9 additions & 0 deletions src/MediatR.CommandQuery/Dispatcher/DispatcherOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace MediatR.CommandQuery.Dispatcher;

public class DispatcherOptions
{
public string RoutePrefix { get; set; } = "/api";

public string SendRoute { get; set; } = "/dispatcher";

}
6 changes: 6 additions & 0 deletions src/MediatR.CommandQuery/Dispatcher/IDispatcher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace MediatR.CommandQuery.Dispatcher;

public interface IDispatcher
{
Task<TResponse?> Send<TResponse>(IRequest<TResponse> request, CancellationToken cancellationToken = default);
}
16 changes: 16 additions & 0 deletions src/MediatR.CommandQuery/Dispatcher/MediatorDispatcher.cs
Original file line number Diff line number Diff line change
@@ -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<TResponse?> Send<TResponse>(IRequest<TResponse> request, CancellationToken cancellationToken = default)
{
return await _sender.Send(request, cancellationToken);
}
}
90 changes: 90 additions & 0 deletions src/MediatR.CommandQuery/Dispatcher/RemoteDispatcher.cs
Original file line number Diff line number Diff line change
@@ -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> dispatcherOptions)
{
_httpClient = httpClient;
_serializerOptions = serializerOptions;
_dispatcherOptions = dispatcherOptions.Value;
}

public async Task<TResponse?> Send<TResponse>(IRequest<TResponse> 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<TResponse>(
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<ProblemDetails>(
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}";
}
}
1 change: 1 addition & 0 deletions src/MediatR.CommandQuery/MediatR.CommandQuery.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<PackageReference Include="MediatR" Version="12.4.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.2" />
<PackageReference Include="System.Linq.Dynamic.Core" Version="1.4.5" />
<PackageReference Include="System.Text.Json" Version="8.0.5" />
<PackageReference Include="SystemTextJsonPatch" Version="3.3.0" />
Expand Down
14 changes: 14 additions & 0 deletions src/MediatR.CommandQuery/MediatorJsonContext.cs
Original file line number Diff line number Diff line change
@@ -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;
17 changes: 17 additions & 0 deletions src/MediatR.CommandQuery/MediatorServiceExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using FluentValidation;

using MediatR.CommandQuery.Dispatcher;
using MediatR.NotificationPublishers;

using Microsoft.Extensions.DependencyInjection;
Expand Down Expand Up @@ -39,4 +40,20 @@ public static IServiceCollection AddValidatorsFromAssembly<T>(this IServiceColle

return services;
}

public static IServiceCollection AddRemoteDispatcher(this IServiceCollection services)
{
services.TryAddTransient<IDispatcher>(sp => sp.GetRequiredService<RemoteDispatcher>());
services.AddOptions<DispatcherOptions>();

return services;
}

public static IServiceCollection AddServerDispatcher(this IServiceCollection services)
{
services.TryAddTransient<IDispatcher, MediatorDispatcher>();
services.AddOptions<DispatcherOptions>();

return services;
}
}
79 changes: 79 additions & 0 deletions src/MediatR.CommandQuery/Models/ProblemDetails.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
using System.Text.Json.Serialization;

namespace MediatR.CommandQuery.Models;

/// <summary>
/// A machine-readable format for specifying errors in HTTP API responses based on https://tools.ietf.org/html/rfc7807.
/// </summary>
public class ProblemDetails
{
/// <summary>
/// The content-type for a problem json response
/// </summary>
public const string ContentType = "application/problem+json";

/// <summary>
/// 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".
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyOrder(-5)]
[JsonPropertyName("type")]
public string? Type { get; set; }

/// <summary>
/// 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).
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyOrder(-4)]
[JsonPropertyName("title")]
public string? Title { get; set; }

/// <summary>
/// The HTTP status code([RFC7231], Section 6) generated by the origin server for this occurrence of the problem.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyOrder(-3)]
[JsonPropertyName("status")]
public int? Status { get; set; }

/// <summary>
/// A human-readable explanation specific to this occurrence of the problem.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyOrder(-2)]
[JsonPropertyName("detail")]
public string? Detail { get; set; }

/// <summary>
/// A URI reference that identifies the specific occurrence of the problem. It may or may not yield further information if dereferenced.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyOrder(-1)]
[JsonPropertyName("instance")]
public string? Instance { get; set; }

/// <summary>
/// Gets the validation errors associated with this instance of problem details
/// </summary>
[JsonPropertyName("errors")]
public IDictionary<string, string[]> Errors { get; set; } = new Dictionary<string, string[]>(StringComparer.Ordinal);

/// <summary>
/// Gets the <see cref="IDictionary{TKey, TValue}"/> for extension members.
/// <para>
/// 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.
/// </para>
/// </summary>
/// <remarks>
/// The round-tripping behavior for <see cref="Extensions"/> 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.
/// </remarks>
[JsonExtensionData]
public IDictionary<string, object?> Extensions { get; set; } = new Dictionary<string, object?>(StringComparer.Ordinal);
}

0 comments on commit 94740c0

Please sign in to comment.