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
47 changes: 33 additions & 14 deletions src/Ardalis.Result.AspNetCore/MinimalApiResultExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,17 +42,36 @@ internal static Microsoft.AspNetCore.Http.IResult ToMinimalApiResult(this IResul
_ => throw new NotSupportedException($"Result {result.Status} conversion is not supported."),
};

// Invoke the optional global customization hook (if configured) on the ProblemDetails before returning it,
// so consumers can adjust Title/Detail/Extensions without reimplementing the conversion. See ResultProblemDetailsOptions.
private static ProblemDetails ApplyCustomization(IResult result, int statusCode, ProblemDetails problemDetails)
{
var customize = ResultProblemDetailsOptions.Customize;
if (customize != null)
{
customize(new ResultProblemDetailsContext
{
ResultStatus = result.Status,
StatusCode = statusCode,
Result = result,
ProblemDetails = problemDetails
});
}

return problemDetails;
}

private static Microsoft.AspNetCore.Http.IResult UnprocessableEntity(IResult result)
{
var details = new StringBuilder("Next error(s) occurred:");

foreach (var error in result.Errors) details.Append("* ").Append(error).AppendLine();

return Results.UnprocessableEntity(new ProblemDetails
return Results.UnprocessableEntity(ApplyCustomization(result, StatusCodes.Status422UnprocessableEntity, new ProblemDetails
{
Title = "Something went wrong.",
Detail = details.ToString()
});
}));
}

