using System.Diagnostics.CodeAnalysis; using MediatR; using static StatusCodes; // sample usages app.MapCommand("customers", dto => new(dto.Name, dto.Email)); app.MapQuery("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; } } /// /// Bind Query/Command into ASP.NET core endpoint /// internal static class EndpointExtensions { private record Map(string Title, string Url); private static readonly Dictionary 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(this IEndpointRouteBuilder route, [StringSyntax("Route")]string pattern, Func? transformer = null) where TCommand : IRequest { 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(this IEndpointRouteBuilder route, [StringSyntax("Route")] string pattern, Func? transformer = null) where TQuery : IRequest { 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 Execute(TDto dto, Func? transformer, HttpContext http, bool isCreating, CancellationToken cancellationToken) where TRequest : IRequest { try { var mediator = http.RequestServices.GetRequiredService(); var request = transformer is not null ? transformer(dto) : http.RequestServices.GetRequiredService().Map(dto); var result = await mediator.Send(request, cancellationToken); return Transform(result, http, isCreating); } catch (Exception exception) { var log = http.RequestServices.GetRequiredService>(); 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 hasValue when isCreating => Results.Created(http.Request.Path, hasValue.Value), // result's value is dynamic from Query handler, boxing? Result.Success.WithValue 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(); 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 : IPipelineBehavior where TRequest : IRequest { private readonly IEnumerable> _validators; private readonly Regex _propertyPattern = new(@"\[\d+\]$", RegexOptions.Compiled); public ValidationBehaviour(IEnumerable> validators) { _validators = validators; } public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) { if (!_validators.Any()) { return await next(); } var context = new ValidationContext(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 : IPipelineBehavior where TRequest : IRequest { private readonly ILogger> _logger; public UnhandledExceptionBehaviour(ILogger> logger) { _logger = logger; } public async Task Handle(TRequest request, RequestHandlerDelegate 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); } } /// /// Get class name along with parent class (for nested class) /// /// /// 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 GetDetail(int userId); Task Create(Customer customer); } // sample query public struct GetCustomerDetail { public record Query(int CustomerId) : IRequest; public record Outcome(int CustomerId, string Name, string Email); public sealed class Handler : IRequestHandler { private readonly ICustomerRepository _repository; public Handler(ICustomerRepository repository) { _repository = repository; } public async Task 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; public sealed class Validator : AbstractValidator { public Validator() { RuleFor(x => x.Name).NotEmpty().MaximumLength(100); RuleFor(x => x.Email).NotEmpty().EmailAddress().MaximumLength(100); } } public sealed class Handler : IRequestHandler { private readonly ICustomerRepository _repository; public Handler(ICustomerRepository repository) { _repository = repository; } public async Task 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."); } } }