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 { }