Skip to content

Instantly share code, notes, and snippets.

@fakhrulhilal
Last active July 5, 2023 00:59
Show Gist options
  • Save fakhrulhilal/8c9c1529c1307817baff7b2047ebee46 to your computer and use it in GitHub Desktop.
Save fakhrulhilal/8c9c1529c1307817baff7b2047ebee46 to your computer and use it in GitHub Desktop.
Bind query/command from MediatR to ASP.NET core endpoint using monad
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