diff --git a/src/Ardalis.Result.AspNetCore/MinimalApiResultExtensions.cs b/src/Ardalis.Result.AspNetCore/MinimalApiResultExtensions.cs
index da3f558..9222370 100644
--- a/src/Ardalis.Result.AspNetCore/MinimalApiResultExtensions.cs
+++ b/src/Ardalis.Result.AspNetCore/MinimalApiResultExtensions.cs
@@ -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)
@@ -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
{
@@ -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
{
@@ -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
{
@@ -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
{
@@ -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
{
@@ -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
{
diff --git a/src/Ardalis.Result.AspNetCore/ResultProblemDetailsOptions.cs b/src/Ardalis.Result.AspNetCore/ResultProblemDetailsOptions.cs
new file mode 100644
index 0000000..5fb0b43
--- /dev/null
+++ b/src/Ardalis.Result.AspNetCore/ResultProblemDetailsOptions.cs
@@ -0,0 +1,46 @@
+#if NET6_0_OR_GREATER
+#nullable enable
+using System;
+
+using Microsoft.AspNetCore.Mvc;
+
+namespace Ardalis.Result.AspNetCore;
+
+///
+/// Context passed to when the Minimal API
+/// ToMinimalApiResult extensions build a for an error-class result.
+///
+public sealed class ResultProblemDetailsContext
+{
+ /// The Ardalis.Result status that produced this response.
+ public required ResultStatus ResultStatus { get; init; }
+
+ /// The HTTP status code that will be returned.
+ public required int StatusCode { get; init; }
+
+ /// The result being converted.
+ public required IResult Result { get; init; }
+
+ ///
+ /// The about to be returned. Mutate it in place (Title, Detail,
+ /// Extensions, ...) to customize the response.
+ ///
+ public required ProblemDetails ProblemDetails { get; init; }
+}
+
+///
+/// Global, configure-once customization for the produced by the Minimal API
+/// ToMinimalApiResult extensions. Mirrors the spirit of ASP.NET Core's
+/// ProblemDetailsOptions.CustomizeProblemDetails, but applies to the Ardalis.Result conversion which
+/// runs without access to an HttpContext.
+///
+public static class ResultProblemDetailsOptions
+{
+ ///
+ /// Optional hook to customize the 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.
+ ///
+ public static Action? Customize { get; set; }
+}
+#endif
\ No newline at end of file
diff --git a/tests/Ardalis.Result.AspNetCore.UnitTests/MinimalApiResultProblemDetailsCustomizationTests.cs b/tests/Ardalis.Result.AspNetCore.UnitTests/MinimalApiResultProblemDetailsCustomizationTests.cs
new file mode 100644
index 0000000..74e3846
--- /dev/null
+++ b/tests/Ardalis.Result.AspNetCore.UnitTests/MinimalApiResultProblemDetailsCustomizationTests.cs
@@ -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.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.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.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
\ No newline at end of file