8000 Merge pull request #80 from cnblogs/72-use-problem-detail-to-handle-a… · cnblogs/Architecture@fc2701d · GitHub
[go: up one dir, main page]

Skip to content
8000

Commit fc2701d

Browse files
authored
Merge pull request #80 from cnblogs/72-use-problem-detail-to-handle-api-errors
feat!: support problem details
2 parents c96a804 + fa32ca2 commit fc2701d

File tree

13 files changed

+405
-19
lines changed

13 files changed

+405
-19
lines changed

src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/ApiControllerBase.cs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
using Cnblogs.Architecture.Ddd.Cqrs.Abstractions;
22
using Cnblogs.Architecture.Ddd.Domain.Abstractions;
33
using Microsoft.AspNetCore.Mvc;
4+
using Microsoft.Extensions.DependencyInjection;
5+
using Microsoft.Extensions.Options;
46

57
namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore;
68

@@ -10,6 +12,17 @@ namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore;
1012
[ApiController]
1113
public class ApiControllerBase : ControllerBase
1214
{
15+
private CqrsHttpOptions? _cqrsHttpOptions;
16+
17+
private CqrsHttpOptions CqrsHttpOptions
18+
{
19+
get
20+
{
21+
_cqrsHttpOptions ??= HttpContext.RequestServices.GetRequiredService<IOptions<CqrsHttpOptions>>().Value;
22+
return _cqrsHttpOptions;
23+
}
24+
}
25+
1326
/// <summary>
1427
/// Handle command response and return 204 if success, 400 if error.
1528
/// </summary>
@@ -47,6 +60,61 @@ protected IActionResult HandleCommandResponse<TResponse, TError>(CommandResponse
4760

4861
private IActionResult HandleErrorCommandResponse<TError>(CommandResponse<TError> response)
4962
where TError : Enumeration
63+
{
64+
return CqrsHttpOptions.CommandErrorResponseType switch
65+
{
66+
ErrorResponseType.PlainText => MapErrorCommandResponseToPlainText(response),
67+
ErrorResponseType.ProblemDetails => MapErrorCommandResponseToProblemDetails(response),
68+
ErrorResponseType.Custom => CustomErrorCommandResponseMap(response),
69+
_ => throw new ArgumentOutOfRangeException(
70+
$"Unsupported CommandErrorResponseType: {CqrsHttpOptions.CommandErrorResponseType}")
71+
};
72+
}
73+
74+
/// <summary>
75+
/// 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"/>.
76+
/// The <c>CqrsHttpOptions.CustomCommandErrorResponseMapper</c> will be used as default implementation if configured. PlainText mapper will be used as the final fallback.
77+
/// </summary>
78+
/// <param name="response">The <see cref="CommandResponse{TError}"/> in error state.</param>
79+
/// <typeparam name="TError">The error type.</typeparam>
80+
/// <returns></returns>
81+
protected virtual IActionResult CustomErrorCommandResponseMap<TError>(CommandResponse<TError> response)
82+
where TError : Enumeration
83+
{
84+
if (CqrsHttpOptions.CustomCommandErrorResponseMapper != null)
85+
{
86+
var result = CqrsHttpOptions.CustomCommandErrorResponseMapper.Invoke(response, HttpContext);
87+
return new HttpActionResult(result);
88+
}
89+
90+
return MapErrorCommandResponseToPlainText(response);
91+
}
92+
93+
private IActionResult MapErrorCommandResponseToProblemDetails<TError>(CommandResponse<TError> response)
94+
where TError : Enumeration
95+
{
96+
if (response.IsValidationError)
97+
{
98+
ModelState.AddModelError(
99+
response.ValidationError!.ParameterName ?? "command",
100+
response.ValidationError!.Message);
101+
return ValidationProblem();
102+
}
103+
104+
if (response is { IsConcurrentError: true, LockAcquired: false })
105+
{
106+
return Problem(
107+
"The lock can not be acquired within time limit, please try later.",
108+
null,
109+
429,
110+
"Concurrent error");
111+
}
112+
113+
return Problem(response.GetErrorMessage(), null, 400, "Execution failed");
114+
}
115+
116+
private IActionResult MapErrorCommandResponseToPlainText<TError>(CommandResponse<TError> response)
117+
where TError : Enumeration
50118
{
51119
if (response.IsValidationError)
52120
{

src/Cnblogs.Architecture.Ddd.Cqrs.AspNetCore/CommandEndpointHandler.cs

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using Cnblogs.Architecture.Ddd.Cqrs.Abstractions;
22
using MediatR;
33
using Microsoft.AspNetCore.Http;
4+
using Microsoft.Extensions.Options;
45

56
namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore;
67

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

1416
/// <summary>
1517
/// Create a command endpoint handler.
1618
/// </summary>
1719
/// <param name="mediator"><see cref="IMediator"/></param>
18-
public CommandEndpointHandler(IMediator mediator)
20+
/// <param name="options">The options for command response handling.</param>
21+
public Comm 10000 andEndpointHandler(IMediator mediator, IOptions<CqrsHttpOptions> options)
1922
{
2023
_mediator = mediator;
24+
_options = options.Value;
2125
}
2226

2327
/// <inheritdoc />
@@ -54,10 +58,23 @@ public CommandEndpointHandler(IMediator mediator)
5458
return Results.NoContent();
5559
}
5660

57-
return HandleErrorCommandResponse(commandResponse);
61+
return HandleErrorCommandResponse(commandResponse, context.HttpContext);
5862
}
5963

60-
private static IResult HandleErrorCommandResponse(CommandResponse response)
64+
private IResult HandleErrorCommandResponse(CommandResponse response, HttpContext context)
65+
{
66+
return _options.CommandErrorResponseType switch
67+
{
68+
ErrorResponseType.PlainText => HandleErrorCommandResponseWithPlainText(response),
69+
ErrorResponseType.ProblemDetails => HandleErrorCommandResponseWithProblemDetails(response),
70+
ErrorResponseType.Custom => _options.CustomCommandErrorResponseMapper?.Invoke(response, context)
71+
?? HandleErrorCommandResponseWithPlainText(response),
72+
_ => throw new ArgumentOutOfRangeException(
73+
$"Unsupported CommandErrorResponseType: {_options.CommandErrorResponseType}")
74+
};
75+
}
76+
77+
private static IResult HandleErrorCommandResponseWithPlainText(CommandResponse response)
6178
{
6279
if (response.IsValidationError)
6380
{
@@ -71,4 +88,28 @@ private static IResult HandleErrorCommandResponse(CommandResponse response)
7188

7289
return Results.Text(response.GetErrorMessage(), statusCode: 400);
7390
}
91+
92+
private static IResult HandleErrorCommandResponseWithProblemDetails(CommandResponse response)
93+
{
94+
if (response.IsValidationError)
95+
{
96+
var errors = new Dictionary<string, string[]>
97+
{
98+
{
99+
response.ValidationError!.ParameterName ?? "command", new[] { response.ValidationError!.Message }
100+
}
101+
};
102+
return Results.ValidationProblem(errors, statusCode: 400);
103+
}
104+
105+
if (response is { IsConcurrentError: true, LockAcquired: false })
106+
{
107+
return Results.Problem(
108+
"The lock can not be acquired within time limit, please try later.",
109+
statusCode: 429,
110+
title: "Concurrent error");
111+
}
112+
113+
return Results.Problem(response.GetErrorMessage(), statusCode: 400, title: "Execution failed");
114+
}
74115
}
Lines changed: 20 additions & 0 deletions
< 93C6 div data-testid="addition diffstat" class="DiffSquares-module__diffSquare--h5kjy DiffSquares-module__addition--jeNtt">
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using Cnblogs.Architecture.Ddd.Cqrs.Abstractions;
2+
using Microsoft.AspNetCore.Http;
3+
4+
namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore;
5+
6+
/// <summary>
7+
/// Configure options for mapping cqrs responses into http responses.
8+
/// </summary>
9+
public class CqrsHttpOptions
10+
{
11+
/// <summary>
12+
/// Configure the http response type for command errors.
13+
/// </summary>
14+
public ErrorResponseType CommandErrorResponseType { get; set; } = ErrorResponseType.PlainText;
15+
16+
/// <summary>
17+
/// Custom logic to handle error command response.
18+
/// </summary>
19+
public Func<CommandResponse, HttpContext, IResult>? CustomCommandErrorResponseMapper { get; set; }
20+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
using Cnblogs.Architecture.Ddd.Cqrs.Abstractions;
2+
using Cnblogs.Architecture.Ddd.Cqrs.DependencyInjection;
3+
using Microsoft.AspNetCore.Http;
4+
using Microsoft.AspNetCore.Mvc;
5+
using Microsoft.Extensions.DependencyInjection;
6+
7+
namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore;
8+
9+
/// <summary>
10+
/// Extension methods to configure behaviors of mapping command/query response to http response.
11+
/// </summary>
12+
public static class CqrsHttpOptionsInjector
13+
{
14+
/// <summary>
15+
/// Use <see cref="ProblemDetails"/> to represent command response.
16+
/// </summary>
17+
/// <param name="injector">The <see cref="CqrsInjector"/>.</param>
18+
/// <returns></returns>
19+
public static CqrsInjector UseProblemDetails(this CqrsInjector injector)
20+
{
21+
injector.Services.AddProblemDetails();
22+
injector.Services.Configure<CqrsHttpOptions>(
23+
c => c.CommandErrorResponseType = ErrorResponseType.ProblemDetails);
24+
return injector;
25+
}
26+
27+
/// <summary>
28+
/// Use custom mapper to convert command response into HTTP response.
29+
/// </summary>
30+
/// <param name="injector">The <see cref="CqrsInjector"/>.</param>
31+
/// <param name="mapper">The custom map function.</param>
32+
/// <returns></returns>
33+
public static CqrsInjector UseCustomCommandErrorResponseMapper(
34+
this CqrsInjector injector,
35+
Func<CommandResponse, HttpContext, IResult> mapper)
36+
{
37+
injector.Services.Configure<CqrsHttpOptions>(
38+
c =>
39+
{
40+
c.CommandErrorResponseType = ErrorResponseType.Custom;
41+
c.CustomCommandErrorResponseMapper = mapper;
42+
});
43+
return injector;
44+
}
45+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore;
2+
3+
/// <summary>
4+
/// Configure the response type for command errors.
5+
/// </summary>
6+
public enum ErrorResponseType
7+
{
8+
/// <summary>
9+
/// Returns plain text, this is the default behavior.
10+
/// </summary>
11+
PlainText,
12+
13+
/// <summary>
14+
/// Returns <see cref="ProblemDetails"/>.
15+
/// </summary>
16+
ProblemDetails,
17+
18+
/// <summary>
19+
/// Handles command error by custom logic.
20+
/// </summary>
21+
Custom
22+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using Microsoft.AspNetCore.Http;
2+
using Microsoft.AspNetCore.Mvc;
3+
4+
namespace Cnblogs.Architecture.Ddd.Cqrs.AspNetCore;
5+
6+
/// <summary>
7+
/// Used because the same class in AspNetCore framework is internal.
8+
/// </summary>
9+
internal sealed class HttpActionResult : ActionResult
10+
{
11+
/// <summary>
12+
/// Gets the instance of the current <see cref="IResult"/>.
13+
/// </summary>
14+
public IResult Result { get; }
15+
16+
/// <summary>
17+
/// Initializes a new instance of the <see cref="HttpActionResult"/> class with the
18+
/// <see cref="IResult"/> provided.
19+
/// </summary>
20+
/// <param name="result">The <see cref="IResult"/> instance to be used during the <see cref="ExecuteResultAsync"/> invocation.</param>
21+
public HttpActionResult(IResult result)
22+
{
23+
Result = result;
24+
}
25+
26+
/// <inheritdoc/>
27+
public override Task ExecuteResultAsync(ActionContext context) => Result.ExecuteAsync(context.HttpContext);
28+
}

test/Cnblogs.Architecture.IntegrationTestProject/Application/Commands/CommandHandlers.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ public Task<CommandResponse<TestError>> Handle(CreateCommand request, Cancellati
1818
/// <inheritdoc />
1919
public Task<CommandResponse<TestError>> Handle(UpdateCommand request, CancellationToken cancellationToken)
2020
{
21-
return Task.FromResult(request.NeedError
21+
return Task.FromResult(request.NeedExecutionError
2222
? CommandResponse<TestError>.Fail(TestError.Default)
2323
: CommandResponse<TestError>.Success());
2424
}

test/Cnblogs.Architecture.IntegrationTestProject/Application/Commands/UpdateCommand.cs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,21 @@
33

44
namespace Cnblogs.Architecture.IntegrationTestProject.Application.Commands;
55

6-
public record UpdateCommand(int Id, bool NeedError, bool ValidateOnly = false) : ICommand<TestError>;
6+
public record UpdateCommand(
7+
int Id,
8+
bool NeedValidationError,
9+
bool NeedExecutionError,
10+
bool ValidateOnly = false)
11+
: ICommand<TestError>, IValidatable
12+
{
13+
/// <inheritdoc />
14+
public ValidationError? Validate()
15+
{
16+
if (NeedValidationError)
17+
{
18+
return new ValidationError("need validation error", nameof(NeedValidationError));
19+
}
20+
21+
return null;
22+
}
23+
}
Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,35 @@
11
using Asp.Versioning;
22
using Cnblogs.Architecture.Ddd.Cqrs.AspNetCore;
33
using Cnblogs.Architecture.Ddd.Infrastructure.Abstractions;
4-
4+
using Cnblogs.Architecture.IntegrationTestProject.Application.Commands;
5+
using Cnblogs.Architecture.IntegrationTestProject.Payloads;
6+
using MediatR;
57
using Microsoft.AspNetCore.Mvc;
68

79
namespace Cnblogs.Architecture.IntegrationTestProject.Controllers;
810

911
[ApiVersion("1")]
10-
[Route("/api/v{version:apiVersion}")]
12+
[Route("/api/v{version:apiVersion}/mvc")]
1113
public class TestController : ApiControllerBase
1214
{
15+
private readonly IMediator _mediator;
16+
17+
public TestController(IMediator mediator)
18+
{
19+
_mediator = mediator;
20+
}
21+
1322
[HttpGet("paging")]
1423
public Task<PagingParams?> PagingParamsAsync([FromQuery] PagingParams? pagingParams)
1524
{
1625
return Task.FromResult(pagingParams);
1726
}
27+
28+
[HttpPut("strings/{id:int}")]
29+
public async Task<IActionResult> PutStringAsync(int id, [FromBody] UpdatePayload payload)
30+
{
31+
var response =
32+
await _mediator.Send(new UpdateCommand(id, payload.NeedValidationError, payload.NeedExecutionError));
33+
return HandleCommandResponse(response);
34+
}
1835
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
namespace Cnblogs.Architecture.IntegrationTestProject.Payloads;
22

3-
public record UpdatePayload(bool NeedError);
3+
public record UpdatePayload(bool NeedExecutionError = false, bool NeedValidationError = false);

test/Cnblogs.Architecture.IntegrationTestProject/Program.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
v1.MapCommand("strings", (CreatePayload payload) => new CreateCommand(payload.NeedError));
4242
v1.MapCommand(
4343
"strings/{id:int}",
44-
(int id, UpdatePayload payload) => new UpdateCommand(id, payload.NeedError));
44+
(int id, UpdatePayload payload) => new UpdateCommand(id, payload.NeedValidationError, payload.NeedExecutionError));
4545
v1.MapCommand<DeleteCommand>("strings/{id:int}");
4646

4747
app.Run();

0 commit comments

Comments
 (0)
0