Skip to content

Commit 4265228

Browse files
paulirwinclaude
andauthored
Refactor result extensions with RFC7807 problem details support (#11)
## Summary This PR refactors the result extension methods to support RFC7807 Problem Details format and fixes the incorrect HTTP status code for authorization failures. **Fixes #8 and #10** ## Changes - Split monolithic `ResultExtensions` into separate concerns: - `MinimalApiResultExtensions`: Converts Result types to IResult for minimal APIs - `MvcResultExtensions`: Converts Result types to IActionResult for MVC controllers - Add RFC7807 Problem Details support via `useProblemDetails` parameter (default: true) - Add `ValidationErrorExtensions` utility class for standardized error conversion - Fix HTTP status code for authorization failures: 403 Forbidden instead of 401 - Support legacy response formats when `useProblemDetails=false` - Comprehensive test coverage for all result types and response formats - Add example endpoints and controller demonstrating all result types ## Test plan - All existing tests pass with improved coverage - Minimal API examples at `/minimal-apis/results/` demonstrate all result types - MVC examples at `/mvc/results/` demonstrate all result types - Problem details format properly handles validation errors and failures - Legacy response format still works for backward compatibility - HTTP 403 is correctly used for authorization failures 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b204dbd commit 4265228

File tree

13 files changed

+1667
-694
lines changed

13 files changed

+1667
-694
lines changed

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -433,4 +433,6 @@ _UpgradeReport_Files/
433433

434434
Thumbs.db
435435
Desktop.ini
436-
.DS_Store
436+
.DS_Store
437+
**/.idea/
438+
.idea/
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
using System.Net;
2+
using F23.Hateoas;
3+
using F23.Kernel.Results;
4+
using Microsoft.AspNetCore.Http;
5+
using Microsoft.AspNetCore.Http.HttpResults;
6+
using UnauthorizedResult = F23.Kernel.Results.UnauthorizedResult;
7+
using HttpResults = Microsoft.AspNetCore.Http.Results;
8+
9+
namespace F23.Kernel.AspNetCore;
10+
11+
/// <summary>
12+
/// Provides extension methods for converting <see cref="Result"/> objects to ASP.NET Core Minimal API <see cref="IResult"/> objects.
13+
/// </summary>
14+
public static class MinimalApiResultExtensions
15+
{
16+
/// <summary>
17+
/// Converts a <see cref="Result"/> into an appropriate <see cref="IResult"/> to be used in a minimal API context.
18+
/// </summary>
19+
/// <param name="result">The <see cref="Result"/> to be converted.</param>
20+
/// <param name="useProblemDetails">Whether to use a RFC7807 problem details body for a failure response. The default is <c>true</c>.</param>
21+
/// <returns>
22+
/// An <see cref="IResult"/> that represents the appropriate response:
23+
/// <list type="bullet">
24+
/// <item><see cref="NoContent"/> for a <see cref="SuccessResult"/>.</item>
25+
/// <item><see cref="ProblemHttpResult"/> with RFC7807 <see cref="Microsoft.AspNetCore.Mvc.ProblemDetails"/> for any non-successful result if <paramref name="useProblemDetails"/> is <c>true</c>.</item>
26+
/// <item><see cref="NotFound"/> for a <see cref="PreconditionFailedResult"/> with a reason of <see cref="PreconditionFailedReason.NotFound"/> if <paramref name="useProblemDetails"/> is <c>false</c>.</item>
27+
/// <item><see cref="StatusCodeHttpResult"/> with status code 412 for a <see cref="PreconditionFailedResult"/> with a reason of <see cref="PreconditionFailedReason.ConcurrencyMismatch"/> if <paramref name="useProblemDetails"/> is <c>false</c>.</item>
28+
/// <item><see cref="Conflict"/> for a <see cref="PreconditionFailedResult"/> with a reason of <see cref="PreconditionFailedReason.Conflict"/> if <paramref name="useProblemDetails"/> is <c>false</c>.</item>
29+
/// <item><see cref="BadRequest"/> with model state populated for a <see cref="ValidationFailedResult"/> if <paramref name="useProblemDetails"/> is <c>false</c>.</item>
30+
/// <item><see cref="ForbidHttpResult"/> for an <see cref="Kernel.Results.UnauthorizedResult"/> if <paramref name="useProblemDetails"/> is <c>false</c>.</item>
31+
/// </list>
32+
/// </returns>
33+
/// <exception cref="ArgumentOutOfRangeException">
34+
/// Thrown when the <paramref name="result"/> does not match any known result types.
35+
/// </exception>
36+
public static IResult ToMinimalApiResult(this Result result, bool useProblemDetails = true)
37+
=> result switch
38+
{
39+
SuccessResult
40+
=> HttpResults.NoContent(),
41+
AggregateResult { IsSuccess: true }
42+
=> HttpResults.NoContent(),
43+
AggregateResult { IsSuccess: false, Results.Count: > 0 } aggregateResult
44+
=> aggregateResult.Results.First(i => !i.IsSuccess).ToMinimalApiResult(useProblemDetails),
45+
PreconditionFailedResult { Reason: PreconditionFailedReason.NotFound } when useProblemDetails
46+
=> result.ToProblemHttpResult(HttpStatusCode.NotFound),
47+
PreconditionFailedResult { Reason: PreconditionFailedReason.NotFound } when !useProblemDetails
48+
=> HttpResults.NotFound(),
49+
PreconditionFailedResult { Reason: PreconditionFailedReason.ConcurrencyMismatch } when useProblemDetails
50+
=> result.ToProblemHttpResult(HttpStatusCode.PreconditionFailed),
51+
PreconditionFailedResult { Reason: PreconditionFailedReason.ConcurrencyMismatch } when !useProblemDetails
52+
=> HttpResults.StatusCode((int) HttpStatusCode.PreconditionFailed),
53+
PreconditionFailedResult { Reason: PreconditionFailedReason.Conflict } when useProblemDetails
54+
=> result.ToProblemHttpResult(HttpStatusCode.Conflict),
55+
PreconditionFailedResult { Reason: PreconditionFailedReason.Conflict } when !useProblemDetails
56+
=> HttpResults.Conflict(),
57+
ValidationFailedResult validationFailed when useProblemDetails
58+
=> HttpResults.ValidationProblem(errors: validationFailed.Errors.CreateErrorDictionary(), title: result.Message),
59+
ValidationFailedResult validationFailed when !useProblemDetails
60+
=> HttpResults.BadRequest(validationFailed.Errors.ToModelState()),
61+
UnauthorizedResult when useProblemDetails
62+
=> result.ToProblemHttpResult(HttpStatusCode.Forbidden),
63+
UnauthorizedResult when !useProblemDetails
64+
=> HttpResults.Forbid(),
65+
_ => throw new ArgumentOutOfRangeException(nameof(result))
66+
};
67+
68+
/// <summary>
69+
/// Converts a <see cref="Result{T}"/> into an appropriate <see cref="IResult"/> to be used in a minimal API context.
70+
/// </summary>
71+
/// <param name="result">The <see cref="Result{T}"/> instance to be converted.</param>
72+
/// <param name="successMap">
73+
/// An optional function to map the value of a successful result into a user-defined <see cref="IResult"/>.
74+
/// If not provided, successful results will default to an HTTP 200 response with the value serialized as the body.
75+
/// </param>
76+
/// <param name="useProblemDetails">Whether to use a RFC7807 problem details body for a failure response. The default is <c>true</c>.</param>
77+
/// <typeparam name="T">The type of the result's value.</typeparam>
78+
/// <returns>
79+
/// An <see cref="IResult"/> that represents the appropriate response:
80+
/// <list type="bullet">
81+
/// <item><see cref="Ok"/> response for a <see cref="SuccessResult{T}"/> if <paramref name="successMap"/> is not provided.</item>
82+
/// <item>The result of <paramref name="successMap"/> if provided and the result is a <see cref="SuccessResult{T}"/>.</item>
83+
/// <item><see cref="ProblemHttpResult"/> with RFC7807 <see cref="Microsoft.AspNetCore.Mvc.ProblemDetails"/> for any non-successful result if <paramref name="useProblemDetails"/> is <c>true</c>.</item>
84+
/// <item><see cref="NotFound"/> for a <see cref="PreconditionFailedResult{T}"/> with a reason of <see cref="PreconditionFailedReason.NotFound"/> if <paramref name="useProblemDetails"/> is <c>false</c>.</item>
85+
/// <item><see cref="StatusCodeHttpResult"/> with status code 412 for a <see cref="PreconditionFailedResult{T}"/> with a reason of <see cref="PreconditionFailedReason.ConcurrencyMismatch"/> if <paramref name="useProblemDetails"/> is <c>false</c>.</item>
86+
/// <item><see cref="Conflict"/> for a <see cref="PreconditionFailedResult{T}"/> with a reason of <see cref="PreconditionFailedReason.Conflict"/> if <paramref name="useProblemDetails"/> is <c>false</c>.</item>
87+
/// <item><see cref="BadRequest"/> with model state populated for a <see cref="ValidationFailedResult{T}"/> if <paramref name="useProblemDetails"/> is <c>false</c>.</item>
88+
/// <item><see cref="ForbidHttpResult"/> for an <see cref="UnauthorizedResult{T}"/> if <paramref name="useProblemDetails"/> is <c>false</c>.</item>
89+
/// </list>
90+
/// </returns>
91+
/// <exception cref="ArgumentOutOfRangeException">
92+
/// Thrown when the <paramref name="result"/> does not match any known result types.
93+
/// </exception>
94+
public static IResult ToMinimalApiResult<T>(this Result<T> result, Func<T, IResult>? successMap = null, bool useProblemDetails = true)
95+
=> result switch
96+
{
97+
SuccessResult<T> success when successMap != null
98+
=> successMap(success.Value),
99+
SuccessResult<T> success
100+
=> HttpResults.Ok(new HypermediaResponse(success.Value)),
101+
PreconditionFailedResult<T> { Reason: PreconditionFailedReason.NotFound } when useProblemDetails
102+
=> result.ToProblemHttpResult(HttpStatusCode.NotFound),
103+
PreconditionFailedResult<T> { Reason: PreconditionFailedReason.NotFound } when !useProblemDetails
104+
=> HttpResults.NotFound(),
105+
PreconditionFailedResult<T> { Reason: PreconditionFailedReason.ConcurrencyMismatch } when useProblemDetails
106+
=> result.ToProblemHttpResult(HttpStatusCode.PreconditionFailed),
107+
PreconditionFailedResult<T> { Reason: PreconditionFailedReason.ConcurrencyMismatch } when !useProblemDetails
108+
=> HttpResults.StatusCode((int)HttpStatusCode.PreconditionFailed),
109+
PreconditionFailedResult<T> { Reason: PreconditionFailedReason.Conflict } when useProblemDetails
110+
=> result.ToProblemHttpResult(HttpStatusCode.Conflict),
111+
PreconditionFailedResult<T> { Reason: PreconditionFailedReason.Conflict } when !useProblemDetails
112+
=> HttpResults.Conflict(),
113+
ValidationFailedResult<T> validationFailed when useProblemDetails
114+
=> HttpResults.ValidationProblem(errors: validationFailed.Errors.CreateErrorDictionary(), title: result.Message),
115+
ValidationFailedResult<T> validationFailed when !useProblemDetails
116+
=> HttpResults.BadRequest(validationFailed.Errors.ToModelState()),
117+
UnauthorizedResult<T> when useProblemDetails
118+
=> result.ToProblemHttpResult(HttpStatusCode.Forbidden),
119+
UnauthorizedResult<T> when !useProblemDetails
120+
=> HttpResults.Forbid(),
121+
_ => throw new ArgumentOutOfRangeException(nameof(result)),
122+
};
123+
124+
/// <summary>
125+
/// Converts a <see cref="Result"/> into an <see cref="ProblemHttpResult"/> containing RFC7807 <see cref="Microsoft.AspNetCore.Mvc.ProblemDetails"/>.
126+
/// </summary>
127+
/// <param name="result">The <see cref="Result"/> to be converted.</param>
128+
/// <param name="statusCode">The HTTP status code to be set in the <see cref="Microsoft.AspNetCore.Mvc.ProblemDetails"/>.</param>
129+
/// <returns>A <see cref="ProblemHttpResult"/> containing the <see cref="Microsoft.AspNetCore.Mvc.ProblemDetails"/>.</returns>
130+
/// <remarks>
131+
/// This is primarily an internal API, intended to be used from <see cref="ToMinimalApiResult"/> or <see cref="ToMinimalApiResult{T}"/>.
132+
/// However, it might have some desired use cases where direct usage is appropriate, so it is made public.
133+
/// </remarks>
134+
public static IResult ToProblemHttpResult(this Result result, HttpStatusCode statusCode)
135+
=> HttpResults.Problem(title: result.Message, statusCode: (int)statusCode);
136+
}

0 commit comments

Comments
 (0)