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