private static Microsoft.AspNetCore.Http.IResult NotFoundEntity(IResult result)
Expand All @@ -63,11 +82,11 @@ private static Microsoft.AspNetCore.Http.IResult NotFoundEntity(IResult result)
{
foreach (var error in result.Errors) details.Append("* ").Append(error).AppendLine();

return Results.NotFound(new ProblemDetails
return Results.NotFound(ApplyCustomization(result, StatusCodes.Status404NotFound, new ProblemDetails
{
Title = "Resource not found.",
Detail = details.ToString()
});
}));
}
else
{
Expand All @@ -83,11 +102,11 @@ private static Microsoft.AspNetCore.Http.IResult ConflictEntity(IResult result)
{
foreach (var error in result.Errors) details.Append("* ").Append(error).AppendLine();

return Results.Conflict(new ProblemDetails
return Results.Conflict(ApplyCustomization(result, StatusCodes.Status409Conflict, new ProblemDetails
{
Title = "There was a conflict.",
Detail = details.ToString()
});
}));
}
else
{
Expand All @@ -103,12 +122,12 @@ private static Microsoft.AspNetCore.Http.IResult CriticalEntity(IResult result)
{
foreach (var error in result.Errors) details.Append("* ").Append(error).AppendLine();

return Results.Problem(new ProblemDetails()
return Results.Problem(ApplyCustomization(result, StatusCodes.Status500InternalServerError, new ProblemDetails()
{
Title = "Something went wrong.",
Detail = details.ToString(),
Status = StatusCodes.Status500InternalServerError
});
}));
}
else
{
Expand All @@ -124,12 +143,12 @@ private static Microsoft.AspNetCore.Http.IResult UnavailableEntity(IResult resul
{
foreach (var error in result.Errors) details.Append("* ").Append(error).AppendLine();

return Results.Problem(new ProblemDetails
return Results.Problem(ApplyCustomization(result, StatusCodes.Status503ServiceUnavailable, new ProblemDetails
{
Title = "Service unavailable.",
Detail = details.ToString(),
Status = StatusCodes.Status503ServiceUnavailable
});
}));
}
else
{
Expand All @@ -145,12 +164,12 @@ private static Microsoft.AspNetCore.Http.IResult Forbidden(IResult result)
{
foreach (var error in result.Errors) details.Append("* ").Append(error).AppendLine();

return Results.Problem(new ProblemDetails
return Results.Problem(ApplyCustomization(result, StatusCodes.Status403Forbidden, new ProblemDetails
{
Title = "Forbidden.",
Detail = details.ToString(),
Status = StatusCodes.Status403Forbidden
});
}));
}
else
{
Expand All @@ -166,12 +185,12 @@ private static Microsoft.AspNetCore.Http.IResult UnAuthorized(IResult result)
{
foreach (var error in result.Errors) details.Append("* ").Append(error).AppendLine();

return Results.Problem(new ProblemDetails
return Results.Problem(ApplyCustomization(result, StatusCodes.Status401Unauthorized, new ProblemDetails
{
Title = "Unauthorized.",
Detail = details.ToString(),
Status = StatusCodes.Status401Unauthorized
});
}));
}
else
{
Expand Down
46 changes: 46 additions & 0 deletions src/Ardalis.Result.AspNetCore/ResultProblemDetailsOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
#if NET6_0_OR_GREATER
#nullable enable
using System;

using Microsoft.AspNetCore.Mvc;

namespace Ardalis.Result.AspNetCore;

/// <summary>
/// Context passed to <see cref="ResultProblemDetailsOptions.Customize"/> when the Minimal API
/// <c>ToMinimalApiResult</c> extensions build a <see cref="ProblemDetails"/> for an error-class result.
/// </summary>
public sealed class ResultProblemDetailsContext
{
/// <summary>The Ardalis.Result status that produced this response.</summary>
public required ResultStatus ResultStatus { get; init; }

/// <summary>The HTTP status code that will be returned.</summary>
public required int StatusCode { get; init; }

/// <summary>The result being converted.</summary>
public required IResult Result { get; init; }

/// <summary>
/// The <see cref="ProblemDetails"/> about to be returned. Mutate it in place (Title, Detail,
/// Extensions, ...) to customize the response.
/// </summary>
public required ProblemDetails ProblemDetails { get; init; }
}

/// <summary>
/// Global, configure-once customization for the <see cref="ProblemDetails"/> produced by the Minimal API
/// <c>ToMinimalApiResult</c> extensions. Mirrors the spirit of ASP.NET Core's
/// <c>ProblemDetailsOptions.CustomizeProblemDetails</c>, but applies to the Ardalis.Result conversion which
/// runs without access to an <c>HttpContext</c>.
/// </summary>
public static class ResultProblemDetailsOptions
{
/// <summary>
/// Optional hook to customize the <see cref="ProblemDetails"/> produced for error-class results. Null by
/// default, leaving the library's formatting unchanged. Set once at application startup, e.g. to remove the
/// default "Next error(s) occurred:" Detail prefix or to localize the Title.
/// </summary>
public static Action<ResultProblemDetailsContext>? Customize { get; set; }
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#if NET6_0_OR_GREATER

using System;
using System.Reflection;

using Ardalis.Result.AspNetCore;
using Microsoft.AspNetCore.Mvc;
using Xunit;

namespace Ardalis.Result.AspNetCore.UnitTests;

public class MinimalApiResultProblemDetailsCustomizationTests : IDisposable
{
// Customize is global static; reset after every test so cases stay isolated.
public void Dispose() => ResultProblemDetailsOptions.Customize = null;

[Fact]
public void WithoutCustomization_DefaultTitleAndDetailAreUnchanged()
{
ResultProblemDetailsOptions.Customize = null;

var problemDetails = ExtractProblemDetails(Result<int>.Error("boom").ToMinimalApiResult());

Assert.Equal("Something went wrong.", problemDetails.Title);
Assert.StartsWith("Next error(s) occurred:", problemDetails.Detail);
}

[Fact]
public void Customize_CanRewriteTitleAndDetail()
{
ResultProblemDetailsOptions.Customize = context =>
{
context.ProblemDetails.Title = "Custom title";
context.ProblemDetails.Detail = string.Join("; ", context.Result.Errors);
};

var problemDetails = ExtractProblemDetails(Result<int>.Error("boom").ToMinimalApiResult());

Assert.Equal("Custom title", problemDetails.Title);
Assert.Equal("boom", problemDetails.Detail);
}

[Fact]
public void Customize_ReceivesResultStatusAndStatusCode()
{
ResultProblemDetailsContext captured = null;
ResultProblemDetailsOptions.Customize = context => captured = context;

Result<int>.Conflict("nope").ToMinimalApiResult();

Assert.NotNull(captured);
Assert.Equal(ResultStatus.Conflict, captured.ResultStatus);
Assert.Equal(409, captured.StatusCode);
}

// The concrete result type returned by Results.UnprocessableEntity/Conflict/Problem differs across target
// frameworks (and is internal on net6), so read the ProblemDetails reflectively by well-known property name.
private static ProblemDetails ExtractProblemDetails(Microsoft.AspNetCore.Http.IResult result)
{
const BindingFlags flags = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance;
var type = result.GetType();
foreach (var propertyName in new[] { "ProblemDetails", "Value" })
{
if (type.GetProperty(propertyName, flags)?.GetValue(result) is ProblemDetails problemDetails)
{
return problemDetails;
}
}
throw new InvalidOperationException($"Could not extract ProblemDetails from {type.FullName}");
}
}
#endif
Loading