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.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 new file mode 100644 index 0000000..10bc162 --- /dev/null +++ b/src/F23.Kernel/ValidationErrorExtensions.cs @@ -0,0 +1,44 @@ +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) + { + 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; + } +}