Last active
July 5, 2023 00:59
-
-
Save fakhrulhilal/8c9c1529c1307817baff7b2047ebee46 to your computer and use it in GitHub Desktop.
Bind query/command from MediatR to ASP.NET core endpoint using monad
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System.Diagnostics.CodeAnalysis; | |
using MediatR; | |
using static StatusCodes; | |
// sample usages | |
app.MapCommand<RegisterCustomerDto, RegisterCustomer.Command>("customers", dto => new(dto.Name, dto.Email)); | |
app.MapQuery<GetCustomerDetailDto, GetCustomerDetail.Query>("customers/{CustomerId:int}", dto => new(dto.CustomerId)); | |
public class RegisterCustomerDto | |
{ | |
[FromBody] | |
public string Name { get; set; } | |
[FromBody] | |
public string Email { get; set; } | |
} | |
public class GetCustomerDetailDto | |
{ | |
[FromRoute] | |
public int CustomerId { get; set; } | |
} | |
/// <summary> | |
/// Bind Query/Command into ASP.NET core endpoint | |
/// </summary> | |
internal static class EndpointExtensions | |
{ | |
private record Map(string Title, string Url); | |
private static readonly Dictionary<int, Map> Maps = new() { | |
[Status400BadRequest] = new("Validation Failed", "https://tools.ietf.org/html/rfc7231#section-6.5.1"), | |
[Status401Unauthorized] = | |
new("Unauthenticated", "https://www.rfc-editor.org/rfc/rfc7235#section-3.1"), | |
[Status403Forbidden] = new("Forbidden", "https://www.rfc-editor.org/rfc/rfc7231#section-6.5.3"), | |
[Status404NotFound] = new("Not Found", "https://tools.ietf.org/html/rfc7231#section-6.5.4"), | |
[Status409Conflict] = | |
new("Feature Not Available", "https://datatracker.ietf.org/doc/html/rfc7231#section-6.5.8"), | |
[Status422UnprocessableEntity] = new("Process Failed", "https://http.dev/422"), | |
[Status500InternalServerError] = new("Internal Server error", | |
"https://tools.ietf.org/html/rfc7231#section-6.6.1") | |
}; | |
public static IEndpointRouteBuilder MapCommand<TDto, TCommand>(this IEndpointRouteBuilder route, | |
[StringSyntax("Route")]string pattern, Func<TDto, TCommand>? transformer = null) | |
where TCommand : IRequest<Result> { | |
string commandName = typeof(TCommand).Name; | |
(string methodName, bool isCreating) = commandName switch { | |
{ } name when name.StartsWith("Delete") || name.StartsWith("Remove") => (HttpMethods.Delete, false), | |
{ } name when name.StartsWith("Edit") || name.StartsWith("Update") => (HttpMethods.Put, false), | |
_ => (HttpMethods.Post, true) | |
}; | |
route.MapMethods(pattern, new[] { methodName }, | |
async (HttpContext http, TDto dto, CancellationToken cancellationToken) => | |
await Execute(dto, transformer, http, isCreating, cancellationToken)); | |
return route; | |
} | |
public static IEndpointRouteBuilder MapQuery<TDto, TQuery>(this IEndpointRouteBuilder route, | |
[StringSyntax("Route")] string pattern, Func<TDto, TQuery>? transformer = null) | |
where TQuery : IRequest<Result> { | |
route.MapGet(pattern, async (HttpContext http, TDto dto, | |
[FromServices] IMapper mapper, CancellationToken cancellationToken) => | |
await Execute(dto, transformer, http, false, cancellationToken)); | |
return route; | |
} | |
private static async Task<IResult> Execute<TDto, TRequest>(TDto dto, Func<TDto, TRequest>? transformer, | |
HttpContext http, bool isCreating, CancellationToken cancellationToken) | |
where TRequest : IRequest<Result> | |
{ | |
try | |
{ | |
var mediator = http.RequestServices.GetRequiredService<IMediator>(); | |
var request = transformer is not null | |
? transformer(dto) | |
: http.RequestServices.GetRequiredService<IMapper>().Map<TRequest>(dto); | |
var result = await mediator.Send(request, cancellationToken); | |
return Transform(result, http, isCreating); | |
} | |
catch (Exception exception) { | |
var log = http.RequestServices.GetRequiredService<ILogger<TRequest>>(); | |
log.LogError(exception, "Unknown error occurs"); | |
return Transform(Result.Error(exception), http, isCreating); | |
} | |
} | |
private static IResult Transform(Result result, HttpContext http, bool isCreating) { | |
string instance = http.Request.Path; | |
if (result is not Result.Failure.Unknown unknown) { | |
return result switch { | |
Result.Failure.Invalid invalid => Results.ValidationProblem(invalid.Errors, invalid.Reason, | |
instance, Status400BadRequest, Maps[invalid.Code].Title, Maps[invalid.Code].Url), | |
Result.Failure fail => Results.Problem(fail.Reason, instance, fail.Code, | |
Maps[fail.Code].Title, Maps[fail.Code].Url), | |
Result.Success.WithValue<int> hasValue when isCreating => Results.Created(http.Request.Path, hasValue.Value), | |
// result's value is dynamic from Query handler, boxing? | |
Result.Success.WithValue<object> hasValue => Results.Ok(hasValue.Value), | |
_ => Results.NoContent() | |
}; | |
} | |
var problem = new ProblemDetails { | |
Title = Maps[unknown.Code].Title, | |
Type = Maps[unknown.Code].Url, | |
Detail = "An error occurred while processing your request.", | |
Instance = instance, | |
Status = unknown.Code | |
}; | |
var env = http.RequestServices.GetRequiredService<IHostEnvironment>(); | |
if (!env.IsDevelopment()) { | |
return Results.Problem(problem); | |
} | |
problem.Extensions.Add("Message", unknown.Reason); | |
problem.Extensions.Add("Stacktrace", unknown.Exception.StackTrace); | |
return Results.Problem(problem); | |
} | |
} | |
// sample MediatR validation pipeline | |
public sealed class ValidationBehaviour<TRequest > : IPipelineBehavior<TRequest, Result> | |
where TRequest : IRequest<Result> | |
{ | |
private readonly IEnumerable<IValidator<TRequest>> _validators; | |
private readonly Regex _propertyPattern = new(@"\[\d+\]$", RegexOptions.Compiled); | |
public ValidationBehaviour(IEnumerable<IValidator<TRequest>> validators) { | |
_validators = validators; | |
} | |
public async Task<Result> Handle(TRequest request, RequestHandlerDelegate<Result> next, | |
CancellationToken cancellationToken) { | |
if (!_validators.Any()) { | |
return await next(); | |
} | |
var context = new ValidationContext<TRequest>(request); | |
var validationResults = | |
await Task.WhenAll(_validators.Select(v => v.ValidateAsync(context, cancellationToken))); | |
var failures = validationResults.SelectMany(r => r.Errors) | |
.Where(f => f != null) | |
.GroupBy(e => _propertyPattern.Replace(e.PropertyName, string.Empty), e => e.ErrorMessage) | |
.ToDictionary(failureGroup => failureGroup.Key, failureGroup => failureGroup.ToArray()); | |
return failures.Count != 0 ? Result.Reject(failures) : await next(); | |
} | |
} | |
// sample MediatR pipeline for handling exception, must be registered at first before other pipelines | |
public sealed class UnhandledExceptionBehaviour<TRequest> : IPipelineBehavior<TRequest, Result> | |
where TRequest : IRequest<Result> | |
{ | |
private readonly ILogger<UnhandledExceptionBehaviour<TRequest>> _logger; | |
public UnhandledExceptionBehaviour(ILogger<UnhandledExceptionBehaviour<TRequest>> logger) | |
{ | |
_logger = logger; | |
} | |
public async Task<Result> Handle(TRequest request, RequestHandlerDelegate<Result> next, CancellationToken cancellationToken) | |
{ | |
try | |
{ | |
return await next(); | |
} | |
catch (Exception exception) | |
{ | |
string requestName = GetClassName(typeof(TRequest)); | |
_logger.LogError(exception, "Unhandled exception for request {Name} {@Request}", requestName, request); | |
return Result.Error(exception); | |
} | |
} | |
/// <summary> | |
/// Get class name along with parent class (for nested class) | |
/// </summary> | |
/// <param name="type"></param> | |
/// <returns></returns> | |
private static string GetClassName(Type type) => type switch { | |
{ IsNested: false } => type.Name, | |
{ IsAbstract: true, IsSealed: true } => type.Name, | |
{ DeclaringType: not null } => $"{GetClassName(type.DeclaringType)}{type.Name}", | |
_ => type.Name | |
}; | |
} | |
public record Customer(int UserId, string Name, string Email); | |
public interface ICustomerRepository | |
{ | |
Task<Customer?> GetDetail(int userId); | |
Task<int> Create(Customer customer); | |
} | |
// sample query | |
public struct GetCustomerDetail | |
{ | |
public record Query(int CustomerId) : IRequest<Result>; | |
public record Outcome(int CustomerId, string Name, string Email); | |
public sealed class Handler : IRequestHandler<Query, Result> | |
{ | |
private readonly ICustomerRepository _repository; | |
public Handler(ICustomerRepository repository) { | |
_repository = repository; | |
} | |
public async Task<Result> Handle(Query request, CancellationToken cancellationToken) { | |
var user = await _repository.GetDetail(request.CustomerId); | |
return user is null | |
? Result.NotFound("User") | |
: Result.Ok(new Outcome(user.UserId, user.Name, user.Email)); | |
} | |
} | |
} | |
// sample command | |
public struct RegisterCustomer | |
{ | |
public record Command(string Name, string Email) : IRequest<Result>; | |
public sealed class Validator : AbstractValidator<Command> | |
{ | |
public Validator() { | |
RuleFor(x => x.Name).NotEmpty().MaximumLength(100); | |
RuleFor(x => x.Email).NotEmpty().EmailAddress().MaximumLength(100); | |
} | |
} | |
public sealed class Handler : IRequestHandler<Command, Result> | |
{ | |
private readonly ICustomerRepository _repository; | |
public Handler(ICustomerRepository repository) { | |
_repository = repository; | |
} | |
public async Task<Result> Handle(Command request, CancellationToken cancellationToken) { | |
var customer = new Customer(default, request.Name, request.Email); | |
int customerId = await _repository.Create(customer); | |
return customerId > 0 ? Result.Ok(customerId) : Result.Fail("Unable to register customer."); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment