From 99adc3dd4e1c2c1e4ef2c0182aaf0c7a29874d0f Mon Sep 17 00:00:00 2001 From: Amber Mickler Date: Thu, 5 Nov 2026 10:25:13 -0500 Subject: [PATCH 1/2] Add AnswerRequired precondition reason and carry the reason in problem details - PreconditionFailedReason.AnswerRequired = 4, with a default message. - One reason-to-status map (PreconditionFailedReasonHttpExtensions) shared by the MVC and Minimal API adapters; AnswerRequired maps to 422. - Problem-details bodies for a precondition failure carry a "reason" extension member. - Precondition theories enumerate every reason instead of a hard-coded list. Co-Authored-By: Claude Opus 5.5 --- .../MinimalApiResultExtensions.cs | 35 +++++++++------- .../MvcResultExtensions.cs | 42 ++++++++++++------- .../PreconditionFailedReasonHttpExtensions.cs | 34 +++++++++++++++ .../MinimalApiResultExtensionsTests.cs | 36 +++++++++++++++- .../AspNetCore/MvcResultExtensionsTests.cs | 42 ++++++++++++++++++- ...onditionFailedReasonHttpExtensionsTests.cs | 36 ++++++++++++++++ .../Mocks/PreconditionFailedReasons.cs | 18 ++++++++ .../Results/PreconditionFailedResultTests.cs | 20 ++++++--- .../Results/PreconditionFailedReason.cs | 7 ++++ 9 files changed, 232 insertions(+), 38 deletions(-) create mode 100644 src/F23.Kernel.AspNetCore/PreconditionFailedReasonHttpExtensions.cs create mode 100644 src/F23.Kernel.Tests/AspNetCore/PreconditionFailedReasonHttpExtensionsTests.cs create mode 100644 src/F23.Kernel.Tests/Mocks/PreconditionFailedReasons.cs diff --git a/src/F23.Kernel.AspNetCore/MinimalApiResultExtensions.cs b/src/F23.Kernel.AspNetCore/MinimalApiResultExtensions.cs index c5c8853..37a8aa1 100644 --- a/src/F23.Kernel.AspNetCore/MinimalApiResultExtensions.cs +++ b/src/F23.Kernel.AspNetCore/MinimalApiResultExtensions.cs @@ -22,10 +22,11 @@ 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 , 422 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. + /// for a with a reason of if is false. /// with model state populated for a if is false. /// for an if is false. /// @@ -42,18 +43,16 @@ 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(), + PreconditionFailedResult { Reason: PreconditionFailedReason.AnswerRequired } when !useProblemDetails + => HttpResults.UnprocessableEntity(), ValidationFailedResult validationFailed when useProblemDetails => HttpResults.ValidationProblem(errors: validationFailed.Errors.CreateErrorDictionary(), title: result.Message), ValidationFailedResult validationFailed when !useProblemDetails @@ -80,10 +79,11 @@ 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 , 422 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. + /// for a with a reason of if is false. /// with model state populated for a if is false. /// for an if is false. /// @@ -98,18 +98,16 @@ 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(), + PreconditionFailedResult { Reason: PreconditionFailedReason.AnswerRequired } when !useProblemDetails + => HttpResults.UnprocessableEntity(), ValidationFailedResult validationFailed when useProblemDetails => HttpResults.ValidationProblem(errors: validationFailed.Errors.CreateErrorDictionary(), title: result.Message), ValidationFailedResult validationFailed when !useProblemDetails @@ -133,4 +131,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..6debf6c 100644 --- a/src/F23.Kernel.AspNetCore/MvcResultExtensions.cs +++ b/src/F23.Kernel.AspNetCore/MvcResultExtensions.cs @@ -21,10 +21,11 @@ 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 , 422 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. + /// for a with a reason of if is false. /// with model state populated for a if is false. /// in case of an if is false. /// @@ -41,18 +42,16 @@ 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(), + PreconditionFailedResult { Reason: PreconditionFailedReason.AnswerRequired } when !useProblemDetails + => new UnprocessableEntityResult(), ValidationFailedResult validationFailed when useProblemDetails => new ObjectResult(new ValidationProblemDetails(validationFailed.Errors.ToModelState()) { @@ -87,10 +86,11 @@ 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 , 422 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. + /// for a with a reason of if is false. /// with model state populated for a if is false. /// in case of an if is false. /// @@ -105,18 +105,16 @@ 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(), + PreconditionFailedResult { Reason: PreconditionFailedReason.AnswerRequired } when !useProblemDetails + => new UnprocessableEntityResult(), ValidationFailedResult validationFailed when useProblemDetails => new ObjectResult(new ValidationProblemDetails(validationFailed.Errors.ToModelState()) { @@ -154,4 +152,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..8488e6e --- /dev/null +++ b/src/F23.Kernel.AspNetCore/PreconditionFailedReasonHttpExtensions.cs @@ -0,0 +1,34 @@ +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, + PreconditionFailedReason.AnswerRequired => HttpStatusCode.UnprocessableEntity, + _ => 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..a972a5d 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,36 @@ 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); + } + } + + [Theory] + [InlineData(PreconditionFailedReason.AnswerRequired, HttpStatusCode.UnprocessableEntity)] + public void PreconditionFailedResult_AnswerRequired_With_UseProblemDetails_Returns_Its_Status(PreconditionFailedReason reason, HttpStatusCode expected) + { + Assert.Equal((int)expected, Assert.IsType(new PreconditionFailedResult(reason, null).ToMinimalApiResult()).StatusCode); + Assert.Equal((int)expected, Assert.IsType(new PreconditionFailedResult(reason, null).ToMinimalApiResult()).StatusCode); + } + + [Fact] + public void PreconditionFailedResult_AnswerRequired_Without_ProblemDetails_Returns_UnprocessableEntity() + { + Assert.IsType(new PreconditionFailedResult(PreconditionFailedReason.AnswerRequired, null).ToMinimalApiResult(useProblemDetails: false)); + Assert.IsType(new PreconditionFailedResult(PreconditionFailedReason.AnswerRequired, null).ToMinimalApiResult(useProblemDetails: false)); + } +} diff --git a/src/F23.Kernel.Tests/AspNetCore/MvcResultExtensionsTests.cs b/src/F23.Kernel.Tests/AspNetCore/MvcResultExtensionsTests.cs index 47b819e..a2a108a 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,42 @@ 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); + } + } + + [Theory] + [InlineData(PreconditionFailedReason.AnswerRequired, HttpStatusCode.UnprocessableEntity)] + public void PreconditionFailedResult_AnswerRequired_With_UseProblemDetails_Returns_Its_Status(PreconditionFailedReason reason, HttpStatusCode expected) + { + var plain = new PreconditionFailedResult(reason, null).ToActionResult(useProblemDetails: true); + var typed = new PreconditionFailedResult(reason, null).ToActionResult(useProblemDetails: true); + + Assert.Equal((int)expected, Assert.IsType(plain).StatusCode); + Assert.Equal((int)expected, Assert.IsType(typed).StatusCode); + } + + [Fact] + public void PreconditionFailedResult_AnswerRequired_Returns_UnprocessableEntityResult() + { + Assert.IsType(new PreconditionFailedResult(PreconditionFailedReason.AnswerRequired, null).ToActionResult(useProblemDetails: false)); + Assert.IsType(new PreconditionFailedResult(PreconditionFailedReason.AnswerRequired, null).ToActionResult(useProblemDetails: false)); + } +} diff --git a/src/F23.Kernel.Tests/AspNetCore/PreconditionFailedReasonHttpExtensionsTests.cs b/src/F23.Kernel.Tests/AspNetCore/PreconditionFailedReasonHttpExtensionsTests.cs new file mode 100644 index 0000000..edb4a30 --- /dev/null +++ b/src/F23.Kernel.Tests/AspNetCore/PreconditionFailedReasonHttpExtensionsTests.cs @@ -0,0 +1,36 @@ +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)] + [InlineData(PreconditionFailedReason.AnswerRequired, HttpStatusCode.UnprocessableEntity)] + 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 { } diff --git a/src/F23.Kernel/Results/PreconditionFailedReason.cs b/src/F23.Kernel/Results/PreconditionFailedReason.cs index 2273ef0..30e34da 100644 --- a/src/F23.Kernel/Results/PreconditionFailedReason.cs +++ b/src/F23.Kernel/Results/PreconditionFailedReason.cs @@ -19,6 +19,12 @@ public enum PreconditionFailedReason /// Indicates that a conflict with the current state of the resource caused the failure of a precondition. /// Conflict = 3, + + /// + /// Indicates that the operation cannot proceed until the caller supplies an answer to a question it raised, + /// such as a confirmation or a choice between alternatives. + /// + AnswerRequired = 4, } /// @@ -39,6 +45,7 @@ internal static class PreconditionFailedReasonExtensions PreconditionFailedReason.NotFound => "A requested resource was not found.", PreconditionFailedReason.ConcurrencyMismatch => "A concurrency mismatch occurred.", PreconditionFailedReason.Conflict => "A conflict occurred with the current state of the resource.", + PreconditionFailedReason.AnswerRequired => "The operation needs an answer before it can proceed.", _ => throw new ArgumentOutOfRangeException(nameof(reason), reason, null) }; } From c6b7a2ac6ade9aec19cbd7ac2fd4735b4c73c4ea Mon Sep 17 00:00:00 2001 From: Amber Mickler Date: Thu, 5 Nov 2026 12:38:31 -0500 Subject: [PATCH 2/2] Remove AnswerRequired; keep the shared status map and the reason member Review feedback: AnswerRequired describes an interaction protocol rather than resource state, so it doesn't belong in PreconditionFailedReason. The app-specific case moves to an optional code on the result, in a separate PR. Co-Authored-By: Claude Opus 5.5 --- .../MinimalApiResultExtensions.cs | 10 ++-------- .../MvcResultExtensions.cs | 10 ++-------- .../PreconditionFailedReasonHttpExtensions.cs | 1 - .../MinimalApiResultExtensionsTests.cs | 15 --------------- .../AspNetCore/MvcResultExtensionsTests.cs | 18 ------------------ ...conditionFailedReasonHttpExtensionsTests.cs | 1 - .../Results/PreconditionFailedReason.cs | 7 ------- 7 files changed, 4 insertions(+), 58 deletions(-) diff --git a/src/F23.Kernel.AspNetCore/MinimalApiResultExtensions.cs b/src/F23.Kernel.AspNetCore/MinimalApiResultExtensions.cs index 37a8aa1..6727473 100644 --- a/src/F23.Kernel.AspNetCore/MinimalApiResultExtensions.cs +++ b/src/F23.Kernel.AspNetCore/MinimalApiResultExtensions.cs @@ -22,11 +22,10 @@ public static class MinimalApiResultExtensions /// An that represents the appropriate response: /// /// for a . - /// 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 , 422 for ), and the reason is in a reason extension member. + /// 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. - /// for a with a reason of if is false. /// with model state populated for a if is false. /// for an if is false. /// @@ -51,8 +50,6 @@ PreconditionFailedResult preconditionFailed when useProblemDetails => HttpResults.StatusCode((int) HttpStatusCode.PreconditionFailed), PreconditionFailedResult { Reason: PreconditionFailedReason.Conflict } when !useProblemDetails => HttpResults.Conflict(), - PreconditionFailedResult { Reason: PreconditionFailedReason.AnswerRequired } when !useProblemDetails - => HttpResults.UnprocessableEntity(), ValidationFailedResult validationFailed when useProblemDetails => HttpResults.ValidationProblem(errors: validationFailed.Errors.CreateErrorDictionary(), title: result.Message), ValidationFailedResult validationFailed when !useProblemDetails @@ -79,11 +76,10 @@ 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. For a precondition failure the status follows the reason (404 for , 412 for , 409 for , 422 for ), and the reason is in a reason extension member. + /// 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. - /// for a with a reason of if is false. /// with model state populated for a if is false. /// for an if is false. /// @@ -106,8 +102,6 @@ PreconditionFailedResult preconditionFailed when useProblemDetails => HttpResults.StatusCode((int)HttpStatusCode.PreconditionFailed), PreconditionFailedResult { Reason: PreconditionFailedReason.Conflict } when !useProblemDetails => HttpResults.Conflict(), - PreconditionFailedResult { Reason: PreconditionFailedReason.AnswerRequired } when !useProblemDetails - => HttpResults.UnprocessableEntity(), ValidationFailedResult validationFailed when useProblemDetails => HttpResults.ValidationProblem(errors: validationFailed.Errors.CreateErrorDictionary(), title: result.Message), ValidationFailedResult validationFailed when !useProblemDetails diff --git a/src/F23.Kernel.AspNetCore/MvcResultExtensions.cs b/src/F23.Kernel.AspNetCore/MvcResultExtensions.cs index 6debf6c..6fb6d1d 100644 --- a/src/F23.Kernel.AspNetCore/MvcResultExtensions.cs +++ b/src/F23.Kernel.AspNetCore/MvcResultExtensions.cs @@ -21,11 +21,10 @@ public static class MvcResultExtensions /// An representing the HTTP response: /// /// for . - /// with for any non-successful result if is true. For a precondition failure the status follows the reason (404 for , 412 for , 409 for , 422 for ), and the reason is in a reason extension member. + /// 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. - /// for a with a reason of if is false. /// with model state populated for a if is false. /// in case of an if is false. /// @@ -50,8 +49,6 @@ PreconditionFailedResult preconditionFailed when useProblemDetails => new StatusCodeResult((int) HttpStatusCode.PreconditionFailed), PreconditionFailedResult { Reason: PreconditionFailedReason.Conflict } when !useProblemDetails => new ConflictResult(), - PreconditionFailedResult { Reason: PreconditionFailedReason.AnswerRequired } when !useProblemDetails - => new UnprocessableEntityResult(), ValidationFailedResult validationFailed when useProblemDetails => new ObjectResult(new ValidationProblemDetails(validationFailed.Errors.ToModelState()) { @@ -86,11 +83,10 @@ 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. For a precondition failure the status follows the reason (404 for , 412 for , 409 for , 422 for ), and the reason is in a reason extension member. + /// 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. - /// for a with a reason of if is false. /// with model state populated for a if is false. /// in case of an if is false. /// @@ -113,8 +109,6 @@ PreconditionFailedResult preconditionFailed when useProblemDetails => new StatusCodeResult((int) HttpStatusCode.PreconditionFailed), PreconditionFailedResult { Reason: PreconditionFailedReason.Conflict } when !useProblemDetails => new ConflictResult(), - PreconditionFailedResult { Reason: PreconditionFailedReason.AnswerRequired } when !useProblemDetails - => new UnprocessableEntityResult(), ValidationFailedResult validationFailed when useProblemDetails => new ObjectResult(new ValidationProblemDetails(validationFailed.Errors.ToModelState()) { diff --git a/src/F23.Kernel.AspNetCore/PreconditionFailedReasonHttpExtensions.cs b/src/F23.Kernel.AspNetCore/PreconditionFailedReasonHttpExtensions.cs index 8488e6e..4043308 100644 --- a/src/F23.Kernel.AspNetCore/PreconditionFailedReasonHttpExtensions.cs +++ b/src/F23.Kernel.AspNetCore/PreconditionFailedReasonHttpExtensions.cs @@ -28,7 +28,6 @@ internal static class PreconditionFailedReasonHttpExtensions PreconditionFailedReason.NotFound => HttpStatusCode.NotFound, PreconditionFailedReason.ConcurrencyMismatch => HttpStatusCode.PreconditionFailed, PreconditionFailedReason.Conflict => HttpStatusCode.Conflict, - PreconditionFailedReason.AnswerRequired => HttpStatusCode.UnprocessableEntity, _ => 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 a972a5d..2f7e734 100644 --- a/src/F23.Kernel.Tests/AspNetCore/MinimalApiResultExtensionsTests.cs +++ b/src/F23.Kernel.Tests/AspNetCore/MinimalApiResultExtensionsTests.cs @@ -446,19 +446,4 @@ public void PreconditionFailedResult_With_UseProblemDetails_Carries_The_Reason(P Assert.Equal("message", problem.ProblemDetails.Title); } } - - [Theory] - [InlineData(PreconditionFailedReason.AnswerRequired, HttpStatusCode.UnprocessableEntity)] - public void PreconditionFailedResult_AnswerRequired_With_UseProblemDetails_Returns_Its_Status(PreconditionFailedReason reason, HttpStatusCode expected) - { - Assert.Equal((int)expected, Assert.IsType(new PreconditionFailedResult(reason, null).ToMinimalApiResult()).StatusCode); - Assert.Equal((int)expected, Assert.IsType(new PreconditionFailedResult(reason, null).ToMinimalApiResult()).StatusCode); - } - - [Fact] - public void PreconditionFailedResult_AnswerRequired_Without_ProblemDetails_Returns_UnprocessableEntity() - { - Assert.IsType(new PreconditionFailedResult(PreconditionFailedReason.AnswerRequired, null).ToMinimalApiResult(useProblemDetails: false)); - Assert.IsType(new PreconditionFailedResult(PreconditionFailedReason.AnswerRequired, null).ToMinimalApiResult(useProblemDetails: false)); - } } diff --git a/src/F23.Kernel.Tests/AspNetCore/MvcResultExtensionsTests.cs b/src/F23.Kernel.Tests/AspNetCore/MvcResultExtensionsTests.cs index a2a108a..ec7e1ec 100644 --- a/src/F23.Kernel.Tests/AspNetCore/MvcResultExtensionsTests.cs +++ b/src/F23.Kernel.Tests/AspNetCore/MvcResultExtensionsTests.cs @@ -486,22 +486,4 @@ public void PreconditionFailedResult_With_UseProblemDetails_Carries_The_Reason(P Assert.Equal(problemDetails.Status, objectResult.StatusCode); } } - - [Theory] - [InlineData(PreconditionFailedReason.AnswerRequired, HttpStatusCode.UnprocessableEntity)] - public void PreconditionFailedResult_AnswerRequired_With_UseProblemDetails_Returns_Its_Status(PreconditionFailedReason reason, HttpStatusCode expected) - { - var plain = new PreconditionFailedResult(reason, null).ToActionResult(useProblemDetails: true); - var typed = new PreconditionFailedResult(reason, null).ToActionResult(useProblemDetails: true); - - Assert.Equal((int)expected, Assert.IsType(plain).StatusCode); - Assert.Equal((int)expected, Assert.IsType(typed).StatusCode); - } - - [Fact] - public void PreconditionFailedResult_AnswerRequired_Returns_UnprocessableEntityResult() - { - Assert.IsType(new PreconditionFailedResult(PreconditionFailedReason.AnswerRequired, null).ToActionResult(useProblemDetails: false)); - Assert.IsType(new PreconditionFailedResult(PreconditionFailedReason.AnswerRequired, null).ToActionResult(useProblemDetails: false)); - } } diff --git a/src/F23.Kernel.Tests/AspNetCore/PreconditionFailedReasonHttpExtensionsTests.cs b/src/F23.Kernel.Tests/AspNetCore/PreconditionFailedReasonHttpExtensionsTests.cs index edb4a30..b41181d 100644 --- a/src/F23.Kernel.Tests/AspNetCore/PreconditionFailedReasonHttpExtensionsTests.cs +++ b/src/F23.Kernel.Tests/AspNetCore/PreconditionFailedReasonHttpExtensionsTests.cs @@ -22,7 +22,6 @@ public void EveryReason_HasAStatusCode(PreconditionFailedReason reason) [InlineData(PreconditionFailedReason.NotFound, HttpStatusCode.NotFound)] [InlineData(PreconditionFailedReason.ConcurrencyMismatch, HttpStatusCode.PreconditionFailed)] [InlineData(PreconditionFailedReason.Conflict, HttpStatusCode.Conflict)] - [InlineData(PreconditionFailedReason.AnswerRequired, HttpStatusCode.UnprocessableEntity)] public void Reason_MapsToItsStatusCode(PreconditionFailedReason reason, HttpStatusCode expected) { Assert.Equal(expected, reason.ToHttpStatusCode()); diff --git a/src/F23.Kernel/Results/PreconditionFailedReason.cs b/src/F23.Kernel/Results/PreconditionFailedReason.cs index 30e34da..2273ef0 100644 --- a/src/F23.Kernel/Results/PreconditionFailedReason.cs +++ b/src/F23.Kernel/Results/PreconditionFailedReason.cs @@ -19,12 +19,6 @@ public enum PreconditionFailedReason /// Indicates that a conflict with the current state of the resource caused the failure of a precondition. /// Conflict = 3, - - /// - /// Indicates that the operation cannot proceed until the caller supplies an answer to a question it raised, - /// such as a confirmation or a choice between alternatives. - /// - AnswerRequired = 4, } /// @@ -45,7 +39,6 @@ internal static class PreconditionFailedReasonExtensions PreconditionFailedReason.NotFound => "A requested resource was not found.", PreconditionFailedReason.ConcurrencyMismatch => "A concurrency mismatch occurred.", PreconditionFailedReason.Conflict => "A conflict occurred with the current state of the resource.", - PreconditionFailedReason.AnswerRequired => "The operation needs an answer before it can proceed.", _ => throw new ArgumentOutOfRangeException(nameof(reason), reason, null) }; }