From 17a813ce2db088e9451ffcb81c06f71cd8abbcce Mon Sep 17 00:00:00 2001 From: Paul Irwin Date: Thu, 6 Nov 2025 11:00:56 -0700 Subject: [PATCH 1/2] Refactor result extensions with RFC7807 problem details support and HTTP 403 fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the monolithic ResultExtensions class into separate concerns: - MinimalApiResultExtensions: Converts Result types to IResult for minimal APIs - MvcResultExtensions: Converts Result types to IActionResult for MVC controllers Key improvements: - Add RFC7807 Problem Details support via useProblemDetails parameter (default: true) - ValidationErrorExtensions provides error dictionary conversion for problem details - Fix HTTP status code for authorization failures: use 403 Forbidden instead of 401 - Both extensions support legacy responses when useProblemDetails=false - Comprehensive test coverage for all result types and response formats Example usage: - Add example minimal API endpoints at /minimal-apis/results/ - Add example MVC controller at /mvc/results/ - Register controllers and endpoints in Program.cs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .gitignore | 4 +- .../MinimalApiResultExtensions.cs | 136 +++++ .../MvcResultExtensions.cs | 157 ++++++ src/F23.Kernel.AspNetCore/ResultExtensions.cs | 160 ------ .../Core/ResultsEndpoints.cs | 117 +++++ .../Infrastructure/ResultsController.cs | 108 ++++ src/F23.Kernel.Examples.AspNetCore/Program.cs | 5 + .../MinimalApiResultExtensionsTests.cs | 428 ++++++++++++++++ .../AspNetCore/MvcResultExtensionsTests.cs | 465 ++++++++++++++++++ .../ResultExtensionsMinimalApiTests.cs | 267 ---------- .../AspNetCore/ResultExtensionsTests.cs | 266 ---------- src/F23.Kernel/ValidationErrorExtensions.cs | 33 ++ 12 files changed, 1452 insertions(+), 694 deletions(-) create mode 100644 src/F23.Kernel.AspNetCore/MinimalApiResultExtensions.cs create mode 100644 src/F23.Kernel.AspNetCore/MvcResultExtensions.cs delete mode 100644 src/F23.Kernel.AspNetCore/ResultExtensions.cs create mode 100644 src/F23.Kernel.Examples.AspNetCore/Core/ResultsEndpoints.cs create mode 100644 src/F23.Kernel.Examples.AspNetCore/Infrastructure/ResultsController.cs create mode 100644 src/F23.Kernel.Tests/AspNetCore/MinimalApiResultExtensionsTests.cs create mode 100644 src/F23.Kernel.Tests/AspNetCore/MvcResultExtensionsTests.cs delete mode 100644 src/F23.Kernel.Tests/AspNetCore/ResultExtensionsMinimalApiTests.cs delete mode 100644 src/F23.Kernel.Tests/AspNetCore/ResultExtensionsTests.cs create mode 100644 src/F23.Kernel/ValidationErrorExtensions.cs diff --git a/.gitignore b/.gitignore index 370150d..0320691 100644 --- a/.gitignore +++ b/.gitignore @@ -433,4 +433,6 @@ _UpgradeReport_Files/ Thumbs.db Desktop.ini -.DS_Store \ No newline at end of file +.DS_Store +**/.idea/ +.idea/ diff --git a/src/F23.Kernel.AspNetCore/MinimalApiResultExtensions.cs b/src/F23.Kernel.AspNetCore/MinimalApiResultExtensions.cs new file mode 100644 index 0000000..000e300 --- /dev/null +++ b/src/F23.Kernel.AspNetCore/MinimalApiResultExtensions.cs @@ -0,0 +1,136 @@ +using System.Net; +using F23.Hateoas; +using F23.Kernel.Results; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; +using UnauthorizedResult = F23.Kernel.Results.UnauthorizedResult; +using HttpResults = Microsoft.AspNetCore.Http.Results; + +namespace F23.Kernel.AspNetCore; + +/// +/// Provides extension methods for converting objects to ASP.NET Core Minimal API objects. +/// +public static class MinimalApiResultExtensions +{ + /// + /// Converts a into an appropriate to be used in a minimal API context. + /// + /// The to be converted. + /// Whether to use a RFC7807 problem details body for a failure response. The default is true. + /// + /// An that represents the appropriate response: + /// + /// for a . + /// with RFC7807 for any non-successful result if is true. + /// 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. + /// with model state populated for a if is false. + /// for an if is false. + /// + /// + /// + /// Thrown when the does not match any known result types. + /// + public static IResult ToMinimalApiResult(this Result result, bool useProblemDetails = true) + => result switch + { + SuccessResult + => HttpResults.NoContent(), + AggregateResult { IsSuccess: true } + => 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 { 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 + => HttpResults.ValidationProblem(errors: validationFailed.Errors.CreateErrorDictionary(), title: result.Message), + ValidationFailedResult validationFailed when !useProblemDetails + => HttpResults.BadRequest(validationFailed.Errors.ToModelState()), + UnauthorizedResult when useProblemDetails + => result.ToProblemHttpResult(HttpStatusCode.Forbidden), + UnauthorizedResult when !useProblemDetails + => HttpResults.Forbid(), + _ => throw new ArgumentOutOfRangeException(nameof(result)) + }; + + /// + /// Converts a into an appropriate to be used in a minimal API context. + /// + /// The instance to be converted. + /// + /// An optional function to map the value of a successful result into a user-defined . + /// If not provided, successful results will default to an HTTP 200 response with the value serialized as the body. + /// + /// Whether to use a RFC7807 problem details body for a failure response. The default is true. + /// The type of the result's value. + /// + /// An that represents the appropriate response: + /// + /// 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. + /// 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. + /// with model state populated for a if is false. + /// for an if is false. + /// + /// + /// + /// Thrown when the does not match any known result types. + /// + public static IResult ToMinimalApiResult(this Result result, Func? successMap = null, bool useProblemDetails = true) + => result switch + { + SuccessResult success when successMap != null + => successMap(success.Value), + SuccessResult success + => HttpResults.Ok(new HypermediaResponse(success.Value)), + PreconditionFailedResult { Reason: PreconditionFailedReason.NotFound } when useProblemDetails + => result.ToProblemHttpResult(HttpStatusCode.NotFound), + 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 + => HttpResults.ValidationProblem(errors: validationFailed.Errors.CreateErrorDictionary(), title: result.Message), + ValidationFailedResult validationFailed when !useProblemDetails + => HttpResults.BadRequest(validationFailed.Errors.ToModelState()), + UnauthorizedResult when useProblemDetails + => result.ToProblemHttpResult(HttpStatusCode.Forbidden), + UnauthorizedResult when !useProblemDetails + => HttpResults.Forbid(), + _ => throw new ArgumentOutOfRangeException(nameof(result)), + }; + + /// + /// Converts a into an containing RFC7807 . + /// + /// The to be converted. + /// The HTTP status code to be set in the . + /// A containing the . + /// + /// This is primarily an internal API, intended to be used from or . + /// However, it might have some desired use cases where direct usage is appropriate, so it is made public. + /// + public static IResult ToProblemHttpResult(this Result result, HttpStatusCode statusCode) + => HttpResults.Problem(title: result.Message, statusCode: (int)statusCode); +} diff --git a/src/F23.Kernel.AspNetCore/MvcResultExtensions.cs b/src/F23.Kernel.AspNetCore/MvcResultExtensions.cs new file mode 100644 index 0000000..adc8010 --- /dev/null +++ b/src/F23.Kernel.AspNetCore/MvcResultExtensions.cs @@ -0,0 +1,157 @@ +using System.Net; +using F23.Hateoas; +using F23.Kernel.Results; +using Microsoft.AspNetCore.Mvc; +using UnauthorizedResult = F23.Kernel.Results.UnauthorizedResult; + +namespace F23.Kernel.AspNetCore; + +/// +/// Provides extension methods for converting objects to ASP.NET Core MVC objects. +/// +public static class MvcResultExtensions +{ + /// + /// Converts a into an appropriate + /// that represents the result to be sent in an HTTP response. + /// + /// The to be converted. + /// Whether to use a RFC7807 problem details body for a failure response. The default is true. + /// + /// An representing the HTTP response: + /// + /// for . + /// with for any non-successful result if is true. + /// for a indicating if is false. + /// with HTTP status code 412 for a indicating if is false. + /// for a indicating if is false. + /// with model state populated for a if is false. + /// in case of an if is false. + /// + /// + /// + /// Thrown when the does not match any known result types. + /// + public static IActionResult ToActionResult(this Result result, bool useProblemDetails = true) + => result switch + { + SuccessResult + => new NoContentResult(), + AggregateResult { IsSuccess: true } + => 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 { 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 + => new ObjectResult(new ValidationProblemDetails(validationFailed.Errors.ToModelState()) + { + Title = result.Message, + Status = (int) HttpStatusCode.BadRequest, + }) + { + StatusCode = (int) HttpStatusCode.BadRequest, + }, + ValidationFailedResult validationFailed when !useProblemDetails + => new BadRequestObjectResult(validationFailed.Errors.ToModelState()), + UnauthorizedResult when useProblemDetails + => result.ToProblemDetailsResult(HttpStatusCode.Forbidden), + UnauthorizedResult when !useProblemDetails + => new ForbidResult(), + _ => throw new ArgumentOutOfRangeException(nameof(result)) + }; + + /// + /// Converts a into an appropriate + /// that represents the result to be sent in an HTTP response. + /// + /// The type of the value contained in the result, if successful. + /// The result instance to convert. + /// + /// An optional function to map a successful result to a custom . + /// If not provided, a default mapping is applied. + /// + /// Whether to use a RFC7807 problem details body for a failure response. The default is true. + /// + /// An representing the HTTP response: + /// + /// The result of , if specified, for a . + /// for a , when is not specified. + /// with for any non-successful result if is true. + /// for a indicating if is false. + /// with HTTP status code 412 for a indicating if is false. + /// for a indicating if is false. + /// with model state populated for a if is false. + /// in case of an if is false. + /// + /// + /// + /// Thrown when the does not match any known result types. + /// + public static IActionResult ToActionResult(this Result result, Func? successMap = null, bool useProblemDetails = true) + => result switch + { + SuccessResult success when successMap != null + => successMap(success.Value), + SuccessResult success + => new OkObjectResult(new HypermediaResponse(success.Value)), + PreconditionFailedResult { Reason: PreconditionFailedReason.NotFound } when useProblemDetails + => result.ToProblemDetailsResult(HttpStatusCode.NotFound), + 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 + => new ObjectResult(new ValidationProblemDetails(validationFailed.Errors.ToModelState()) + { + Title = result.Message, + Status = (int) HttpStatusCode.BadRequest, + }) + { + StatusCode = (int) HttpStatusCode.BadRequest, + }, + ValidationFailedResult validationFailed when !useProblemDetails + => new BadRequestObjectResult(validationFailed.Errors.ToModelState()), + UnauthorizedResult when useProblemDetails + => result.ToProblemDetailsResult(HttpStatusCode.Forbidden), + UnauthorizedResult when !useProblemDetails + => new ForbidResult(), + _ => throw new ArgumentOutOfRangeException(nameof(result)) + }; + + /// + /// Converts a into a containing . + /// + /// The to be converted. + /// The HTTP status code to be set in the . + /// A containing the . + /// + /// This is primarily an internal API, intended to be used from or . + /// However, it might have some desired use cases where direct usage is appropriate, so it is made public. + /// + public static IActionResult ToProblemDetailsResult(this Result result, HttpStatusCode statusCode) + => new ObjectResult(new ProblemDetails + { + Title = result.Message, + Status = (int)statusCode, + }) + { + StatusCode = (int)statusCode, + }; +} diff --git a/src/F23.Kernel.AspNetCore/ResultExtensions.cs b/src/F23.Kernel.AspNetCore/ResultExtensions.cs deleted file mode 100644 index a3ae881..0000000 --- a/src/F23.Kernel.AspNetCore/ResultExtensions.cs +++ /dev/null @@ -1,160 +0,0 @@ -using System.Net; -using F23.Hateoas; -using F23.Kernel.Results; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.AspNetCore.Mvc; -using UnauthorizedResult = Microsoft.AspNetCore.Mvc.UnauthorizedResult; - -namespace F23.Kernel.AspNetCore; - -/// -/// Provides extension methods for converting objects to objects. -/// -public static class ResultExtensions -{ - /// - /// Converts a into an appropriate - /// that represents the result to be sent in an HTTP response. - /// - /// The to be converted. - /// - /// An representing the HTTP response: - /// - /// A for successful results. - /// A for a indicating . - /// A with HTTP status code 412 for a indicating . - /// A for a indicating . - /// A with model state populated for a . - /// An in case of an . - /// - /// - /// - /// Thrown when the does not match any known result types. - /// - public static IActionResult ToActionResult(this Result result) - => result switch - { - SuccessResult => new NoContentResult(), - AggregateResult { IsSuccess: true } => new NoContentResult(), - AggregateResult { IsSuccess: false, Results.Count: > 0 } aggregateResult => aggregateResult.Results.First(i => !i.IsSuccess).ToActionResult(), - PreconditionFailedResult { Reason: PreconditionFailedReason.NotFound } => new NotFoundResult(), - PreconditionFailedResult { Reason: PreconditionFailedReason.ConcurrencyMismatch } => new StatusCodeResult((int) HttpStatusCode.PreconditionFailed), - PreconditionFailedResult { Reason: PreconditionFailedReason.Conflict } => new ConflictResult(), - ValidationFailedResult validationFailed => new BadRequestObjectResult(validationFailed.Errors.ToModelState()), - F23.Kernel.Results.UnauthorizedResult => new UnauthorizedResult(), - _ => throw new ArgumentOutOfRangeException(nameof(result)) - }; - - /// - /// Converts a into an appropriate to be used in a minimal API context. - /// - /// The to be converted. - /// - /// An that represents the appropriate response: - /// - An for a . - /// - A for a with a reason of . - /// - A with status code 412 for a with a reason of . - /// - A for a with a reason of . - /// - A with model state populated for a . - /// - An for an . - /// - /// - /// Thrown when the does not match any known result types. - /// - public static IResult ToMinimalApiResult(this Result result) - => result switch - { - SuccessResult => Microsoft.AspNetCore.Http.Results.NoContent(), - AggregateResult { IsSuccess: true } => Microsoft.AspNetCore.Http.Results.NoContent(), - AggregateResult { IsSuccess: false, Results.Count: > 0 } aggregateResult => - aggregateResult.Results.First(i => !i.IsSuccess).ToMinimalApiResult(), - PreconditionFailedResult { Reason: PreconditionFailedReason.NotFound } => - Microsoft.AspNetCore.Http.Results.NotFound(), - PreconditionFailedResult { Reason: PreconditionFailedReason.ConcurrencyMismatch } => - Microsoft.AspNetCore.Http.Results.StatusCode((int) HttpStatusCode.PreconditionFailed), - PreconditionFailedResult { Reason: PreconditionFailedReason.Conflict } => - Microsoft.AspNetCore.Http.Results.Conflict(), - ValidationFailedResult validationFailed => - Microsoft.AspNetCore.Http.Results.BadRequest(validationFailed.Errors.ToModelState()), - F23.Kernel.Results.UnauthorizedResult => Microsoft.AspNetCore.Http.Results.Unauthorized(), - _ => throw new ArgumentOutOfRangeException(nameof(result)) - }; - - /// - /// Converts a into an appropriate - /// that represents the result to be sent in an HTTP response. - /// - /// The type of the value contained in the result, if successful. - /// The result instance to convert. - /// - /// An optional function to map a successful result to a custom . - /// If not provided, a default mapping is applied. - /// - /// - /// An representing the HTTP response: - /// - /// The result of , if specified, for a . - /// An for a , when is not specified. - /// A for a indicating . - /// A with HTTP status code 412 for a indicating . - /// A for a indicating . - /// A with model state populated for a . - /// An in case of an . - /// - /// - /// - /// Thrown when the does not match any known result types. - /// - public static IActionResult ToActionResult(this Result result, Func? successMap = null) - => result switch - { - SuccessResult success when successMap != null => successMap(success.Value), - SuccessResult success => new OkObjectResult(new HypermediaResponse(success.Value)), - PreconditionFailedResult { Reason: PreconditionFailedReason.NotFound } => new NotFoundResult(), - PreconditionFailedResult { Reason: PreconditionFailedReason.ConcurrencyMismatch } => new StatusCodeResult((int) HttpStatusCode.PreconditionFailed), - PreconditionFailedResult { Reason: PreconditionFailedReason.Conflict } => new ConflictResult(), - ValidationFailedResult validationFailed => new BadRequestObjectResult(validationFailed.Errors.ToModelState()), - UnauthorizedResult => new UnauthorizedResult(), - _ => throw new ArgumentOutOfRangeException(nameof(result)) - }; - - /// - /// Converts a into an appropriate to be used in a minimal API context. - /// - /// The instance to be converted. - /// - /// An optional function to map the value of a successful result into a user-defined . - /// If not provided, successful results will default to an HTTP 200 response with the value serialized as the body. - /// - /// The type of the result's value. - /// - /// An that represents the appropriate response: - /// - An HTTP 200 (OK) response for a if is not provided. - /// - The result of if provided and the result is a . - /// - An HTTP 404 (NotFound) response for a with a reason of . - /// - An HTTP 412 (PreconditionFailed) response for a with a reason of . - /// - An HTTP 409 (Conflict) response for a with a reason of . - /// - An HTTP 400 (BadRequest) response with model state populated for a . - /// - An HTTP 401 (Unauthorized) response for an . - /// - /// - /// Thrown when the does not match any known result types. - /// - public static IResult ToMinimalApiResult(this Result result, Func? successMap = null) - => result switch - { - SuccessResult success when successMap != null => successMap(success.Value), - SuccessResult success => Microsoft.AspNetCore.Http.Results.Ok(new HypermediaResponse(success.Value)), - PreconditionFailedResult { Reason: PreconditionFailedReason.NotFound } => - Microsoft.AspNetCore.Http.Results.NotFound(), - PreconditionFailedResult { Reason: PreconditionFailedReason.ConcurrencyMismatch } => - Microsoft.AspNetCore.Http.Results.StatusCode((int)HttpStatusCode.PreconditionFailed), - PreconditionFailedResult { Reason: PreconditionFailedReason.Conflict } => - Microsoft.AspNetCore.Http.Results.Conflict(), - ValidationFailedResult validationFailed => - Microsoft.AspNetCore.Http.Results.BadRequest(validationFailed.Errors.ToModelState()), - UnauthorizedResult => Microsoft.AspNetCore.Http.Results.Unauthorized(), - _ => throw new ArgumentOutOfRangeException(nameof(result)), - }; -} diff --git a/src/F23.Kernel.Examples.AspNetCore/Core/ResultsEndpoints.cs b/src/F23.Kernel.Examples.AspNetCore/Core/ResultsEndpoints.cs new file mode 100644 index 0000000..57d8c44 --- /dev/null +++ b/src/F23.Kernel.Examples.AspNetCore/Core/ResultsEndpoints.cs @@ -0,0 +1,117 @@ +using F23.Kernel; +using F23.Kernel.AspNetCore; +using F23.Kernel.Results; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; + +namespace F23.Kernel.Examples.AspNetCore.Core; + +/// +/// Minimal API endpoints demonstrating the different types of results available in F23.Kernel. +/// +public static class ResultsEndpoints +{ + /// + /// Maps minimal API endpoints for demonstrating result types. + /// + /// The web application builder. + public static void MapResultsEndpoints(this WebApplication app) + { + var group = app.MapGroup("/minimal-apis/results") + .WithTags("Results - Minimal APIs") + .WithOpenApi(); + + group.MapGet("/success-no-value", SuccessNoValue) + .WithName("MinimalApiSuccessNoValue") + .WithSummary("Demonstrates a successful result with no value") + .WithDescription("Returns 204 No Content"); + + group.MapGet("/success-with-value", SuccessWithValue) + .WithName("MinimalApiSuccessWithValue") + .WithSummary("Demonstrates a successful result with a value") + .WithDescription("Returns 200 OK with the value in the response body"); + + group.MapGet("/validation-failed", ValidationFailed) + .WithName("MinimalApiValidationFailed") + .WithSummary("Demonstrates a validation failed result") + .WithDescription("Returns 400 Bad Request with validation errors"); + + group.MapGet("/unauthorized", Unauthorized) + .WithName("MinimalApiUnauthorized") + .WithSummary("Demonstrates an unauthorized result") + .WithDescription("Returns 403 Forbidden"); + + group.MapGet("/not-found", NotFound) + .WithName("MinimalApiNotFound") + .WithSummary("Demonstrates a precondition failed result (not found)") + .WithDescription("Returns 404 Not Found"); + + group.MapGet("/concurrency-mismatch", ConcurrencyMismatch) + .WithName("MinimalApiConcurrencyMismatch") + .WithSummary("Demonstrates a precondition failed result (concurrency mismatch)") + .WithDescription("Returns 412 Precondition Failed"); + + group.MapGet("/conflict", Conflict) + .WithName("MinimalApiConflict") + .WithSummary("Demonstrates a precondition failed result (conflict)") + .WithDescription("Returns 409 Conflict"); + } + + private static IResult SuccessNoValue() + { + var result = Result.Success(); + return result.ToMinimalApiResult(); + } + + private static IResult SuccessWithValue() + { + var data = new { message = "Operation completed successfully", timestamp = DateTime.UtcNow }; + var result = Result.Success(data); + return result.ToMinimalApiResult(); + } + + private static IResult ValidationFailed() + { + var errors = new[] + { + new ValidationError("email", "Email address is invalid"), + new ValidationError("password", "Password must be at least 8 characters"), + new ValidationError("username", "Username is already taken"), + }; + var result = Result.ValidationFailed(errors); + return result.ToMinimalApiResult(); + } + + private static IResult Unauthorized() + { + var result = Result.Unauthorized("User does not have permission to access this resource"); + return result.ToMinimalApiResult(); + } + + private static IResult NotFound() + { + var result = Result.PreconditionFailed( + PreconditionFailedReason.NotFound, + "The requested resource was not found" + ); + return result.ToMinimalApiResult(); + } + + private static IResult ConcurrencyMismatch() + { + var result = Result.PreconditionFailed( + PreconditionFailedReason.ConcurrencyMismatch, + "The resource has been modified by another process. Please refresh and try again." + ); + return result.ToMinimalApiResult(); + } + + private static IResult Conflict() + { + var result = Result.PreconditionFailed( + PreconditionFailedReason.Conflict, + "The operation conflicts with the current state of the resource" + ); + return result.ToMinimalApiResult(); + } +} diff --git a/src/F23.Kernel.Examples.AspNetCore/Infrastructure/ResultsController.cs b/src/F23.Kernel.Examples.AspNetCore/Infrastructure/ResultsController.cs new file mode 100644 index 0000000..85b80f7 --- /dev/null +++ b/src/F23.Kernel.Examples.AspNetCore/Infrastructure/ResultsController.cs @@ -0,0 +1,108 @@ +using F23.Kernel; +using F23.Kernel.AspNetCore; +using F23.Kernel.Results; +using Microsoft.AspNetCore.Mvc; + +namespace F23.Kernel.Examples.AspNetCore.Infrastructure; + +/// +/// MVC controller demonstrating the different types of results available in F23.Kernel. +/// +[ApiController] +[Route("mvc/results")] +[Tags("Results - MVC")] +public class ResultsController : ControllerBase +{ + /// + /// Demonstrates a successful result with no value. + /// + /// 204 No Content + [HttpGet("success-no-value")] + public IActionResult SuccessNoValue() + { + var result = Result.Success(); + return result.ToActionResult(); + } + + /// + /// Demonstrates a successful result with a value. + /// + /// 200 OK with the value in the response body + [HttpGet("success-with-value")] + public IActionResult SuccessWithValue() + { + var data = new { message = "Operation completed successfully", timestamp = DateTime.UtcNow }; + var result = Result.Success(data); + return result.ToActionResult(); + } + + /// + /// Demonstrates a validation failed result. + /// + /// 400 Bad Request with validation errors + [HttpGet("validation-failed")] + public IActionResult ValidationFailed() + { + var errors = new[] + { + new ValidationError("email", "Email address is invalid"), + new ValidationError("password", "Password must be at least 8 characters"), + new ValidationError("username", "Username is already taken"), + }; + var result = Result.ValidationFailed(errors); + return result.ToActionResult(); + } + + /// + /// Demonstrates an unauthorized result. + /// + /// 403 Forbidden + [HttpGet("unauthorized")] + public IActionResult UnauthorizedDemo() + { + var result = Result.Unauthorized("User does not have permission to access this resource"); + return result.ToActionResult(); + } + + /// + /// Demonstrates a precondition failed result (not found). + /// + /// 404 Not Found + [HttpGet("not-found")] + public IActionResult NotFoundDemo() + { + var result = Result.PreconditionFailed( + PreconditionFailedReason.NotFound, + "The requested resource was not found" + ); + return result.ToActionResult(); + } + + /// + /// Demonstrates a precondition failed result (concurrency mismatch). + /// + /// 412 Precondition Failed + [HttpGet("concurrency-mismatch")] + public IActionResult ConcurrencyMismatch() + { + var result = Result.PreconditionFailed( + PreconditionFailedReason.ConcurrencyMismatch, + "The resource has been modified by another process. Please refresh and try again." + ); + return result.ToActionResult(); + } + + /// + /// Demonstrates a precondition failed result (conflict). + /// + /// 409 Conflict + [HttpGet("conflict")] + public IActionResult ConflictDemo() + { + var result = Result.PreconditionFailed( + PreconditionFailedReason.Conflict, + "The operation conflicts with the current state of the resource" + ); + return result.ToActionResult(); + } +} diff --git a/src/F23.Kernel.Examples.AspNetCore/Program.cs b/src/F23.Kernel.Examples.AspNetCore/Program.cs index e96eccf..2f13e86 100644 --- a/src/F23.Kernel.Examples.AspNetCore/Program.cs +++ b/src/F23.Kernel.Examples.AspNetCore/Program.cs @@ -8,6 +8,7 @@ // Add services to the container. // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); @@ -25,6 +26,8 @@ app.UseHttpsRedirection(); +app.MapControllers(); + app.MapGet("/weatherforecast", async (IQueryHandler queryHandler) => { var result = await queryHandler.Handle(new GetWeatherForecastQuery()); @@ -34,4 +37,6 @@ .WithName("GetWeatherForecast") .WithOpenApi(); +app.MapResultsEndpoints(); + app.Run(); diff --git a/src/F23.Kernel.Tests/AspNetCore/MinimalApiResultExtensionsTests.cs b/src/F23.Kernel.Tests/AspNetCore/MinimalApiResultExtensionsTests.cs new file mode 100644 index 0000000..add8d58 --- /dev/null +++ b/src/F23.Kernel.Tests/AspNetCore/MinimalApiResultExtensionsTests.cs @@ -0,0 +1,428 @@ +using System.Net; +using F23.Hateoas; +using F23.Kernel.AspNetCore; +using F23.Kernel.Results; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace F23.Kernel.Tests.AspNetCore; + +public class MinimalApiResultExtensionsTests +{ + [Fact] + public void SuccessResult_Returns_NoContentResult() + { + // Arrange + var result = new SuccessResult(); + + // Act + var actionResult = result.ToMinimalApiResult(); + + // Assert + Assert.IsType(actionResult); + } + + [Fact] + public void AggregateResult_Success_Returns_NoContentResult() + { + // Arrange + var result = new AggregateResult([new SuccessResult()]); + + // Act + var actionResult = result.ToMinimalApiResult(); + + // Assert + Assert.IsType(actionResult); + } + + [Fact] + public void AggregateResult_Failure_Returns_First_Failed_Result() + { + // Arrange + var result = new AggregateResult([ + new SuccessResult(), + new PreconditionFailedResult(PreconditionFailedReason.NotFound, null) + ]); + + // Act + var actionResult = result.ToMinimalApiResult(useProblemDetails: false); + + // Assert + Assert.IsType(actionResult); + } + + [Fact] + public void PreconditionFailedResult_NotFound_Returns_NotFoundResult() + { + // Arrange + var result = new PreconditionFailedResult(PreconditionFailedReason.NotFound, null); + + // Act + var actionResult = result.ToMinimalApiResult(useProblemDetails: false); + + // Assert + Assert.IsType(actionResult); + } + + [Fact] + public void PreconditionFailedResult_ConcurrencyMismatch_Returns_StatusCodeResult() + { + // Arrange + var result = new PreconditionFailedResult(PreconditionFailedReason.ConcurrencyMismatch, null); + + // Act + var actionResult = result.ToMinimalApiResult(useProblemDetails: false); + + // Assert + var statusCodeResult = Assert.IsType(actionResult); + Assert.Equal((int) HttpStatusCode.PreconditionFailed, statusCodeResult.StatusCode); + } + + [Fact] + public void PreconditionFailedResult_Conflict_Returns_ConflictResult() + { + // Arrange + var result = new PreconditionFailedResult(PreconditionFailedReason.Conflict, null); + + // Act + var actionResult = result.ToMinimalApiResult(useProblemDetails: false); + + // Assert + Assert.IsType(actionResult); + } + + [Fact] + public void ValidationFailedResult_Returns_BadRequestObjectResult() + { + // Arrange + var result = new ValidationFailedResult(new List + { + new("Key", "Message") + }); + + // Act + var actionResult = result.ToMinimalApiResult(useProblemDetails: false); + + // Assert + var badRequestObjectResult = Assert.IsType>(actionResult); + var modelState = Assert.IsType(badRequestObjectResult.Value); + Assert.True(modelState.TryGetValue("Key", out var modelStateEntry)); + Assert.NotNull(modelStateEntry); + Assert.Single(modelStateEntry.Errors); + Assert.Equal("Message", modelStateEntry.Errors.First().ErrorMessage); + } + + [Fact] + public void UnauthorizedResult_Returns_ForbidResult() + { + // Arrange + var result = new F23.Kernel.Results.UnauthorizedResult("NONE SHALL PASS"); + + // Act + var actionResult = result.ToMinimalApiResult(useProblemDetails: false); + + // Assert + Assert.IsType(actionResult); + } + + [Fact] + public void SuccessResultT_Returns_OkObjectResult() + { + // Arrange + var result = new SuccessResult("Hello, World!"); + + // Act + var actionResult = result.ToMinimalApiResult(); + + // Assert + var okObjectResult = Assert.IsType>(actionResult); + var hypermediaResponse = Assert.IsType(okObjectResult.Value); + Assert.Equal("Hello, World!", hypermediaResponse.Content); + } + + [Fact] + public void SuccessResultT_With_SuccessMap_Returns_SuccessMapResult() + { + // Arrange + var result = new SuccessResult("Hello, World!"); + + // Act + var actionResult = result.ToMinimalApiResult(Microsoft.AspNetCore.Http.Results.Ok); + + // Assert + var okObjectResult = Assert.IsType>(actionResult); + Assert.Equal("Hello, World!", okObjectResult.Value); + } + + [Fact] + public void PreconditionFailedResultT_NotFound_Returns_NotFoundResult() + { + // Arrange + var result = new PreconditionFailedResult(PreconditionFailedReason.NotFound, null); + + // Act + var actionResult = result.ToMinimalApiResult(useProblemDetails: false); + + // Assert + Assert.IsType(actionResult); + } + + [Fact] + public void PreconditionFailedResultT_ConcurrencyMismatch_Returns_StatusCodeResult() + { + // Arrange + var result = new PreconditionFailedResult(PreconditionFailedReason.ConcurrencyMismatch, null); + + // Act + var actionResult = result.ToMinimalApiResult(useProblemDetails: false); + + // Assert + var statusCodeResult = Assert.IsType(actionResult); + Assert.Equal((int) HttpStatusCode.PreconditionFailed, statusCodeResult.StatusCode); + } + + [Fact] + public void PreconditionFailedResultT_Conflict_Returns_ConflictResult() + { + // Arrange + var result = new PreconditionFailedResult(PreconditionFailedReason.Conflict, null); + + // Act + var actionResult = result.ToMinimalApiResult(useProblemDetails: false); + + // Assert + Assert.IsType(actionResult); + } + + [Fact] + public void ValidationFailedResultT_Returns_BadRequestObjectResult() + { + // Arrange + var result = new ValidationFailedResult(new List + { + new("Key", "Message") + }); + + // Act + var actionResult = result.ToMinimalApiResult(useProblemDetails: false); + + // Assert + var badRequestObjectResult = Assert.IsType>(actionResult); + var modelState = Assert.IsType(badRequestObjectResult.Value); + Assert.True(modelState.TryGetValue("Key", out var modelStateEntry)); + Assert.NotNull(modelStateEntry); + Assert.Single(modelStateEntry.Errors); + Assert.Equal("Message", modelStateEntry.Errors.First().ErrorMessage); + } + + [Fact] + public void UnauthorizedResultT_Returns_ForbidResult() + { + // Arrange + var result = new UnauthorizedResult("NONE SHALL PASS"); + + // Act + var actionResult = result.ToMinimalApiResult(useProblemDetails: false); + + // Assert + Assert.IsType(actionResult); + } + + [Fact] + public void ToMinimalApiResult_Throws_ArgumentOutOfRangeException_For_Unhandled_Result() + { + // Arrange + var result = new TestUnhandledResult(); + + // Act + void Act() => result.ToMinimalApiResult(); + + // Assert + Assert.Throws(Act); + } + + [Fact] + public void ToMinimalApiResultT_Throws_ArgumentOutOfRangeException_For_Unhandled_Result() + { + // Arrange + var result = new TestUnhandledResult(); + + // Act + void Act() => result.ToMinimalApiResult(); + + // Assert + Assert.Throws(Act); + } + + // Problem Details Tests + [Fact] + public void PreconditionFailedResult_NotFound_With_UseProblemDetails_Returns_ProblemDetails() + { + // Arrange + var result = new PreconditionFailedResult(PreconditionFailedReason.NotFound, "Resource not found"); + + // Act + var minimalResult = result.ToMinimalApiResult(useProblemDetails: true); + + // Assert + Assert.NotNull(minimalResult); + // Problem result is returned, which contains the status code and title + } + + [Fact] + public void PreconditionFailedResult_ConcurrencyMismatch_With_UseProblemDetails_Returns_ProblemDetails() + { + // Arrange + var result = new PreconditionFailedResult(PreconditionFailedReason.ConcurrencyMismatch, "Concurrency mismatch"); + + // Act + var minimalResult = result.ToMinimalApiResult(useProblemDetails: true); + + // Assert + Assert.NotNull(minimalResult); + // Problem result is returned, which contains the status code and title + } + + [Fact] + public void PreconditionFailedResult_Conflict_With_UseProblemDetails_Returns_ProblemDetails() + { + // Arrange + var result = new PreconditionFailedResult(PreconditionFailedReason.Conflict, "Conflict occurred"); + + // Act + var minimalResult = result.ToMinimalApiResult(useProblemDetails: true); + + // Assert + Assert.NotNull(minimalResult); + // Problem result is returned, which contains the status code and title + } + + [Fact] + public void ValidationFailedResult_With_UseProblemDetails_Returns_ProblemDetails() + { + // Arrange + var result = new ValidationFailedResult(new List + { + new("Key", "Error message") + }); + + // Act + var minimalResult = result.ToMinimalApiResult(useProblemDetails: true); + + // Assert + Assert.NotNull(minimalResult); + // Problem result is returned for validation failures with problem details + } + + [Fact] + public void UnauthorizedResult_With_UseProblemDetails_Returns_ProblemDetails() + { + // Arrange + var result = new F23.Kernel.Results.UnauthorizedResult("Access denied"); + + // Act + var minimalResult = result.ToMinimalApiResult(useProblemDetails: true); + + // Assert + Assert.NotNull(minimalResult); + // Problem result is returned, which contains the status code and title + } + + [Fact] + public void PreconditionFailedResultT_NotFound_With_UseProblemDetails_Returns_ProblemDetails() + { + // Arrange + var result = new PreconditionFailedResult(PreconditionFailedReason.NotFound, "Resource not found"); + + // Act + var minimalResult = result.ToMinimalApiResult(useProblemDetails: true); + + // Assert + Assert.NotNull(minimalResult); + // Problem result is returned, which contains the status code and title + } + + [Fact] + public void PreconditionFailedResultT_ConcurrencyMismatch_With_UseProblemDetails_Returns_ProblemDetails() + { + // Arrange + var result = new PreconditionFailedResult(PreconditionFailedReason.ConcurrencyMismatch, "Concurrency mismatch"); + + // Act + var minimalResult = result.ToMinimalApiResult(useProblemDetails: true); + + // Assert + Assert.NotNull(minimalResult); + // Problem result is returned, which contains the status code and title + } + + [Fact] + public void PreconditionFailedResultT_Conflict_With_UseProblemDetails_Returns_ProblemDetails() + { + // Arrange + var result = new PreconditionFailedResult(PreconditionFailedReason.Conflict, "Conflict occurred"); + + // Act + var minimalResult = result.ToMinimalApiResult(useProblemDetails: true); + + // Assert + Assert.NotNull(minimalResult); + // Problem result is returned, which contains the status code and title + } + + [Fact] + public void ValidationFailedResultT_With_UseProblemDetails_Returns_ProblemDetails() + { + // Arrange + var result = new ValidationFailedResult(new List + { + new("Key", "Error message") + }); + + // Act + var minimalResult = result.ToMinimalApiResult(useProblemDetails: true); + + // Assert + Assert.NotNull(minimalResult); + // Problem result is returned for validation failures with problem details + } + + [Fact] + public void UnauthorizedResultT_With_UseProblemDetails_Returns_ProblemDetails() + { + // Arrange + var result = new UnauthorizedResult("Access denied"); + + // Act + var minimalResult = result.ToMinimalApiResult(useProblemDetails: true); + + // Assert + Assert.NotNull(minimalResult); + // Problem result is returned, which contains the status code and title + } + + [Fact] + public void ToProblemHttpResult_Returns_ProblemHttpResult() + { + // Arrange + var result = new TestUnhandledResult(); + + // Act + var httpResult = result.ToProblemHttpResult(HttpStatusCode.BadRequest); + + // Assert + Assert.NotNull(httpResult); + // Problem result is returned with the specified status code and title + } + + private class TestUnhandledResult() : Result(true) + { + public override string Message => "whoopsie"; + } + + private class TestUnhandledResult() : Result(true) + { + public override string Message => "whoopsie"; + } +} diff --git a/src/F23.Kernel.Tests/AspNetCore/MvcResultExtensionsTests.cs b/src/F23.Kernel.Tests/AspNetCore/MvcResultExtensionsTests.cs new file mode 100644 index 0000000..24100c3 --- /dev/null +++ b/src/F23.Kernel.Tests/AspNetCore/MvcResultExtensionsTests.cs @@ -0,0 +1,465 @@ +using System.Net; +using F23.Hateoas; +using F23.Kernel.AspNetCore; +using F23.Kernel.Results; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace F23.Kernel.Tests.AspNetCore; + +public class MvcResultExtensionsTests +{ + [Fact] + public void SuccessResult_Returns_NoContentResult() + { + // Arrange + var result = new SuccessResult(); + + // Act + var actionResult = result.ToActionResult(); + + // Assert + Assert.IsType(actionResult); + } + + [Fact] + public void AggregateResult_Success_Returns_NoContentResult() + { + // Arrange + var result = new AggregateResult([new SuccessResult()]); + + // Act + var actionResult = result.ToActionResult(); + + // Assert + Assert.IsType(actionResult); + } + + [Fact] + public void AggregateResult_Failure_Returns_First_Failed_Result() + { + // Arrange + var result = new AggregateResult([ + new SuccessResult(), + new PreconditionFailedResult(PreconditionFailedReason.NotFound, null) + ]); + + // Act + var actionResult = result.ToActionResult(useProblemDetails: false); + + // Assert + Assert.IsType(actionResult); + } + + [Fact] + public void PreconditionFailedResult_NotFound_Returns_NotFoundResult() + { + // Arrange + var result = new PreconditionFailedResult(PreconditionFailedReason.NotFound, null); + + // Act + var actionResult = result.ToActionResult(useProblemDetails: false); + + // Assert + Assert.IsType(actionResult); + } + + [Fact] + public void PreconditionFailedResult_ConcurrencyMismatch_Returns_StatusCodeResult() + { + // Arrange + var result = new PreconditionFailedResult(PreconditionFailedReason.ConcurrencyMismatch, null); + + // Act + var actionResult = result.ToActionResult(useProblemDetails: false); + + // Assert + var statusCodeResult = Assert.IsType(actionResult); + Assert.Equal((int) HttpStatusCode.PreconditionFailed, statusCodeResult.StatusCode); + } + + [Fact] + public void PreconditionFailedResult_Conflict_Returns_ConflictResult() + { + // Arrange + var result = new PreconditionFailedResult(PreconditionFailedReason.Conflict, null); + + // Act + var actionResult = result.ToActionResult(useProblemDetails: false); + + // Assert + Assert.IsType(actionResult); + } + + [Fact] + public void ValidationFailedResult_Returns_BadRequestObjectResult() + { + // Arrange + var result = new ValidationFailedResult(new List + { + new("Key", "Message") + }); + + // Act + var actionResult = result.ToActionResult(useProblemDetails: false); + + // Assert + var badRequestObjectResult = Assert.IsType(actionResult); + var modelState = Assert.IsType(badRequestObjectResult.Value); + Assert.True(modelState.TryGetValue("Key", out var value)); + var errors = Assert.IsType(value); + var message = Assert.Single(errors); + Assert.Equal("Message", message); + } + + [Fact] + public void UnauthorizedResult_Returns_ForbidResult() + { + // Arrange + var result = new F23.Kernel.Results.UnauthorizedResult("NONE SHALL PASS"); + + // Act + var actionResult = result.ToActionResult(useProblemDetails: false); + + // Assert + Assert.IsType(actionResult); + } + + [Fact] + public void SuccessResultT_Returns_OkObjectResult() + { + // Arrange + var result = new SuccessResult("Hello, World!"); + + // Act + var actionResult = result.ToActionResult(); + + // Assert + var okObjectResult = Assert.IsType(actionResult); + var hypermediaResponse = Assert.IsType(okObjectResult.Value); + Assert.Equal("Hello, World!", hypermediaResponse.Content); + } + + [Fact] + public void SuccessResultT_With_SuccessMap_Returns_SuccessMapResult() + { + // Arrange + var result = new SuccessResult("Hello, World!"); + + // Act + var actionResult = result.ToActionResult(value => new OkObjectResult(value)); + + // Assert + var okObjectResult = Assert.IsType(actionResult); + Assert.Equal("Hello, World!", okObjectResult.Value); + } + + [Fact] + public void PreconditionFailedResultT_NotFound_Returns_NotFoundResult() + { + // Arrange + var result = new PreconditionFailedResult(PreconditionFailedReason.NotFound, null); + + // Act + var actionResult = result.ToActionResult(useProblemDetails: false); + + // Assert + Assert.IsType(actionResult); + } + + [Fact] + public void PreconditionFailedResultT_ConcurrencyMismatch_Returns_StatusCodeResult() + { + // Arrange + var result = new PreconditionFailedResult(PreconditionFailedReason.ConcurrencyMismatch, null); + + // Act + var actionResult = result.ToActionResult(useProblemDetails: false); + + // Assert + var statusCodeResult = Assert.IsType(actionResult); + Assert.Equal((int) HttpStatusCode.PreconditionFailed, statusCodeResult.StatusCode); + } + + [Fact] + public void PreconditionFailedResultT_Conflict_Returns_ConflictResult() + { + // Arrange + var result = new PreconditionFailedResult(PreconditionFailedReason.Conflict, null); + + // Act + var actionResult = result.ToActionResult(useProblemDetails: false); + + // Assert + Assert.IsType(actionResult); + } + + [Fact] + public void ValidationFailedResultT_Returns_BadRequestObjectResult() + { + // Arrange + var result = new ValidationFailedResult(new List + { + new("Key", "Message") + }); + + // Act + var actionResult = result.ToActionResult(useProblemDetails: false); + + // Assert + var badRequestObjectResult = Assert.IsType(actionResult); + var modelState = Assert.IsType(badRequestObjectResult.Value); + Assert.True(modelState.TryGetValue("Key", out var value)); + var errors = Assert.IsType(value); + var message = Assert.Single(errors); + Assert.Equal("Message", message); + } + + [Fact] + public void UnauthorizedResultT_Returns_ForbidResult() + { + // Arrange + var result = new UnauthorizedResult("NONE SHALL PASS"); + + // Act + var actionResult = result.ToActionResult(useProblemDetails: false); + + // Assert + Assert.IsType(actionResult); + } + + [Fact] + public void ToActionResult_Throws_ArgumentOutOfRangeException_For_Unhandled_Result() + { + // Arrange + var result = new TestUnhandledResult(); + + // Act + void Act() => result.ToActionResult(); + + // Assert + Assert.Throws(Act); + } + + [Fact] + public void ToActionResultT_Throws_ArgumentOutOfRangeException_For_Unhandled_Result() + { + // Arrange + var result = new TestUnhandledResult(); + + // Act + void Act() => result.ToActionResult(); + + // Assert + Assert.Throws(Act); + } + + [Fact] + public void ToProblemDetailsResult_Returns_ProblemDetailsResult() + { + // Arrange + var result = new TestUnhandledResult(); + + // Act + var actionResult = result.ToProblemDetailsResult(HttpStatusCode.BadRequest); + + // Assert + var objectResult = Assert.IsType(actionResult); + var problemDetailsResult = Assert.IsType(objectResult.Value); + Assert.Equal((int)HttpStatusCode.BadRequest, problemDetailsResult.Status); + Assert.Equal("whoopsie", problemDetailsResult.Title); + } + + // Problem Details Tests + [Fact] + public void PreconditionFailedResult_NotFound_With_UseProblemDetails_Returns_ProblemDetails() + { + // Arrange + var result = new PreconditionFailedResult(PreconditionFailedReason.NotFound, "Resource not found"); + + // Act + var actionResult = result.ToActionResult(useProblemDetails: true); + + // Assert + var objectResult = Assert.IsType(actionResult); + Assert.Equal((int)HttpStatusCode.NotFound, objectResult.StatusCode); + var problemDetails = Assert.IsType(objectResult.Value); + Assert.Equal((int)HttpStatusCode.NotFound, problemDetails.Status); + Assert.Equal("Resource not found", problemDetails.Title); + } + + [Fact] + public void PreconditionFailedResult_ConcurrencyMismatch_With_UseProblemDetails_Returns_ProblemDetails() + { + // Arrange + var result = new PreconditionFailedResult(PreconditionFailedReason.ConcurrencyMismatch, "Concurrency mismatch"); + + // Act + var actionResult = result.ToActionResult(useProblemDetails: true); + + // Assert + var objectResult = Assert.IsType(actionResult); + Assert.Equal((int)HttpStatusCode.PreconditionFailed, objectResult.StatusCode); + var problemDetails = Assert.IsType(objectResult.Value); + Assert.Equal((int)HttpStatusCode.PreconditionFailed, problemDetails.Status); + Assert.Equal("Concurrency mismatch", problemDetails.Title); + } + + [Fact] + public void PreconditionFailedResult_Conflict_With_UseProblemDetails_Returns_ProblemDetails() + { + // Arrange + var result = new PreconditionFailedResult(PreconditionFailedReason.Conflict, "Conflict occurred"); + + // Act + var actionResult = result.ToActionResult(useProblemDetails: true); + + // Assert + var objectResult = Assert.IsType(actionResult); + Assert.Equal((int)HttpStatusCode.Conflict, objectResult.StatusCode); + var problemDetails = Assert.IsType(objectResult.Value); + Assert.Equal((int)HttpStatusCode.Conflict, problemDetails.Status); + Assert.Equal("Conflict occurred", problemDetails.Title); + } + + [Fact] + public void ValidationFailedResult_With_UseProblemDetails_Returns_ValidationProblemDetails() + { + // Arrange + var result = new ValidationFailedResult(new List + { + new("Key", "Error message") + }); + + // Act + var actionResult = result.ToActionResult(useProblemDetails: true); + + // Assert + var objectResult = Assert.IsType(actionResult); + Assert.Equal((int)HttpStatusCode.BadRequest, objectResult.StatusCode); + var validationProblemDetails = Assert.IsType(objectResult.Value); + Assert.Equal((int)HttpStatusCode.BadRequest, validationProblemDetails.Status); + Assert.Equal("The operation failed due to validation errors.", validationProblemDetails.Title); + Assert.True(validationProblemDetails.Errors.TryGetValue("Key", out var errors)); + Assert.Single(errors); + Assert.Equal("Error message", errors[0]); + } + + [Fact] + public void UnauthorizedResult_With_UseProblemDetails_Returns_ProblemDetails() + { + // Arrange + var result = new F23.Kernel.Results.UnauthorizedResult("Access denied"); + + // Act + var actionResult = result.ToActionResult(useProblemDetails: true); + + // Assert + var objectResult = Assert.IsType(actionResult); + Assert.Equal((int)HttpStatusCode.Forbidden, objectResult.StatusCode); + var problemDetails = Assert.IsType(objectResult.Value); + Assert.Equal((int)HttpStatusCode.Forbidden, problemDetails.Status); + Assert.Equal("Access denied", problemDetails.Title); + } + + [Fact] + public void PreconditionFailedResultT_NotFound_With_UseProblemDetails_Returns_ProblemDetails() + { + // Arrange + var result = new PreconditionFailedResult(PreconditionFailedReason.NotFound, "Resource not found"); + + // Act + var actionResult = result.ToActionResult(useProblemDetails: true); + + // Assert + var objectResult = Assert.IsType(actionResult); + Assert.Equal((int)HttpStatusCode.NotFound, objectResult.StatusCode); + var problemDetails = Assert.IsType(objectResult.Value); + Assert.Equal((int)HttpStatusCode.NotFound, problemDetails.Status); + Assert.Equal("Resource not found", problemDetails.Title); + } + + [Fact] + public void PreconditionFailedResultT_ConcurrencyMismatch_With_UseProblemDetails_Returns_ProblemDetails() + { + // Arrange + var result = new PreconditionFailedResult(PreconditionFailedReason.ConcurrencyMismatch, "Concurrency mismatch"); + + // Act + var actionResult = result.ToActionResult(useProblemDetails: true); + + // Assert + var objectResult = Assert.IsType(actionResult); + Assert.Equal((int)HttpStatusCode.PreconditionFailed, objectResult.StatusCode); + var problemDetails = Assert.IsType(objectResult.Value); + Assert.Equal((int)HttpStatusCode.PreconditionFailed, problemDetails.Status); + Assert.Equal("Concurrency mismatch", problemDetails.Title); + } + + [Fact] + public void PreconditionFailedResultT_Conflict_With_UseProblemDetails_Returns_ProblemDetails() + { + // Arrange + var result = new PreconditionFailedResult(PreconditionFailedReason.Conflict, "Conflict occurred"); + + // Act + var actionResult = result.ToActionResult(useProblemDetails: true); + + // Assert + var objectResult = Assert.IsType(actionResult); + Assert.Equal((int)HttpStatusCode.Conflict, objectResult.StatusCode); + var problemDetails = Assert.IsType(objectResult.Value); + Assert.Equal((int)HttpStatusCode.Conflict, problemDetails.Status); + Assert.Equal("Conflict occurred", problemDetails.Title); + } + + [Fact] + public void ValidationFailedResultT_With_UseProblemDetails_Returns_ValidationProblemDetails() + { + // Arrange + var result = new ValidationFailedResult(new List + { + new("Key", "Error message") + }); + + // Act + var actionResult = result.ToActionResult(useProblemDetails: true); + + // Assert + var objectResult = Assert.IsType(actionResult); + Assert.Equal((int)HttpStatusCode.BadRequest, objectResult.StatusCode); + var validationProblemDetails = Assert.IsType(objectResult.Value); + Assert.Equal((int)HttpStatusCode.BadRequest, validationProblemDetails.Status); + Assert.Equal("The operation failed due to validation errors.", validationProblemDetails.Title); + Assert.True(validationProblemDetails.Errors.TryGetValue("Key", out var errors)); + Assert.Single(errors); + Assert.Equal("Error message", errors[0]); + } + + [Fact] + public void UnauthorizedResultT_With_UseProblemDetails_Returns_ProblemDetails() + { + // Arrange + var result = new UnauthorizedResult("Access denied"); + + // Act + var actionResult = result.ToActionResult(useProblemDetails: true); + + // Assert + var objectResult = Assert.IsType(actionResult); + Assert.Equal((int)HttpStatusCode.Forbidden, objectResult.StatusCode); + var problemDetails = Assert.IsType(objectResult.Value); + Assert.Equal((int)HttpStatusCode.Forbidden, problemDetails.Status); + Assert.Equal("Access denied", problemDetails.Title); + } + + private class TestUnhandledResult() : Result(true) + { + public override string Message => "whoopsie"; + } + + private class TestUnhandledResult() : Result(true) + { + public override string Message => "whoopsie"; + } +} diff --git a/src/F23.Kernel.Tests/AspNetCore/ResultExtensionsMinimalApiTests.cs b/src/F23.Kernel.Tests/AspNetCore/ResultExtensionsMinimalApiTests.cs deleted file mode 100644 index 18dcb88..0000000 --- a/src/F23.Kernel.Tests/AspNetCore/ResultExtensionsMinimalApiTests.cs +++ /dev/null @@ -1,267 +0,0 @@ -using System.Net; -using F23.Hateoas; -using F23.Kernel.AspNetCore; -using F23.Kernel.Results; -using Microsoft.AspNetCore.Http.HttpResults; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.ModelBinding; - -namespace F23.Kernel.Tests.AspNetCore; - -public class ResultExtensionsMinimalApiTests -{ - [Fact] - public void SuccessResult_Returns_NoContentResult() - { - // Arrange - var result = new SuccessResult(); - - // Act - var actionResult = result.ToMinimalApiResult(); - - // Assert - Assert.IsType(actionResult); - } - - [Fact] - public void AggregateResult_Success_Returns_NoContentResult() - { - // Arrange - var result = new AggregateResult([new SuccessResult()]); - - // Act - var actionResult = result.ToMinimalApiResult(); - - // Assert - Assert.IsType(actionResult); - } - - [Fact] - public void AggregateResult_Failure_Returns_First_Failed_Result() - { - // Arrange - var result = new AggregateResult([ - new SuccessResult(), - new PreconditionFailedResult(PreconditionFailedReason.NotFound, null) - ]); - - // Act - var actionResult = result.ToMinimalApiResult(); - - // Assert - Assert.IsType(actionResult); - } - - [Fact] - public void PreconditionFailedResult_NotFound_Returns_NotFoundResult() - { - // Arrange - var result = new PreconditionFailedResult(PreconditionFailedReason.NotFound, null); - - // Act - var actionResult = result.ToMinimalApiResult(); - - // Assert - Assert.IsType(actionResult); - } - - [Fact] - public void PreconditionFailedResult_ConcurrencyMismatch_Returns_StatusCodeResult() - { - // Arrange - var result = new PreconditionFailedResult(PreconditionFailedReason.ConcurrencyMismatch, null); - - // Act - var actionResult = result.ToMinimalApiResult(); - - // Assert - var statusCodeResult = Assert.IsType(actionResult); - Assert.Equal((int) HttpStatusCode.PreconditionFailed, statusCodeResult.StatusCode); - } - - [Fact] - public void PreconditionFailedResult_Conflict_Returns_ConflictResult() - { - // Arrange - var result = new PreconditionFailedResult(PreconditionFailedReason.Conflict, null); - - // Act - var actionResult = result.ToMinimalApiResult(); - - // Assert - Assert.IsType(actionResult); - } - - [Fact(Skip = "Need better implementation of BadRequest with ModelState for minimal APIs")] - public void ValidationFailedResult_Returns_BadRequestObjectResult() - { - // Arrange - var result = new ValidationFailedResult(new List - { - new("Key", "Message") - }); - - // Act - var actionResult = result.ToMinimalApiResult(); - - // Assert - var badRequestObjectResult = Assert.IsType>(actionResult); - var modelState = Assert.IsType(badRequestObjectResult.Value); - Assert.True(modelState.TryGetValue("Key", out var value)); - var errors = Assert.IsType(value); - var message = Assert.Single(errors); - Assert.Equal("Message", message); - } - - [Fact] - public void UnauthorizedResult_Returns_UnauthorizedResult() - { - // Arrange - var result = new F23.Kernel.Results.UnauthorizedResult("NONE SHALL PASS"); - - // Act - var actionResult = result.ToMinimalApiResult(); - - // Assert - Assert.IsType(actionResult); - } - - [Fact] - public void SuccessResultT_Returns_OkObjectResult() - { - // Arrange - var result = new SuccessResult("Hello, World!"); - - // Act - var actionResult = result.ToMinimalApiResult(); - - // Assert - var okObjectResult = Assert.IsType>(actionResult); - var hypermediaResponse = Assert.IsType(okObjectResult.Value); - Assert.Equal("Hello, World!", hypermediaResponse.Content); - } - - [Fact] - public void SuccessResultT_With_SuccessMap_Returns_SuccessMapResult() - { - // Arrange - var result = new SuccessResult("Hello, World!"); - - // Act - var actionResult = result.ToMinimalApiResult(Microsoft.AspNetCore.Http.Results.Ok); - - // Assert - var okObjectResult = Assert.IsType>(actionResult); - Assert.Equal("Hello, World!", okObjectResult.Value); - } - - [Fact] - public void PreconditionFailedResultT_NotFound_Returns_NotFoundResult() - { - // Arrange - var result = new PreconditionFailedResult(PreconditionFailedReason.NotFound, null); - - // Act - var actionResult = result.ToMinimalApiResult(); - - // Assert - Assert.IsType(actionResult); - } - - [Fact] - public void PreconditionFailedResultT_ConcurrencyMismatch_Returns_StatusCodeResult() - { - // Arrange - var result = new PreconditionFailedResult(PreconditionFailedReason.ConcurrencyMismatch, null); - - // Act - var actionResult = result.ToMinimalApiResult(); - - // Assert - var statusCodeResult = Assert.IsType(actionResult); - Assert.Equal((int) HttpStatusCode.PreconditionFailed, statusCodeResult.StatusCode); - } - - [Fact] - public void PreconditionFailedResultT_Conflict_Returns_ConflictResult() - { - // Arrange - var result = new PreconditionFailedResult(PreconditionFailedReason.Conflict, null); - - // Act - var actionResult = result.ToMinimalApiResult(); - - // Assert - Assert.IsType(actionResult); - } - - [Fact(Skip = "Need better implementation of BadRequest with ModelState for minimal APIs")] - public void ValidationFailedResultT_Returns_BadRequestObjectResult() - { - // Arrange - var result = new ValidationFailedResult(new List - { - new("Key", "Message") - }); - - // Act - var actionResult = result.ToMinimalApiResult(); - - // Assert - var badRequestObjectResult = Assert.IsType>(actionResult); - var modelState = Assert.IsType(badRequestObjectResult.Value); - Assert.True(modelState.TryGetValue("Key", out var value)); - var errors = Assert.IsType(value); - var message = Assert.Single(errors); - Assert.Equal("Message", message); - } - - [Fact] - public void UnauthorizedResultT_Returns_UnauthorizedResult() - { - // Arrange - var result = new UnauthorizedResult("NONE SHALL PASS"); - - // Act - var actionResult = result.ToMinimalApiResult(); - - // Assert - Assert.IsType(actionResult); - } - - [Fact] - public void ToMinimalApiResult_Throws_ArgumentOutOfRangeException_For_Unhandled_Result() - { - // Arrange - var result = new TestUnhandledResult(); - - // Act - void Act() => result.ToMinimalApiResult(); - - // Assert - Assert.Throws(Act); - } - - [Fact] - public void ToMinimalApiResultT_Throws_ArgumentOutOfRangeException_For_Unhandled_Result() - { - // Arrange - var result = new TestUnhandledResult(); - - // Act - void Act() => result.ToMinimalApiResult(); - - // Assert - Assert.Throws(Act); - } - - private class TestUnhandledResult() : Result(true) - { - public override string Message => "whoopsie"; - } - - private class TestUnhandledResult() : Result(true) - { - public override string Message => "whoopsie"; - } -} diff --git a/src/F23.Kernel.Tests/AspNetCore/ResultExtensionsTests.cs b/src/F23.Kernel.Tests/AspNetCore/ResultExtensionsTests.cs deleted file mode 100644 index 9a10f72..0000000 --- a/src/F23.Kernel.Tests/AspNetCore/ResultExtensionsTests.cs +++ /dev/null @@ -1,266 +0,0 @@ -using System.Net; -using F23.Hateoas; -using F23.Kernel.AspNetCore; -using F23.Kernel.Results; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.ModelBinding; - -namespace F23.Kernel.Tests.AspNetCore; - -public class ResultExtensionsTests -{ - [Fact] - public void SuccessResult_Returns_NoContentResult() - { - // Arrange - var result = new SuccessResult(); - - // Act - var actionResult = result.ToActionResult(); - - // Assert - Assert.IsType(actionResult); - } - - [Fact] - public void AggregateResult_Success_Returns_NoContentResult() - { - // Arrange - var result = new AggregateResult([new SuccessResult()]); - - // Act - var actionResult = result.ToActionResult(); - - // Assert - Assert.IsType(actionResult); - } - - [Fact] - public void AggregateResult_Failure_Returns_First_Failed_Result() - { - // Arrange - var result = new AggregateResult([ - new SuccessResult(), - new PreconditionFailedResult(PreconditionFailedReason.NotFound, null) - ]); - - // Act - var actionResult = result.ToActionResult(); - - // Assert - Assert.IsType(actionResult); - } - - [Fact] - public void PreconditionFailedResult_NotFound_Returns_NotFoundResult() - { - // Arrange - var result = new PreconditionFailedResult(PreconditionFailedReason.NotFound, null); - - // Act - var actionResult = result.ToActionResult(); - - // Assert - Assert.IsType(actionResult); - } - - [Fact] - public void PreconditionFailedResult_ConcurrencyMismatch_Returns_StatusCodeResult() - { - // Arrange - var result = new PreconditionFailedResult(PreconditionFailedReason.ConcurrencyMismatch, null); - - // Act - var actionResult = result.ToActionResult(); - - // Assert - var statusCodeResult = Assert.IsType(actionResult); - Assert.Equal((int) HttpStatusCode.PreconditionFailed, statusCodeResult.StatusCode); - } - - [Fact] - public void PreconditionFailedResult_Conflict_Returns_ConflictResult() - { - // Arrange - var result = new PreconditionFailedResult(PreconditionFailedReason.Conflict, null); - - // Act - var actionResult = result.ToActionResult(); - - // Assert - Assert.IsType(actionResult); - } - - [Fact] - public void ValidationFailedResult_Returns_BadRequestObjectResult() - { - // Arrange - var result = new ValidationFailedResult(new List - { - new("Key", "Message") - }); - - // Act - var actionResult = result.ToActionResult(); - - // Assert - var badRequestObjectResult = Assert.IsType(actionResult); - var modelState = Assert.IsType(badRequestObjectResult.Value); - Assert.True(modelState.TryGetValue("Key", out var value)); - var errors = Assert.IsType(value); - var message = Assert.Single(errors); - Assert.Equal("Message", message); - } - - [Fact] - public void UnauthorizedResult_Returns_UnauthorizedResult() - { - // Arrange - var result = new F23.Kernel.Results.UnauthorizedResult("NONE SHALL PASS"); - - // Act - var actionResult = result.ToActionResult(); - - // Assert - Assert.IsType(actionResult); - } - - [Fact] - public void SuccessResultT_Returns_OkObjectResult() - { - // Arrange - var result = new SuccessResult("Hello, World!"); - - // Act - var actionResult = result.ToActionResult(); - - // Assert - var okObjectResult = Assert.IsType(actionResult); - var hypermediaResponse = Assert.IsType(okObjectResult.Value); - Assert.Equal("Hello, World!", hypermediaResponse.Content); - } - - [Fact] - public void SuccessResultT_With_SuccessMap_Returns_SuccessMapResult() - { - // Arrange - var result = new SuccessResult("Hello, World!"); - - // Act - var actionResult = result.ToActionResult(value => new OkObjectResult(value)); - - // Assert - var okObjectResult = Assert.IsType(actionResult); - Assert.Equal("Hello, World!", okObjectResult.Value); - } - - [Fact] - public void PreconditionFailedResultT_NotFound_Returns_NotFoundResult() - { - // Arrange - var result = new PreconditionFailedResult(PreconditionFailedReason.NotFound, null); - - // Act - var actionResult = result.ToActionResult(); - - // Assert - Assert.IsType(actionResult); - } - - [Fact] - public void PreconditionFailedResultT_ConcurrencyMismatch_Returns_StatusCodeResult() - { - // Arrange - var result = new PreconditionFailedResult(PreconditionFailedReason.ConcurrencyMismatch, null); - - // Act - var actionResult = result.ToActionResult(); - - // Assert - var statusCodeResult = Assert.IsType(actionResult); - Assert.Equal((int) HttpStatusCode.PreconditionFailed, statusCodeResult.StatusCode); - } - - [Fact] - public void PreconditionFailedResultT_Conflict_Returns_ConflictResult() - { - // Arrange - var result = new PreconditionFailedResult(PreconditionFailedReason.Conflict, null); - - // Act - var actionResult = result.ToActionResult(); - - // Assert - Assert.IsType(actionResult); - } - - [Fact] - public void ValidationFailedResultT_Returns_BadRequestObjectResult() - { - // Arrange - var result = new ValidationFailedResult(new List - { - new("Key", "Message") - }); - - // Act - var actionResult = result.ToActionResult(); - - // Assert - var badRequestObjectResult = Assert.IsType(actionResult); - var modelState = Assert.IsType(badRequestObjectResult.Value); - Assert.True(modelState.TryGetValue("Key", out var value)); - var errors = Assert.IsType(value); - var message = Assert.Single(errors); - Assert.Equal("Message", message); - } - - [Fact] - public void UnauthorizedResultT_Returns_UnauthorizedResult() - { - // Arrange - var result = new UnauthorizedResult("NONE SHALL PASS"); - - // Act - var actionResult = result.ToActionResult(); - - // Assert - Assert.IsType(actionResult); - } - - [Fact] - public void ToActionResult_Throws_ArgumentOutOfRangeException_For_Unhandled_Result() - { - // Arrange - var result = new TestUnhandledResult(); - - // Act - void Act() => result.ToActionResult(); - - // Assert - Assert.Throws(Act); - } - - [Fact] - public void ToActionResultT_Throws_ArgumentOutOfRangeException_For_Unhandled_Result() - { - // Arrange - var result = new TestUnhandledResult(); - - // Act - void Act() => result.ToActionResult(); - - // Assert - Assert.Throws(Act); - } - - private class TestUnhandledResult() : Result(true) - { - public override string Message => "whoopsie"; - } - - private class TestUnhandledResult() : Result(true) - { - public override string Message => "whoopsie"; - } -} diff --git a/src/F23.Kernel/ValidationErrorExtensions.cs b/src/F23.Kernel/ValidationErrorExtensions.cs new file mode 100644 index 0000000..a73c5c3 --- /dev/null +++ b/src/F23.Kernel/ValidationErrorExtensions.cs @@ -0,0 +1,33 @@ +namespace F23.Kernel; + +/// +/// Extension methods related to . +/// +public static class ValidationErrorExtensions +{ + /// + /// Creates a dictionary of error messages from the given . + /// + /// The validation errors. + /// >A dictionary where the keys are the field names and the values are arrays of error messages. + /// + /// This is adapted from MIT-licensed code from .NET, in Microsoft.AspNetCore.Mvc.ValidationProblemDetails. + /// + /// License text in file: + /// Licensed to the .NET Foundation under one or more agreements. + /// The .NET Foundation licenses this file to you under the MIT license. + /// + public static IDictionary CreateErrorDictionary(this IReadOnlyCollection validationErrors) + { + ArgumentNullException.ThrowIfNull(validationErrors); + + var errorDictionary = new Dictionary(StringComparer.Ordinal); + + foreach (var (key, message) in validationErrors) + { + errorDictionary.Add(key, [message]); + } + + return errorDictionary; + } +} From fb88366350ab900b80f98bea775f5758d7294075 Mon Sep 17 00:00:00 2001 From: Paul Irwin Date: Thu, 6 Nov 2025 11:12:17 -0700 Subject: [PATCH 2/2] Support accumulating multiple validation errors per field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improve CreateErrorDictionary to properly handle and accumulate multiple errors with the same key into arrays. This is critical for RFC7807 problem details format where validation fields may have multiple error messages. Changes: - Handle duplicate keys by appending messages to existing array - Use case-sensitive comparison (StringComparer.Ordinal) - Efficient array allocation strategy - Add comprehensive unit tests for all scenarios Tests cover: - Single error per key - Multiple errors with different keys - Multiple errors with same key (accumulation) - Mixed single and multiple errors - Empty collections and null validation - Case sensitivity verification - Error order preservation - Special character keys All 133 tests pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../ValidationErrorExtensionsTests.cs | 204 ++++++++++++++++++ src/F23.Kernel/ValidationErrorExtensions.cs | 15 +- 2 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 src/F23.Kernel.Tests/ValidationErrorExtensionsTests.cs diff --git a/src/F23.Kernel.Tests/ValidationErrorExtensionsTests.cs b/src/F23.Kernel.Tests/ValidationErrorExtensionsTests.cs new file mode 100644 index 0000000..8359572 --- /dev/null +++ b/src/F23.Kernel.Tests/ValidationErrorExtensionsTests.cs @@ -0,0 +1,204 @@ +namespace F23.Kernel.Tests; + +public class ValidationErrorExtensionsTests +{ + [Fact] + public void CreateErrorDictionary_WithSingleError_Returns_DictionaryWithOneEntry() + { + // Arrange + var errors = new[] { new ValidationError("email", "Invalid email") }; + + // Act + var result = errors.CreateErrorDictionary(); + + // Assert + Assert.Single(result); + Assert.True(result.TryGetValue("email", out var messages)); + Assert.Single(messages); + Assert.Equal("Invalid email", messages[0]); + } + + [Fact] + public void CreateErrorDictionary_WithMultipleErrorsDifferentKeys_Returns_DictionaryWithMultipleEntries() + { + // Arrange + var errors = new[] + { + new ValidationError("email", "Invalid email"), + new ValidationError("password", "Password too short"), + new ValidationError("username", "Username taken") + }; + + // Act + var result = errors.CreateErrorDictionary(); + + // Assert + Assert.Equal(3, result.Count); + Assert.True(result.TryGetValue("email", out var emailMessages)); + Assert.Single(emailMessages); + Assert.Equal("Invalid email", emailMessages[0]); + + Assert.True(result.TryGetValue("password", out var passwordMessages)); + Assert.Single(passwordMessages); + Assert.Equal("Password too short", passwordMessages[0]); + + Assert.True(result.TryGetValue("username", out var usernameMessages)); + Assert.Single(usernameMessages); + Assert.Equal("Username taken", usernameMessages[0]); + } + + [Fact] + public void CreateErrorDictionary_WithMultipleErrorsSameKey_Returns_AccumulatedMessages() + { + // Arrange + var errors = new[] + { + new ValidationError("email", "Email is required"), + new ValidationError("email", "Email format is invalid"), + new ValidationError("email", "Email already registered") + }; + + // Act + var result = errors.CreateErrorDictionary(); + + // Assert + Assert.Single(result); + Assert.True(result.TryGetValue("email", out var messages)); + Assert.Equal(3, messages.Length); + Assert.Equal("Email is required", messages[0]); + Assert.Equal("Email format is invalid", messages[1]); + Assert.Equal("Email already registered", messages[2]); + } + + [Fact] + public void CreateErrorDictionary_WithMixedSingleAndMultipleErrors_Returns_ProperlyAccumulatedMessages() + { + // Arrange + var errors = new[] + { + new ValidationError("email", "Email is required"), + new ValidationError("email", "Email format is invalid"), + new ValidationError("password", "Password too short"), + new ValidationError("username", "Username taken"), + new ValidationError("username", "Username contains invalid characters") + }; + + // Act + var result = errors.CreateErrorDictionary(); + + // Assert + Assert.Equal(3, result.Count); + + Assert.True(result.TryGetValue("email", out var emailMessages)); + Assert.Equal(2, emailMessages.Length); + Assert.Equal(new[] { "Email is required", "Email format is invalid" }, emailMessages); + + Assert.True(result.TryGetValue("password", out var passwordMessages)); + Assert.Single(passwordMessages); + Assert.Equal("Password too short", passwordMessages[0]); + + Assert.True(result.TryGetValue("username", out var usernameMessages)); + Assert.Equal(2, usernameMessages.Length); + Assert.Equal(new[] { "Username taken", "Username contains invalid characters" }, usernameMessages); + } + + [Fact] + public void CreateErrorDictionary_WithEmptyCollection_Returns_EmptyDictionary() + { + // Arrange + var errors = Array.Empty(); + + // Act + var result = errors.CreateErrorDictionary(); + + // Assert + Assert.Empty(result); + } + + [Fact] + public void CreateErrorDictionary_WithNullInput_Throws_ArgumentNullException() + { + // Arrange + IReadOnlyCollection? errors = null; + + // Act + void Act() => errors!.CreateErrorDictionary(); + + // Assert + Assert.Throws(Act); + } + + [Fact] + public void CreateErrorDictionary_IsCaseSensitive() + { + // Arrange + var errors = new[] + { + new ValidationError("email", "Error 1"), + new ValidationError("Email", "Error 2"), + new ValidationError("EMAIL", "Error 3") + }; + + // Act + var result = errors.CreateErrorDictionary(); + + // Assert + Assert.Equal(3, result.Count); + Assert.True(result.TryGetValue("email", out var lowercaseMessages)); + Assert.Single(lowercaseMessages); + Assert.Equal("Error 1", lowercaseMessages[0]); + + Assert.True(result.TryGetValue("Email", out var pascalcaseMessages)); + Assert.Single(pascalcaseMessages); + Assert.Equal("Error 2", pascalcaseMessages[0]); + + Assert.True(result.TryGetValue("EMAIL", out var uppercaseMessages)); + Assert.Single(uppercaseMessages); + Assert.Equal("Error 3", uppercaseMessages[0]); + } + + [Fact] + public void CreateErrorDictionary_PreservesErrorOrder() + { + // Arrange + var errors = new[] + { + new ValidationError("field", "First error"), + new ValidationError("field", "Second error"), + new ValidationError("field", "Third error"), + new ValidationError("field", "Fourth error") + }; + + // Act + var result = errors.CreateErrorDictionary(); + + // Assert + Assert.True(result.TryGetValue("field", out var messages)); + Assert.Equal(4, messages.Length); + Assert.Equal("First error", messages[0]); + Assert.Equal("Second error", messages[1]); + Assert.Equal("Third error", messages[2]); + Assert.Equal("Fourth error", messages[3]); + } + + [Fact] + public void CreateErrorDictionary_WithSpecialCharacterKeys_Returns_CorrectMapping() + { + // Arrange + var errors = new[] + { + new ValidationError("user.email", "Invalid email"), + new ValidationError("user-phone", "Invalid phone"), + new ValidationError("user_address", "Invalid address") + }; + + // Act + var result = errors.CreateErrorDictionary(); + + // Assert + Assert.Equal(3, result.Count); + Assert.True(result.ContainsKey("user.email")); + Assert.True(result.ContainsKey("user-phone")); + Assert.True(result.ContainsKey("user_address")); + } +} diff --git a/src/F23.Kernel/ValidationErrorExtensions.cs b/src/F23.Kernel/ValidationErrorExtensions.cs index a73c5c3..10bc162 100644 --- a/src/F23.Kernel/ValidationErrorExtensions.cs +++ b/src/F23.Kernel/ValidationErrorExtensions.cs @@ -9,7 +9,7 @@ public static class ValidationErrorExtensions /// Creates a dictionary of error messages from the given . /// /// The validation errors. - /// >A dictionary where the keys are the field names and the values are arrays of error messages. + /// A dictionary where the keys are the field names and the values are arrays of error messages. /// /// This is adapted from MIT-licensed code from .NET, in Microsoft.AspNetCore.Mvc.ValidationProblemDetails. /// @@ -25,7 +25,18 @@ public static IDictionary CreateErrorDictionary(this IReadOnly foreach (var (key, message) in validationErrors) { - errorDictionary.Add(key, [message]); + if (errorDictionary.TryGetValue(key, out var messages)) + { + // Inefficient, but probably better than allocating a List for each just to convert it back to an array. + var updatedMessages = new string[messages.Length + 1]; + messages.CopyTo(updatedMessages, 0); + updatedMessages[^1] = message; + errorDictionary[key] = updatedMessages; + } + else + { + errorDictionary.Add(key, [message]); + } } return errorDictionary;