Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 15 additions & 14 deletions src/F23.Kernel.AspNetCore/MinimalApiResultExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public static class MinimalApiResultExtensions
/// An <see cref="IResult"/> that represents the appropriate response:
/// <list type="bullet">
/// <item><see cref="NoContent"/> for a <see cref="SuccessResult"/>.</item>
/// <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>
/// <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>. For a precondition failure the status follows the reason (404 for <see cref="PreconditionFailedReason.NotFound"/>, 412 for <see cref="PreconditionFailedReason.ConcurrencyMismatch"/>, 409 for <see cref="PreconditionFailedReason.Conflict"/>), and the reason is in a <c>reason</c> extension member.</item>
/// <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>
/// <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>
/// <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>
Expand All @@ -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
Expand Down Expand Up @@ -80,7 +76,7 @@ UnauthorizedResult when !useProblemDetails
/// <list type="bullet">
/// <item><see cref="Ok"/> response for a <see cref="SuccessResult{T}"/> if <paramref name="successMap"/> is not provided.</item>
/// <item>The result of <paramref name="successMap"/> if provided and the result is a <see cref="SuccessResult{T}"/>.</item>
/// <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>
/// <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>. For a precondition failure the status follows the reason (404 for <see cref="PreconditionFailedReason.NotFound"/>, 412 for <see cref="PreconditionFailedReason.ConcurrencyMismatch"/>, 409 for <see cref="PreconditionFailedReason.Conflict"/>), and the reason is in a <c>reason</c> extension member.</item>
/// <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>
/// <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>
/// <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>
Expand All @@ -98,16 +94,12 @@ public static IResult ToMinimalApiResult<T>(this Result<T> result, Func<T, IResu
=> successMap(success.Value),
SuccessResult<T> success
=> HttpResults.Ok(new HypermediaResponse(success.Value!)), // [!]: TODO: Fix nullability issue in F23.Hateoas
PreconditionFailedResult<T> { Reason: PreconditionFailedReason.NotFound } when useProblemDetails
=> result.ToProblemHttpResult(HttpStatusCode.NotFound),
PreconditionFailedResult<T> preconditionFailed when useProblemDetails
=> result.ToProblemHttpResult(preconditionFailed.Reason),
PreconditionFailedResult<T> { Reason: PreconditionFailedReason.NotFound } when !useProblemDetails
=> HttpResults.NotFound(),
PreconditionFailedResult<T> { Reason: PreconditionFailedReason.ConcurrencyMismatch } when useProblemDetails
=> result.ToProblemHttpResult(HttpStatusCode.PreconditionFailed),
PreconditionFailedResult<T> { Reason: PreconditionFailedReason.ConcurrencyMismatch } when !useProblemDetails
=> HttpResults.StatusCode((int)HttpStatusCode.PreconditionFailed),
PreconditionFailedResult<T> { Reason: PreconditionFailedReason.Conflict } when useProblemDetails
=> result.ToProblemHttpResult(HttpStatusCode.Conflict),
PreconditionFailedResult<T> { Reason: PreconditionFailedReason.Conflict } when !useProblemDetails
=> HttpResults.Conflict(),
ValidationFailedResult<T> validationFailed when useProblemDetails
Expand All @@ -133,4 +125,13 @@ UnauthorizedResult<T> when !useProblemDetails
/// </remarks>
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<string, object?>
{
[PreconditionFailedReasonHttpExtensions.ReasonExtension] = reason.ToString(),
});
}
36 changes: 22 additions & 14 deletions src/F23.Kernel.AspNetCore/MvcResultExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public static class MvcResultExtensions
/// An <see cref="IActionResult"/> representing the HTTP response:
/// <list type="bullet">
/// <item><see cref="NoContentResult"/> for <see cref="SuccessResult"/>.</item>
/// <item><see cref="ObjectResult"/> with <see cref="ProblemDetails"/> for any non-successful result if <paramref name="useProblemDetails"/> is <c>true</c>.</item>
/// <item><see cref="ObjectResult"/> with <see cref="ProblemDetails"/> for any non-successful result if <paramref name="useProblemDetails"/> is <c>true</c>. For a precondition failure the status follows the reason (404 for <see cref="PreconditionFailedReason.NotFound"/>, 412 for <see cref="PreconditionFailedReason.ConcurrencyMismatch"/>, 409 for <see cref="PreconditionFailedReason.Conflict"/>), and the reason is in a <c>reason</c> extension member.</item>
/// <item><see cref="NotFoundResult"/> for a <see cref="PreconditionFailedResult"/> indicating <see cref="PreconditionFailedReason.NotFound"/> if <paramref name="useProblemDetails"/> is <c>false</c>.</item>
/// <item><see cref="StatusCodeResult"/> with HTTP status code 412 for a <see cref="PreconditionFailedResult"/> indicating <see cref="PreconditionFailedReason.ConcurrencyMismatch"/> if <paramref name="useProblemDetails"/> is <c>false</c>.</item>
/// <item><see cref="ConflictResult"/> for a <see cref="PreconditionFailedResult"/> indicating <see cref="PreconditionFailedReason.Conflict"/> if <paramref name="useProblemDetails"/> is <c>false</c>.</item>
Expand All @@ -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
Expand Down Expand Up @@ -87,7 +83,7 @@ UnauthorizedResult when !useProblemDetails
/// <list type="bullet">
/// <item>The result of <paramref name="successMap"/>, if specified, for a <see cref="SuccessResult{T}"/>.</item>
/// <item><see cref="OkObjectResult"/> for a <see cref="SuccessResult{T}"/>, when <paramref name="successMap"/> is not specified.</item>
/// <item><see cref="ObjectResult"/> with <see cref="ProblemDetails"/> for any non-successful result if <paramref name="useProblemDetails"/> is <c>true</c>.</item>
/// <item><see cref="ObjectResult"/> with <see cref="ProblemDetails"/> for any non-successful result if <paramref name="useProblemDetails"/> is <c>true</c>. For a precondition failure the status follows the reason (404 for <see cref="PreconditionFailedReason.NotFound"/>, 412 for <see cref="PreconditionFailedReason.ConcurrencyMismatch"/>, 409 for <see cref="PreconditionFailedReason.Conflict"/>), and the reason is in a <c>reason</c> extension member.</item>
/// <item><see cref="NotFoundResult"/> for a <see cref="PreconditionFailedResult{T}"/> indicating <see cref="PreconditionFailedReason.NotFound"/> if <paramref name="useProblemDetails"/> is <c>false</c>.</item>
/// <item><see cref="StatusCodeResult"/> with HTTP status code 412 for a <see cref="PreconditionFailedResult{T}"/> indicating <see cref="PreconditionFailedReason.ConcurrencyMismatch"/> if <paramref name="useProblemDetails"/> is <c>false</c>.</item>
/// <item><see cref="ConflictResult"/> for a <see cref="PreconditionFailedResult{T}"/> indicating <see cref="PreconditionFailedReason.Conflict"/> if <paramref name="useProblemDetails"/> is <c>false</c>.</item>
Expand All @@ -105,16 +101,12 @@ public static IActionResult ToActionResult<T>(this Result<T> result, Func<T, IAc
=> successMap(success.Value),
SuccessResult<T> success
=> new OkObjectResult(new HypermediaResponse(success.Value!)), // [!]: TODO: Fix nullability issue in F23.Hateoas
PreconditionFailedResult<T> { Reason: PreconditionFailedReason.NotFound } when useProblemDetails
=> result.ToProblemDetailsResult(HttpStatusCode.NotFound),
PreconditionFailedResult<T> preconditionFailed when useProblemDetails
=> result.ToProblemDetailsResult(preconditionFailed.Reason),
PreconditionFailedResult<T> { Reason: PreconditionFailedReason.NotFound } when !useProblemDetails
=> new NotFoundResult(),
PreconditionFailedResult<T> { Reason: PreconditionFailedReason.ConcurrencyMismatch } when useProblemDetails
=> result.ToProblemDetailsResult(HttpStatusCode.PreconditionFailed),
PreconditionFailedResult<T> { Reason: PreconditionFailedReason.ConcurrencyMismatch } when !useProblemDetails
=> new StatusCodeResult((int) HttpStatusCode.PreconditionFailed),
PreconditionFailedResult<T> { Reason: PreconditionFailedReason.Conflict } when useProblemDetails
=> result.ToProblemDetailsResult(HttpStatusCode.Conflict),
PreconditionFailedResult<T> { Reason: PreconditionFailedReason.Conflict } when !useProblemDetails
=> new ConflictResult(),
ValidationFailedResult<T> validationFailed when useProblemDetails
Expand Down Expand Up @@ -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,
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System.Net;
using F23.Kernel.Results;

namespace F23.Kernel.AspNetCore;

/// <summary>
/// The HTTP status each <see cref="PreconditionFailedReason"/> maps to, shared by the MVC and Minimal API adapters
/// so that a new reason is mapped in one place.
/// </summary>
internal static class PreconditionFailedReasonHttpExtensions
{
/// <summary>
/// The name of the problem details extension member that carries the <see cref="PreconditionFailedReason"/>.
/// </summary>
/// <remarks>
/// Lets a client branch on the reason without parsing the title, which is free text and may be a custom message.
/// </remarks>
internal const string ReasonExtension = "reason";

/// <summary>
/// Gets the HTTP status code for a precondition failure.
/// </summary>
/// <param name="reason">The reason for the precondition failure.</param>
/// <returns>The HTTP status code to respond with.</returns>
/// <exception cref="ArgumentOutOfRangeException">Thrown when <paramref name="reason"/> is not a defined value.</exception>
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)
};
}
21 changes: 19 additions & 2 deletions src/F23.Kernel.Tests/AspNetCore/MinimalApiResultExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<TestResultContent>(reason, "message").ToMinimalApiResult()
})
{
var problem = Assert.IsType<ProblemHttpResult>(httpResult);
Assert.Equal(reason.ToString(), problem.ProblemDetails.Extensions["reason"]);
Assert.Equal("message", problem.ProblemDetails.Title);
}
}
}
24 changes: 22 additions & 2 deletions src/F23.Kernel.Tests/AspNetCore/MvcResultExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<TestResultContent>(reason, "message").ToActionResult(useProblemDetails: true);

// Assert
foreach (var actionResult in new[] { plain, typed })
{
var objectResult = Assert.IsType<ObjectResult>(actionResult);
var problemDetails = Assert.IsType<ProblemDetails>(objectResult.Value);
Assert.Equal(reason.ToString(), problemDetails.Extensions["reason"]);
Assert.Equal("message", problemDetails.Title);
Assert.Equal(problemDetails.Status, objectResult.StatusCode);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<ArgumentOutOfRangeException>(() => ((PreconditionFailedReason)999).ToHttpStatusCode());
}
}
18 changes: 18 additions & 0 deletions src/F23.Kernel.Tests/Mocks/PreconditionFailedReasons.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using F23.Kernel.Results;

namespace F23.Kernel.Tests.Mocks;

public static class PreconditionFailedReasons
{
public static TheoryData<PreconditionFailedReason> All()
{
var data = new TheoryData<PreconditionFailedReason>();

foreach (var reason in Enum.GetValues<PreconditionFailedReason>())
{
data.Add(reason);
}

return data;
}
}
Loading