Skip to content

feat!: support problem details #80

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Apr 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/ApiControllerBase.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using Cnblogs.Architecture.Ddd.Cqrs.Abstractions;
using Cnblogs.Architecture.Ddd.Domain.Abstractions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;

namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore;

Expand All @@ -10,6 +12,17 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore;
[ApiController]
public class ApiControllerBase : ControllerBase
{
private CqrsHttpOptions? _cqrsHttpOptions;

private CqrsHttpOptions CqrsHttpOptions
{
get
{
_cqrsHttpOptions ??= HttpContext.RequestServices.GetRequiredService<IOptions<CqrsHttpOptions>>().Value;
return _cqrsHttpOptions;
}
}

/// <summary>
/// Handle command response and return 204 if success, 400 if error.
/// </summary>
Expand Down Expand Up @@ -47,6 +60,61 @@ protected IActionResult HandleCommandResponse<TResponse, TError>(CommandResponse

private IActionResult HandleErrorCommandResponse<TError>(CommandResponse<TError> response)
where TError : Enumeration
{
return CqrsHttpOptions.CommandErrorResponseType switch
{
ErrorResponseType.PlainText => MapErrorCommandResponseToPlainText(response),
ErrorResponseType.ProblemDetails => MapErrorCommandResponseToProblemDetails(response),
ErrorResponseType.Custom => CustomErrorCommandResponseMap(response),
_ => throw new ArgumentOutOfRangeException(
$"Unsupported CommandErrorResponseType: {CqrsHttpOptions.CommandErrorResponseType}")
};
}

/// <summary>
/// Provides custom map logic that mapping error <see cref="CommandResponse{TError}"/> to <see cref="IActionResult"/> when <see cref="CqrsHttpOptions.CommandErrorResponseType"/> is <see cref="ErrorResponseType.Custom"/>.
/// The <c>CqrsHttpOptions.CustomCommandErrorResponseMapper</c> will be used as default implementation if configured. PlainText mapper will be used as the final fallback.
/// </summary>
/// <param name="response">The <see cref="CommandResponse{TError}"/> in error state.</param>
/// <typeparam name="TError">The error type.</typeparam>
/// <returns></returns>
protected virtual IActionResult CustomErrorCommandResponseMap<TError>(CommandResponse<TError> response)
where TError : Enumeration
{
if (CqrsHttpOptions.CustomCommandErrorResponseMapper != null)
{
var result = CqrsHttpOptions.CustomCommandErrorResponseMapper.Invoke(response, HttpContext);
return new HttpActionResult(result);
}

return MapErrorCommandResponseToPlainText(response);
}

private IActionResult MapErrorCommandResponseToProblemDetails<TError>(CommandResponse<TError> response)
where TError : Enumeration
{
if (response.IsValidationError)
{
ModelState.AddModelError(
response.ValidationError!.ParameterName ?? "command",
response.ValidationError!.Message);
return ValidationProblem();
}

if (response is { IsConcurrentError: true, LockAcquired: false })
{
return Problem(
"The lock can not be acquired within time limit, please try later.",
null,
429,
"Concurrent error");
}

return Problem(response.GetErrorMessage(), null, 400, "Execution failed");
}

private IActionResult MapErrorCommandResponseToPlainText<TError>(CommandResponse<TError> response)
where TError : Enumeration
{
if (response.IsValidationError)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Cnblogs.Architecture.Ddd.Cqrs.Abstractions;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;

namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore;

Expand All @@ -10,14 +11,17 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore;
public class CommandEndpointHandler : IEndpointFilter
{
private readonly IMediator _mediator;
private readonly CqrsHttpOptions _options;

/// <summary>
/// Create a command endpoint handler.
/// </summary>
/// <param name="mediator"><see cref="IMediator"/></param>
public CommandEndpointHandler(IMediator mediator)
/// <param name="options">The options for command response handling.</param>
public CommandEndpointHandler(IMediator mediator, IOptions<CqrsHttpOptions> options)
{
_mediator = mediator;
_options = options.Value;
}

/// <inheritdoc />
Expand Down Expand Up @@ -54,10 +58,23 @@ public CommandEndpointHandler(IMediator mediator)
return Results.NoContent();
}

return HandleErrorCommandResponse(commandResponse);
return HandleErrorCommandResponse(commandResponse, context.HttpContext);
}

private static IResult HandleErrorCommandResponse(CommandResponse response)
private IResult HandleErrorCommandResponse(CommandResponse response, HttpContext context)
{
return _options.CommandErrorResponseType switch
{
ErrorResponseType.PlainText => HandleErrorCommandResponseWithPlainText(response),
ErrorResponseType.ProblemDetails => HandleErrorCommandResponseWithProblemDetails(response),
ErrorResponseType.Custom => _options.CustomCommandErrorResponseMapper?.Invoke(response, context)
?? HandleErrorCommandResponseWithPlainText(response),
_ => throw new ArgumentOutOfRangeException(
$"Unsupported CommandErrorResponseType: {_options.CommandErrorResponseType}")
};
}

private static IResult HandleErrorCommandResponseWithPlainText(CommandResponse response)
{
if (response.IsValidationError)
{
Expand All @@ -71,4 +88,28 @@ private static IResult HandleErrorCommandResponse(CommandResponse response)

return Results.Text(response.GetErrorMessage(), statusCode: 400);
}

private static IResult HandleErrorCommandResponseWithProblemDetails(CommandResponse response)
{
if (response.IsValidationError)
{
var errors = new Dictionary<string, string[]>
{
{
response.ValidationError!.ParameterName ?? "command", new[] { response.ValidationError!.Message }
}
};
return Results.ValidationProblem(errors, statusCode: 400);
}

if (response is { IsConcurrentError: true, LockAcquired: false })
{
return Results.Problem(
"The lock can not be acquired within time limit, please try later.",
statusCode: 429,
title: "Concurrent error");
}

return Results.Problem(response.GetErrorMessage(), statusCode: 400, title: "Execution failed");
}
}
20 changes: 20 additions & 0 deletions src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CqrsHttpOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using Cnblogs.Architecture.Ddd.Cqrs.Abstractions;
using Microsoft.AspNetCore.Http;

namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore;

/// <summary>
/// Configure options for mapping cqrs responses into http responses.
/// </summary>
public class CqrsHttpOptions
{
/// <summary>
/// Configure the http response type for command errors.
/// </summary>
public ErrorResponseType CommandErrorResponseType { get; set; } = ErrorResponseType.PlainText;

/// <summary>
/// Custom logic to handle error command response.
/// </summary>
public Func<CommandResponse, HttpContext, IResult>? CustomCommandErrorResponseMapper { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using Cnblogs.Architecture.Ddd.Cqrs.Abstractions;
using Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;

namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore;

/// <summary>
/// Extension methods to configure behaviors of mapping command/query response to http response.
/// </summary>
public static class CqrsHttpOptionsInjector
{
/// <summary>
/// Use <see cref="ProblemDetails"/> to represent command response.
/// </summary>
/// <param name="injector">The <see cref="CqrsInjector"/>.</param>
/// <returns></returns>
public static CqrsInjector UseProblemDetails(this CqrsInjector injector)
{
injector.Services.AddProblemDetails();
injector.Services.Configure<CqrsHttpOptions>(
c => c.CommandErrorResponseType = ErrorResponseType.ProblemDetails);
return injector;
}

/// <summary>
/// Use custom mapper to convert command response into HTTP response.
/// </summary>
/// <param name="injector">The <see cref="CqrsInjector"/>.</param>
/// <param name="mapper">The custom map function.</param>
/// <returns></returns>
public static CqrsInjector UseCustomCommandErrorResponseMapper(
this CqrsInjector injector,
Func<CommandResponse, HttpContext, IResult> mapper)
{
injector.Services.Configure<CqrsHttpOptions>(
c =>
{
c.CommandErrorResponseType = ErrorResponseType.Custom;
c.CustomCommandErrorResponseMapper = mapper;
});
return injector;
}
}
22 changes: 22 additions & 0 deletions src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/ErrorResponseType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore;

/// <summary>
/// Configure the response type for command errors.
/// </summary>
public enum ErrorResponseType
{
/// <summary>
/// Returns plain text, this is the default behavior.
/// </summary>
PlainText,

/// <summary>
/// Returns <see cref="ProblemDetails"/>.
/// </summary>
ProblemDetails,

/// <summary>
/// Handles command error by custom logic.
/// </summary>
Custom
}
28 changes: 28 additions & 0 deletions src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/HttpActionResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;

namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore;

/// <summary>
/// Used because the same class in AspNetCore framework is internal.
/// </summary>
internal sealed class HttpActionResult : ActionResult
{
/// <summary>
/// Gets the instance of the current <see cref="IResult"/>.
/// </summary>
public IResult Result { get; }

/// <summary>
/// Initializes a new instance of the <see cref="HttpActionResult"/> class with the
/// <see cref="IResult"/> provided.
/// </summary>
/// <param name="result">The <see cref="IResult"/> instance to be used during the <see cref="ExecuteResultAsync"/> invocation.</param>
public HttpActionResult(IResult result)
{
Result = result;
}

/// <inheritdoc/>
public override Task ExecuteResultAsync(ActionContext context) => Result.ExecuteAsync(context.HttpContext);
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public Task<CommandResponse<TestError>> Handle(CreateCommand request, Cancellati
/// <inheritdoc />
public Task<CommandResponse<TestError>> Handle(UpdateCommand request, CancellationToken cancellationToken)
{
return Task.FromResult(request.NeedError
return Task.FromResult(request.NeedExecutionError
? CommandResponse<TestError>.Fail(TestError.Default)
: CommandResponse<TestError>.Success());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,21 @@

namespace Cnblogs.Architecture.IntegrationTestProject.Application.Commands;

public record UpdateCommand(int Id, bool NeedError, bool ValidateOnly = false) : ICommand<TestError>;
public record UpdateCommand(
int Id,
bool NeedValidationError,
bool NeedExecutionError,
bool ValidateOnly = false)
: ICommand<TestError>, IValidatable
{
/// <inheritdoc />
public ValidationError? Validate()
{
if (NeedValidationError)
{
return new ValidationError("need validation error", nameof(NeedValidationError));
}

return null;
}
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,35 @@
using Asp.Versioning;
using Cnblogs.Architecture.Ddd.Cqrs.AspNetCore;
using Cnblogs.Architecture.Ddd.Infrastructure.Abstractions;

using Cnblogs.Architecture.IntegrationTestProject.Application.Commands;
using Cnblogs.Architecture.IntegrationTestProject.Payloads;
using MediatR;
using Microsoft.AspNetCore.Mvc;

namespace Cnblogs.Architecture.IntegrationTestProject.Controllers;

[ApiVersion("1")]
[Route("/api/v{version:apiVersion}")]
[Route("/api/v{version:apiVersion}/mvc")]
public class TestController : ApiControllerBase
{
private readonly IMediator _mediator;

public TestController(IMediator mediator)
{
_mediator = mediator;
}

[HttpGet("paging")]
public Task<PagingParams?> PagingParamsAsync([FromQuery] PagingParams? pagingParams)
{
return Task.FromResult(pagingParams);
}

[HttpPut("strings/{id:int}")]
public async Task<IActionResult> PutStringAsync(int id, [FromBody] UpdatePayload payload)
{
var response =
await _mediator.Send(new UpdateCommand(id, payload.NeedValidationError, payload.NeedExecutionError));
return HandleCommandResponse(response);
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
namespace Cnblogs.Architecture.IntegrationTestProject.Payloads;

public record UpdatePayload(bool NeedError);
public record UpdatePayload(bool NeedExecutionError = false, bool NeedValidationError = false);
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
v1.MapCommand("strings", (CreatePayload payload) => new CreateCommand(payload.NeedError));
v1.MapCommand(
"strings/{id:int}",
(int id, UpdatePayload payload) => new UpdateCommand(id, payload.NeedError));
(int id, UpdatePayload payload) => new UpdateCommand(id, payload.NeedValidationError, payload.NeedExecutionError));
v1.MapCommand<DeleteCommand>("strings/{id:int}");

app.Run();
Expand Down
Loading