diff --git a/src/F23.Kernel.AspNetCore/MinimalApiResultExtensions.cs b/src/F23.Kernel.AspNetCore/MinimalApiResultExtensions.cs index c5c8853..6727473 100644 --- a/src/F23.Kernel.AspNetCore/MinimalApiResultExtensions.cs +++ b/src/F23.Kernel.AspNetCore/MinimalApiResultExtensions.cs @@ -22,7 +22,7 @@ public static class MinimalApiResultExtensions /// An that represents the appropriate response: /// /// for a . - /// with RFC7807 for any non-successful result if is true. + /// with RFC7807 for any non-successful result if is true. For a precondition failure the status follows the reason (404 for , 412 for , 409 for ), and the reason is in a reason extension member. /// for a with a reason of if is false. /// with status code 412 for a with a reason of if is false. /// for a with a reason of if is false. @@ -42,16 +42,12 @@ public static IResult ToMinimalApiResult(this Result result, bool useProblemDeta => HttpResults.NoContent(), AggregateResult { IsSuccess: false, Results.Count: > 0 } aggregateResult => aggregateResult.Results.First(i => !i.IsSuccess).ToMinimalApiResult(useProblemDetails), - PreconditionFailedResult { Reason: PreconditionFailedReason.NotFound } when useProblemDetails - => result.ToProblemHttpResult(HttpStatusCode.NotFound), + PreconditionFailedResult preconditionFailed when useProblemDetails + => result.ToProblemHttpResult(preconditionFailed.Reason), PreconditionFailedResult { Reason: PreconditionFailedReason.NotFound } when !useProblemDetails => HttpResults.NotFound(), - PreconditionFailedResult { Reason: PreconditionFailedReason.ConcurrencyMismatch } when useProblemDetails - => result.ToProblemHttpResult(HttpStatusCode.PreconditionFailed), PreconditionFailedResult { Reason: PreconditionFailedReason.ConcurrencyMismatch } when !useProblemDetails => HttpResults.StatusCode((int) HttpStatusCode.PreconditionFailed), - PreconditionFailedResult { Reason: PreconditionFailedReason.Conflict } when useProblemDetails - => result.ToProblemHttpResult(HttpStatusCode.Conflict), PreconditionFailedResult { Reason: PreconditionFailedReason.Conflict } when !useProblemDetails => HttpResults.Conflict(), ValidationFailedResult validationFailed when useProblemDetails @@ -80,7 +76,7 @@ UnauthorizedResult when !useProblemDetails /// /// response for a if is not provided. /// The result of if provided and the result is a . - /// with RFC7807 for any non-successful result if is true. + /// with RFC7807 for any non-successful result if is true. For a precondition failure the status follows the reason (404 for , 412 for , 409 for ), and the reason is in a reason extension member. /// for a with a reason of if is false. /// with status code 412 for a with a reason of if is false. /// for a with a reason of if is false. @@ -98,16 +94,12 @@ public static IResult ToMinimalApiResult(this Result result, Func successMap(success.Value), SuccessResult success => HttpResults.Ok(new HypermediaResponse(success.Value!)), // [!]: TODO: Fix nullability issue in F23.Hateoas - PreconditionFailedResult { Reason: PreconditionFailedReason.NotFound } when useProblemDetails - => result.ToProblemHttpResult(HttpStatusCode.NotFound), + PreconditionFailedResult preconditionFailed when useProblemDetails + => result.ToProblemHttpResult(preconditionFailed.Reason), PreconditionFailedResult { Reason: PreconditionFailedReason.NotFound } when !useProblemDetails => HttpResults.NotFound(), - PreconditionFailedResult { Reason: PreconditionFailedReason.ConcurrencyMismatch } when useProblemDetails - => result.ToProblemHttpResult(HttpStatusCode.PreconditionFailed), PreconditionFailedResult { Reason: PreconditionFailedReason.ConcurrencyMismatch } when !useProblemDetails => HttpResults.StatusCode((int)HttpStatusCode.PreconditionFailed), - PreconditionFailedResult { Reason: PreconditionFailedReason.Conflict } when useProblemDetails - => result.ToProblemHttpResult(HttpStatusCode.Conflict), PreconditionFailedResult { Reason: PreconditionFailedReason.Conflict } when !useProblemDetails => HttpResults.Conflict(), ValidationFailedResult validationFailed when useProblemDetails @@ -133,4 +125,13 @@ UnauthorizedResult when !useProblemDetails /// public static IResult ToProblemHttpResult(this Result result, HttpStatusCode statusCode) => HttpResults.Problem(title: result.Message, statusCode: (int)statusCode); + + private static IResult ToProblemHttpResult(this Result result, PreconditionFailedReason reason) + => HttpResults.Problem( + title: result.Message, + statusCode: (int)reason.ToHttpStatusCode(), + extensions: new Dictionary + { + [PreconditionFailedReasonHttpExtensions.ReasonExtension] = reason.ToString(), + }); } diff --git a/src/F23.Kernel.AspNetCore/MvcResultExtensions.cs b/src/F23.Kernel.AspNetCore/MvcResultExtensions.cs index 21e6ea3..6fb6d1d 100644 --- a/src/F23.Kernel.AspNetCore/MvcResultExtensions.cs +++ b/src/F23.Kernel.AspNetCore/MvcResultExtensions.cs @@ -21,7 +21,7 @@ public static class MvcResultExtensions /// An representing the HTTP response: /// /// for . - /// with for any non-successful result if is true. + /// with for any non-successful result if is true. For a precondition failure the status follows the reason (404 for , 412 for , 409 for ), and the reason is in a reason extension member. /// for a indicating if is false. /// with HTTP status code 412 for a indicating if is false. /// for a indicating if is false. @@ -41,16 +41,12 @@ public static IActionResult ToActionResult(this Result result, bool useProblemDe => new NoContentResult(), AggregateResult { IsSuccess: false, Results.Count: > 0 } aggregateResult => aggregateResult.Results.First(i => !i.IsSuccess).ToActionResult(useProblemDetails), - PreconditionFailedResult { Reason: PreconditionFailedReason.NotFound } when useProblemDetails - => result.ToProblemDetailsResult(HttpStatusCode.NotFound), + PreconditionFailedResult preconditionFailed when useProblemDetails + => result.ToProblemDetailsResult(preconditionFailed.Reason), PreconditionFailedResult { Reason: PreconditionFailedReason.NotFound } when !useProblemDetails => new NotFoundResult(), - PreconditionFailedResult { Reason: PreconditionFailedReason.ConcurrencyMismatch } when useProblemDetails - => result.ToProblemDetailsResult(HttpStatusCode.PreconditionFailed), PreconditionFailedResult { Reason: PreconditionFailedReason.ConcurrencyMismatch } when !useProblemDetails => new StatusCodeResult((int) HttpStatusCode.PreconditionFailed), - PreconditionFailedResult { Reason: PreconditionFailedReason.Conflict } when useProblemDetails - => result.ToProblemDetailsResult(HttpStatusCode.Conflict), PreconditionFailedResult { Reason: PreconditionFailedReason.Conflict } when !useProblemDetails => new ConflictResult(), ValidationFailedResult validationFailed when useProblemDetails @@ -87,7 +83,7 @@ UnauthorizedResult when !useProblemDetails /// /// The result of , if specified, for a . /// for a , when is not specified. - /// with for any non-successful result if is true. + /// with for any non-successful result if is true. For a precondition failure the status follows the reason (404 for , 412 for , 409 for ), and the reason is in a reason extension member. /// for a indicating if is false. /// with HTTP status code 412 for a indicating if is false. /// for a indicating if is false. @@ -105,16 +101,12 @@ public static IActionResult ToActionResult(this Result result, Func successMap(success.Value), SuccessResult success => new OkObjectResult(new HypermediaResponse(success.Value!)), // [!]: TODO: Fix nullability issue in F23.Hateoas - PreconditionFailedResult { Reason: PreconditionFailedReason.NotFound } when useProblemDetails - => result.ToProblemDetailsResult(HttpStatusCode.NotFound), + PreconditionFailedResult preconditionFailed when useProblemDetails + => result.ToProblemDetailsResult(preconditionFailed.Reason), PreconditionFailedResult { Reason: PreconditionFailedReason.NotFound } when !useProblemDetails => new NotFoundResult(), - PreconditionFailedResult { Reason: PreconditionFailedReason.ConcurrencyMismatch } when useProblemDetails - => result.ToProblemDetailsResult(HttpStatusCode.PreconditionFailed), PreconditionFailedResult { Reason: PreconditionFailedReason.ConcurrencyMismatch } when !useProblemDetails => new StatusCodeResult((int) HttpStatusCode.PreconditionFailed), - PreconditionFailedResult { Reason: PreconditionFailedReason.Conflict } when useProblemDetails - => result.ToProblemDetailsResult(HttpStatusCode.Conflict), PreconditionFailedResult { Reason: PreconditionFailedReason.Conflict } when !useProblemDetails => new ConflictResult(), ValidationFailedResult validationFailed when useProblemDetails @@ -154,4 +146,20 @@ public static IActionResult ToProblemDetailsResult(this Result result, HttpStatu { StatusCode = (int)statusCode, }; + + private static IActionResult ToProblemDetailsResult(this Result result, PreconditionFailedReason reason) + { + var statusCode = reason.ToHttpStatusCode(); + var problemDetails = new ProblemDetails + { + Title = result.Message, + Status = (int)statusCode, + }; + problemDetails.Extensions[PreconditionFailedReasonHttpExtensions.ReasonExtension] = reason.ToString(); + + return new ObjectResult(problemDetails) + { + StatusCode = (int)statusCode, + }; + } } diff --git a/src/F23.Kernel.AspNetCore/PreconditionFailedReasonHttpExtensions.cs b/src/F23.Kernel.AspNetCore/PreconditionFailedReasonHttpExtensions.cs new file mode 100644 index 0000000..4043308 --- /dev/null +++ b/src/F23.Kernel.AspNetCore/PreconditionFailedReasonHttpExtensions.cs @@ -0,0 +1,33 @@ +using System.Net; +using F23.Kernel.Results; + +namespace F23.Kernel.AspNetCore; + +/// +/// The HTTP status each maps to, shared by the MVC and Minimal API adapters +/// so that a new reason is mapped in one place. +/// +internal static class PreconditionFailedReasonHttpExtensions +{ + /// + /// The name of the problem details extension member that carries the . + /// + /// + /// Lets a client branch on the reason without parsing the title, which is free text and may be a custom message. + /// + internal const string ReasonExtension = "reason"; + + /// + /// Gets the HTTP status code for a precondition failure. + /// + /// The reason for the precondition failure. + /// The HTTP status code to respond with. + /// Thrown when is not a defined value. + internal static HttpStatusCode ToHttpStatusCode(this PreconditionFailedReason reason) => reason switch + { + PreconditionFailedReason.NotFound => HttpStatusCode.NotFound, + PreconditionFailedReason.ConcurrencyMismatch => HttpStatusCode.PreconditionFailed, + PreconditionFailedReason.Conflict => HttpStatusCode.Conflict, + _ => throw new ArgumentOutOfRangeException(nameof(reason), reason, null) + }; +} diff --git a/src/F23.Kernel.Tests/AspNetCore/MinimalApiResultExtensionsTests.cs b/src/F23.Kernel.Tests/AspNetCore/MinimalApiResultExtensionsTests.cs index fbd4c1f..2f7e734 100644 --- a/src/F23.Kernel.Tests/AspNetCore/MinimalApiResultExtensionsTests.cs +++ b/src/F23.Kernel.Tests/AspNetCore/MinimalApiResultExtensionsTests.cs @@ -2,6 +2,7 @@ using F23.Hateoas; using F23.Kernel.AspNetCore; using F23.Kernel.Results; +using F23.Kernel.Tests.Mocks; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -428,5 +429,21 @@ public override Result Map() { throw new NotImplementedException(); } - } -} + } + + [Theory] + [MemberData(nameof(PreconditionFailedReasons.All), MemberType = typeof(PreconditionFailedReasons))] + public void PreconditionFailedResult_With_UseProblemDetails_Carries_The_Reason(PreconditionFailedReason reason) + { + foreach (var httpResult in new[] + { + new PreconditionFailedResult(reason, "message").ToMinimalApiResult(), + new PreconditionFailedResult(reason, "message").ToMinimalApiResult() + }) + { + var problem = Assert.IsType(httpResult); + Assert.Equal(reason.ToString(), problem.ProblemDetails.Extensions["reason"]); + Assert.Equal("message", problem.ProblemDetails.Title); + } + } +} diff --git a/src/F23.Kernel.Tests/AspNetCore/MvcResultExtensionsTests.cs b/src/F23.Kernel.Tests/AspNetCore/MvcResultExtensionsTests.cs index 47b819e..ec7e1ec 100644 --- a/src/F23.Kernel.Tests/AspNetCore/MvcResultExtensionsTests.cs +++ b/src/F23.Kernel.Tests/AspNetCore/MvcResultExtensionsTests.cs @@ -2,6 +2,7 @@ using F23.Hateoas; using F23.Kernel.AspNetCore; using F23.Kernel.Results; +using F23.Kernel.Tests.Mocks; using Microsoft.AspNetCore.Mvc; namespace F23.Kernel.Tests.AspNetCore; @@ -465,5 +466,24 @@ public override Result Map() { throw new NotImplementedException(); } - } -} + } + + [Theory] + [MemberData(nameof(PreconditionFailedReasons.All), MemberType = typeof(PreconditionFailedReasons))] + public void PreconditionFailedResult_With_UseProblemDetails_Carries_The_Reason(PreconditionFailedReason reason) + { + // Act + var plain = new PreconditionFailedResult(reason, "message").ToActionResult(useProblemDetails: true); + var typed = new PreconditionFailedResult(reason, "message").ToActionResult(useProblemDetails: true); + + // Assert + foreach (var actionResult in new[] { plain, typed }) + { + var objectResult = Assert.IsType(actionResult); + var problemDetails = Assert.IsType(objectResult.Value); + Assert.Equal(reason.ToString(), problemDetails.Extensions["reason"]); + Assert.Equal("message", problemDetails.Title); + Assert.Equal(problemDetails.Status, objectResult.StatusCode); + } + } +} diff --git a/src/F23.Kernel.Tests/AspNetCore/PreconditionFailedReasonHttpExtensionsTests.cs b/src/F23.Kernel.Tests/AspNetCore/PreconditionFailedReasonHttpExtensionsTests.cs new file mode 100644 index 0000000..b41181d --- /dev/null +++ b/src/F23.Kernel.Tests/AspNetCore/PreconditionFailedReasonHttpExtensionsTests.cs @@ -0,0 +1,35 @@ +using System.Net; +using F23.Kernel.AspNetCore; +using F23.Kernel.Results; +using F23.Kernel.Tests.Mocks; + +namespace F23.Kernel.Tests.AspNetCore; + +public class PreconditionFailedReasonHttpExtensionsTests +{ + [Theory] + [MemberData(nameof(PreconditionFailedReasons.All), MemberType = typeof(PreconditionFailedReasons))] + public void EveryReason_HasAStatusCode(PreconditionFailedReason reason) + { + // Act + var statusCode = reason.ToHttpStatusCode(); + + // Assert + Assert.InRange((int)statusCode, 400, 499); + } + + [Theory] + [InlineData(PreconditionFailedReason.NotFound, HttpStatusCode.NotFound)] + [InlineData(PreconditionFailedReason.ConcurrencyMismatch, HttpStatusCode.PreconditionFailed)] + [InlineData(PreconditionFailedReason.Conflict, HttpStatusCode.Conflict)] + public void Reason_MapsToItsStatusCode(PreconditionFailedReason reason, HttpStatusCode expected) + { + Assert.Equal(expected, reason.ToHttpStatusCode()); + } + + [Fact] + public void UndefinedReason_Throws() + { + Assert.Throws(() => ((PreconditionFailedReason)999).ToHttpStatusCode()); + } +} diff --git a/src/F23.Kernel.Tests/Mocks/PreconditionFailedReasons.cs b/src/F23.Kernel.Tests/Mocks/PreconditionFailedReasons.cs new file mode 100644 index 0000000..7779506 --- /dev/null +++ b/src/F23.Kernel.Tests/Mocks/PreconditionFailedReasons.cs @@ -0,0 +1,18 @@ +using F23.Kernel.Results; + +namespace F23.Kernel.Tests.Mocks; + +public static class PreconditionFailedReasons +{ + public static TheoryData All() + { + var data = new TheoryData(); + + foreach (var reason in Enum.GetValues()) + { + data.Add(reason); + } + + return data; + } +} diff --git a/src/F23.Kernel.Tests/Results/PreconditionFailedResultTests.cs b/src/F23.Kernel.Tests/Results/PreconditionFailedResultTests.cs index 645b6c0..75f66e8 100644 --- a/src/F23.Kernel.Tests/Results/PreconditionFailedResultTests.cs +++ b/src/F23.Kernel.Tests/Results/PreconditionFailedResultTests.cs @@ -1,13 +1,12 @@ using F23.Kernel.Results; +using F23.Kernel.Tests.Mocks; namespace F23.Kernel.Tests.Results; public class PreconditionFailedResultTests { [Theory] - [InlineData(PreconditionFailedReason.NotFound)] - [InlineData(PreconditionFailedReason.ConcurrencyMismatch)] - [InlineData(PreconditionFailedReason.Conflict)] + [MemberData(nameof(PreconditionFailedReasons.All), MemberType = typeof(PreconditionFailedReasons))] public void PreconditionFailedResult_SuccessIsFalse(PreconditionFailedReason reason) { // Act @@ -18,9 +17,7 @@ public void PreconditionFailedResult_SuccessIsFalse(PreconditionFailedReason rea } [Theory] - [InlineData(PreconditionFailedReason.NotFound)] - [InlineData(PreconditionFailedReason.ConcurrencyMismatch)] - [InlineData(PreconditionFailedReason.Conflict)] + [MemberData(nameof(PreconditionFailedReasons.All), MemberType = typeof(PreconditionFailedReasons))] public void MappingPreconditionFailedResultToOther_PreservesTheReasonAndMessage(PreconditionFailedReason reason) { // Arrange @@ -36,6 +33,17 @@ public void MappingPreconditionFailedResultToOther_PreservesTheReasonAndMessage( Assert.Equal(originalMessage, preconditionFailedResult.Message); } + [Theory] + [MemberData(nameof(PreconditionFailedReasons.All), MemberType = typeof(PreconditionFailedReasons))] + public void PreconditionFailedResult_WithoutMessage_HasADefaultMessageForEveryReason(PreconditionFailedReason reason) + { + // Act + var result = new PreconditionFailedResult(reason, null); + + // Assert + Assert.False(string.IsNullOrWhiteSpace(result.Message)); + } + private class Original { } private class Other { }