From e1e9057fab59cddd0941f4514d88243a9937bba2 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Sat, 18 May 2024 21:22:22 +0200 Subject: [PATCH 1/3] feat: add additional error types Signed-off-by: Kenny Pflug --- .../Extensions/ToHttpStatusCode.cs | 16 +++++++--------- src/ErrorOr/Errors/ErrorType.cs | 5 +++++ 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/ErrorOr.AspNetCore/Extensions/ToHttpStatusCode.cs b/src/ErrorOr.AspNetCore/Extensions/ToHttpStatusCode.cs index e1a224d..b3c4730 100644 --- a/src/ErrorOr.AspNetCore/Extensions/ToHttpStatusCode.cs +++ b/src/ErrorOr.AspNetCore/Extensions/ToHttpStatusCode.cs @@ -6,20 +6,18 @@ public static class ErrorToHttpStatusCodeExtensions { public static int ToHttpStatsCode(this Error error) { - foreach (var mapping in ErrorOrOptions.Instance.ErrorToStatusCodeMapper) - { - if (mapping(error) is int statusCode) - { - return statusCode; - } - } - return error.Type switch { ErrorType.Conflict => StatusCodes.Status409Conflict, ErrorType.Validation => StatusCodes.Status400BadRequest, ErrorType.NotFound => StatusCodes.Status404NotFound, - ErrorType.Unauthorized => StatusCodes.Status403Forbidden, + ErrorType.Unauthorized => StatusCodes.Status401Unauthorized, + ErrorType.Forbidden => StatusCodes.Status403Forbidden, + ErrorType.UnsupportedMediaType => StatusCodes.Status415UnsupportedMediaType, + ErrorType.UnavailableForLegalReasons => StatusCodes.Status451UnavailableForLegalReasons, + ErrorType.BadGateway => StatusCodes.Status502BadGateway, + ErrorType.ServiceUnavailable => StatusCodes.Status503ServiceUnavailable, + ErrorType.GatewayTimeout => StatusCodes.Status504GatewayTimeout, _ => StatusCodes.Status500InternalServerError, }; } diff --git a/src/ErrorOr/Errors/ErrorType.cs b/src/ErrorOr/Errors/ErrorType.cs index d0536fa..a522ce8 100644 --- a/src/ErrorOr/Errors/ErrorType.cs +++ b/src/ErrorOr/Errors/ErrorType.cs @@ -12,4 +12,9 @@ public enum ErrorType NotFound, Unauthorized, Forbidden, + UnsupportedMediaType, + UnavailableForLegalReasons, + BadGateway, + ServiceUnavailable, + GatewayTimeout, } From 5f9a1af914de49ea1c53c32a4b24c7e61b0bbecc Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Mon, 20 May 2024 23:11:36 +0200 Subject: [PATCH 2/3] feat: implemented prototype pattern for default conversion to minimal API IResult and MVC IActionResult Signed-off-by: Kenny Pflug --- .../DefaultConversion/ErrorDefaults.cs | 109 ++++++++++++ .../DefaultConversion/ErrorExtensions.cs | 29 ++++ .../DefaultConversion/ErrorProblemDetails.cs | 28 +++ .../ModelStateDictionaryExtensions.cs | 18 ++ .../DefaultConversion/ProblemDetailInfo.cs | 9 + .../ProblemDetailsPrototype.cs | 32 ++++ .../ProblemDetailsPrototypeExtensions.cs | 96 +++++++++++ .../DependencyInjection/ErrorOrOptions.cs | 19 +-- .../ServiceCollectionExtensions.cs | 2 - .../ErrorToActionResultExtensions.cs | 77 +++++++++ .../Extensions/ErrorToResultExtensions.cs | 36 ++++ .../Extensions/ToActionResult.cs | 51 ------ .../Extensions/ToHttpStatusCode.cs | 24 --- .../Extensions/ToProblemDetails.cs | 55 ------ src/ErrorOr.AspNetCore/Extensions/ToResult.cs | 32 ---- src/ErrorOr.AspNetCore/Extensions/ToTitle.cs | 19 --- src/ErrorOr/ErrorOr.csproj | 1 + src/ErrorOr/Errors/Error.cs | 19 +-- src/ErrorOr/Errors/ErrorType.cs | 3 + .../JsonSerialization/ErrorConverter.cs | 161 ++++++++++++++++++ .../Extensions/ErrorOrToActionResultTests.cs | 37 ---- .../ErrorOr.AspNetCore/Utils/TestConstants.cs | 6 - .../Utils/ValidationActionResultExtensions.cs | 20 --- .../ErrorOr.IntegrationTests.csproj | 11 +- .../ErrorProblemDetails.cs | 40 +++++ .../MinimalApi/MinimalApiFixture.cs | 61 +++++++ .../MinimalApi/MinimalApiTests.cs | 61 +++++++ .../ProblemDetails.cs | 10 ++ .../ValidationProblemDetails.cs | 68 ++++++++ .../Extensions/ErrorOrToActionResultTests.cs | 37 ---- .../ErrorOr.AspNetCore/Utils/TestConstants.cs | 6 - .../Utils/ValidationActionResultExtensions.cs | 20 --- 32 files changed, 862 insertions(+), 335 deletions(-) create mode 100644 src/ErrorOr.AspNetCore/DefaultConversion/ErrorDefaults.cs create mode 100644 src/ErrorOr.AspNetCore/DefaultConversion/ErrorExtensions.cs create mode 100644 src/ErrorOr.AspNetCore/DefaultConversion/ErrorProblemDetails.cs create mode 100644 src/ErrorOr.AspNetCore/DefaultConversion/ModelStateDictionaryExtensions.cs create mode 100644 src/ErrorOr.AspNetCore/DefaultConversion/ProblemDetailInfo.cs create mode 100644 src/ErrorOr.AspNetCore/DefaultConversion/ProblemDetailsPrototype.cs create mode 100644 src/ErrorOr.AspNetCore/DefaultConversion/ProblemDetailsPrototypeExtensions.cs create mode 100644 src/ErrorOr.AspNetCore/Extensions/ErrorToActionResultExtensions.cs create mode 100644 src/ErrorOr.AspNetCore/Extensions/ErrorToResultExtensions.cs delete mode 100644 src/ErrorOr.AspNetCore/Extensions/ToActionResult.cs delete mode 100644 src/ErrorOr.AspNetCore/Extensions/ToHttpStatusCode.cs delete mode 100644 src/ErrorOr.AspNetCore/Extensions/ToProblemDetails.cs delete mode 100644 src/ErrorOr.AspNetCore/Extensions/ToResult.cs delete mode 100644 src/ErrorOr.AspNetCore/Extensions/ToTitle.cs create mode 100644 src/ErrorOr/JsonSerialization/ErrorConverter.cs delete mode 100644 tests/ErrorOr.IntegrationTests/ErrorOr.AspNetCore/Extensions/ErrorOrToActionResultTests.cs delete mode 100644 tests/ErrorOr.IntegrationTests/ErrorOr.AspNetCore/Utils/TestConstants.cs delete mode 100644 tests/ErrorOr.IntegrationTests/ErrorOr.AspNetCore/Utils/ValidationActionResultExtensions.cs create mode 100644 tests/ErrorOr.IntegrationTests/ErrorProblemDetails.cs create mode 100644 tests/ErrorOr.IntegrationTests/MinimalApi/MinimalApiFixture.cs create mode 100644 tests/ErrorOr.IntegrationTests/MinimalApi/MinimalApiTests.cs create mode 100644 tests/ErrorOr.IntegrationTests/ProblemDetails.cs create mode 100644 tests/ErrorOr.IntegrationTests/ValidationProblemDetails.cs delete mode 100644 tests/ErrorOr.UnitTests/ErrorOr.AspNetCore/Extensions/ErrorOrToActionResultTests.cs delete mode 100644 tests/ErrorOr.UnitTests/ErrorOr.AspNetCore/Utils/TestConstants.cs delete mode 100644 tests/ErrorOr.UnitTests/ErrorOr.AspNetCore/Utils/ValidationActionResultExtensions.cs diff --git a/src/ErrorOr.AspNetCore/DefaultConversion/ErrorDefaults.cs b/src/ErrorOr.AspNetCore/DefaultConversion/ErrorDefaults.cs new file mode 100644 index 0000000..c4957b6 --- /dev/null +++ b/src/ErrorOr.AspNetCore/DefaultConversion/ErrorDefaults.cs @@ -0,0 +1,109 @@ +using Microsoft.AspNetCore.Http; + +namespace ErrorOr; + +public static class ErrorDefaults +{ + public static ProblemDetailInfo Validation { get; } = new ( + ErrorType.Validation, + StatusCodes.Status400BadRequest, + "https://tools.ietf.org/html/rfc9110#section-15.5.1", + "Bad Request"); + + public static ProblemDetailInfo Unauthorized { get; } = new ( + ErrorType.Unauthorized, + StatusCodes.Status401Unauthorized, + "https://tools.ietf.org/html/rfc9110#section-15.5.2", + "Unauthorized"); + + public static ProblemDetailInfo Forbidden { get; } = new ( + ErrorType.Forbidden, + StatusCodes.Status403Forbidden, + "https://tools.ietf.org/html/rfc9110#section-15.5.4", + "Forbidden"); + + public static ProblemDetailInfo NotFound { get; } = new ( + ErrorType.NotFound, + StatusCodes.Status404NotFound, + "https://tools.ietf.org/html/rfc9110#section-15.5.5", + "Not Found"); + + public static ProblemDetailInfo Conflict { get; } = new ( + ErrorType.Conflict, + StatusCodes.Status409Conflict, + "https://tools.ietf.org/html/rfc9110#section-15.5.10", + "Conflict"); + + public static ProblemDetailInfo Gone { get; } = new ( + ErrorType.Gone, + StatusCodes.Status410Gone, + "https://tools.ietf.org/html/rfc9110#section-15.5.11", + "Gone"); + + public static ProblemDetailInfo PreconditionFailed { get; } = new ( + ErrorType.PreconditionFailed, + StatusCodes.Status412PreconditionFailed, + "https://tools.ietf.org/html/rfc9110#section-15.5.13", + "Precondition Failed"); + + public static ProblemDetailInfo UnsupportedMediaType { get; } = new ( + ErrorType.UnsupportedMediaType, + StatusCodes.Status415UnsupportedMediaType, + "https://tools.ietf.org/html/rfc9110#section-15.5.16", + "Unsupported Media Type"); + + public static ProblemDetailInfo UnprocessableEntity { get; } = new ( + ErrorType.UnprocessableEntity, + StatusCodes.Status422UnprocessableEntity, + "https://tools.ietf.org/html/rfc4918#section-11.2", + "Unprocessable Entity"); + + public static ProblemDetailInfo UnavailableForLegalReasons { get; } = new ( + ErrorType.UnavailableForLegalReasons, + StatusCodes.Status451UnavailableForLegalReasons, + "https://tools.ietf.org/html/rfc7725#section-3", + "Unavailable for Legal Reasons"); + + public static ProblemDetailInfo Failure { get; } = new ( + ErrorType.Failure, + StatusCodes.Status500InternalServerError, + "https://tools.ietf.org/html/rfc9110#section-15.6.1", + "An error occurred while processing your request."); + + public static ProblemDetailInfo BadGateway { get; } = new ( + ErrorType.BadGateway, + StatusCodes.Status502BadGateway, + "https://tools.ietf.org/html/rfc9110#section-15.6.3", + "Bad Gateway"); + + public static ProblemDetailInfo ServiceUnavailable { get; } = new ( + ErrorType.ServiceUnavailable, + StatusCodes.Status503ServiceUnavailable, + "https://tools.ietf.org/html/rfc9110#section-15.6.4", + "Service Unavailable"); + + public static ProblemDetailInfo GatewayTimeout { get; } = new ( + ErrorType.GatewayTimeout, + StatusCodes.Status504GatewayTimeout, + "https://tools.ietf.org/html/rfc9110#section-15.6.5", + "Gateway Timeout"); + + public static Dictionary DefaultMappings { get; } = + new[] + { + Validation, + Unauthorized, + Forbidden, + NotFound, + Conflict, + Gone, + PreconditionFailed, + UnsupportedMediaType, + UnprocessableEntity, + Failure, + BadGateway, + ServiceUnavailable, + GatewayTimeout, + } + .ToDictionary(i => i.ErrorType); +} diff --git a/src/ErrorOr.AspNetCore/DefaultConversion/ErrorExtensions.cs b/src/ErrorOr.AspNetCore/DefaultConversion/ErrorExtensions.cs new file mode 100644 index 0000000..9f9992f --- /dev/null +++ b/src/ErrorOr.AspNetCore/DefaultConversion/ErrorExtensions.cs @@ -0,0 +1,29 @@ +namespace ErrorOr; + +public static class ErrorExtensions +{ + public static ErrorType GetLeadingErrorType(this List errors, bool firstTypeIsLeadingType = false) + { + ArgumentNullException.ThrowIfNull(errors); + if (errors.Count == 0) + { + throw new ArgumentException("errors must have at least one item", nameof(errors)); + } + + var firstType = errors[0].Type; + if (firstTypeIsLeadingType || errors.Count == 1) + { + return firstType; + } + + for (var i = 1; i < errors.Count; i++) + { + if (firstType != errors[i].Type) + { + return ErrorType.Failure; + } + } + + return firstType; + } +} diff --git a/src/ErrorOr.AspNetCore/DefaultConversion/ErrorProblemDetails.cs b/src/ErrorOr.AspNetCore/DefaultConversion/ErrorProblemDetails.cs new file mode 100644 index 0000000..2dd2aa3 --- /dev/null +++ b/src/ErrorOr.AspNetCore/DefaultConversion/ErrorProblemDetails.cs @@ -0,0 +1,28 @@ +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Mvc; + +namespace ErrorOr; + +public class ErrorProblemDetails : ProblemDetails +{ + /// + /// Initializes a new instance of . + /// + /// The dictionary that contains the errors for the problem details. + public ErrorProblemDetails(List errors) + { + ArgumentNullException.ThrowIfNull(errors); + if (errors.Count is 0) + { + throw new ArgumentException("The errors list must contain at least one error.", nameof(errors)); + } + + Errors = errors; + } + + /// + /// Gets the errors associated with this instance of . + /// + [JsonPropertyName("errors")] + public List Errors { get; } +} diff --git a/src/ErrorOr.AspNetCore/DefaultConversion/ModelStateDictionaryExtensions.cs b/src/ErrorOr.AspNetCore/DefaultConversion/ModelStateDictionaryExtensions.cs new file mode 100644 index 0000000..2fc694b --- /dev/null +++ b/src/ErrorOr.AspNetCore/DefaultConversion/ModelStateDictionaryExtensions.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace ErrorOr; + +public static class ModelStateDictionaryExtensions +{ + public static ModelStateDictionary AddErrors( + this ModelStateDictionary modelStateDictionary, + List errors) + { + foreach (var error in errors) + { + modelStateDictionary.AddModelError(error.Code, error.Description); + } + + return modelStateDictionary; + } +} diff --git a/src/ErrorOr.AspNetCore/DefaultConversion/ProblemDetailInfo.cs b/src/ErrorOr.AspNetCore/DefaultConversion/ProblemDetailInfo.cs new file mode 100644 index 0000000..b3e3d1a --- /dev/null +++ b/src/ErrorOr.AspNetCore/DefaultConversion/ProblemDetailInfo.cs @@ -0,0 +1,9 @@ +namespace ErrorOr; + +public readonly record struct ProblemDetailInfo( + ErrorType ErrorType, + int StatusCode, + string Type, + string Title, + string? Detail = null +); diff --git a/src/ErrorOr.AspNetCore/DefaultConversion/ProblemDetailsPrototype.cs b/src/ErrorOr.AspNetCore/DefaultConversion/ProblemDetailsPrototype.cs new file mode 100644 index 0000000..db849b2 --- /dev/null +++ b/src/ErrorOr.AspNetCore/DefaultConversion/ProblemDetailsPrototype.cs @@ -0,0 +1,32 @@ +namespace ErrorOr; + +public readonly record struct ProblemDetailsPrototype( + List Errors, + ErrorType LeadingErrorType, + int? StatusCode = null, + string? Title = null, + string? Detail = null, + string? Type = null, + string? Instance = null +) +{ + public static ProblemDetailsPrototype CreateDefaultFromErrors( + List errors, + Dictionary? errorDefaults = null, + bool useFirstErrorAsLeadingType = false) + { + errorDefaults ??= ErrorDefaults.DefaultMappings; + var leadingErrorType = errors.GetLeadingErrorType(useFirstErrorAsLeadingType); + if (!errorDefaults.TryGetValue(leadingErrorType, out var problemDetailInfo)) + { + problemDetailInfo = ErrorDefaults.Failure; + } + + return new ProblemDetailsPrototype( + errors, + problemDetailInfo.ErrorType, + problemDetailInfo.StatusCode, + problemDetailInfo.Title, + Type: problemDetailInfo.Type); + } +} diff --git a/src/ErrorOr.AspNetCore/DefaultConversion/ProblemDetailsPrototypeExtensions.cs b/src/ErrorOr.AspNetCore/DefaultConversion/ProblemDetailsPrototypeExtensions.cs new file mode 100644 index 0000000..f2a1602 --- /dev/null +++ b/src/ErrorOr.AspNetCore/DefaultConversion/ProblemDetailsPrototypeExtensions.cs @@ -0,0 +1,96 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace ErrorOr; + +public static class ProblemDetailsPrototypeExtensions +{ + public static ProblemDetails ConvertToProblemDetails( + this ProblemDetailsPrototype prototype, + bool includeErrorMetadata = false) => + prototype.LeadingErrorType == ErrorType.Validation ? + prototype.ConvertToValidationProblemDetails(includeErrorMetadata) : + prototype.ToErrorProblemDetails(); + + public static ProblemDetails ConvertToProblemDetails( + this ProblemDetailsPrototype prototype, + HttpContext httpContext, + ProblemDetailsFactory factory, + bool includeErrorMetadata = false, + ModelStateDictionary? modelStateDictionary = null) + { + ProblemDetails problemDetails; + if (prototype.LeadingErrorType == ErrorType.Validation) + { + modelStateDictionary ??= new ModelStateDictionary(); + modelStateDictionary.AddErrors(prototype.Errors); + problemDetails = factory.CreateValidationProblemDetails( + httpContext, + modelStateDictionary, + prototype.StatusCode, + prototype.Title, + prototype.Type, + prototype.Detail, + prototype.Instance); + } + else + { + problemDetails = factory.CreateProblemDetails( + httpContext, + prototype.StatusCode, + prototype.Title, + prototype.Type, + prototype.Detail, + prototype.Instance); + } + + if (includeErrorMetadata) + { + problemDetails.AddExtensions(prototype.Errors); + } + + return problemDetails; + } + + private static ErrorProblemDetails ToErrorProblemDetails(this ProblemDetailsPrototype prototype) + { + return new ErrorProblemDetails(prototype.Errors) + { + Status = prototype.StatusCode, + Type = prototype.Type, + Title = prototype.Title, + Instance = prototype.Instance, + Detail = prototype.Detail ?? "See the errors property for more information.", + }; + } + + private static ValidationProblemDetails ConvertToValidationProblemDetails(this ProblemDetailsPrototype prototype, bool includeErrorMetadata) + { + var problemDetails = new ValidationProblemDetails + { + Status = prototype.StatusCode, + Detail = prototype.Detail, + Instance = prototype.Instance, + Errors = prototype + .Errors + .GroupBy(error => error.Code) + .ToDictionary( + group => group.Key, + group => group.Select(error => error.Description).ToArray()), + }; + + if (prototype.Title is not null) + { + problemDetails.Title = prototype.Title; + } + + if (includeErrorMetadata) + { + problemDetails.AddExtensions(prototype.Errors); + } + + return problemDetails; + } +} diff --git a/src/ErrorOr.AspNetCore/DependencyInjection/ErrorOrOptions.cs b/src/ErrorOr.AspNetCore/DependencyInjection/ErrorOrOptions.cs index 257421d..b7cea8c 100644 --- a/src/ErrorOr.AspNetCore/DependencyInjection/ErrorOrOptions.cs +++ b/src/ErrorOr.AspNetCore/DependencyInjection/ErrorOrOptions.cs @@ -5,15 +5,14 @@ namespace ErrorOr; public class ErrorOrOptions { - public static readonly ErrorOrOptions Instance = new(); + public static ErrorOrOptions Instance { get; } = new(); - public bool IncludeMetadataInProblemDetails { get; set; } = false; - public List, IResult?>> ErrorListToResultMapper { get; set; } = []; - public List> ErrorToResultMapper { get; set; } = []; - public List, IActionResult?>> ErrorListToActionResultMapper { get; set; } = []; - public List> ErrorToActionResultMapper { get; set; } = []; - public List, ProblemDetails?>> ErrorListToProblemDetailsMapper { get; set; } = []; - public List> ErrorToProblemDetailsMapper { get; set; } = []; - public List> ErrorToStatusCodeMapper { get; set; } = []; - public List> ErrorToTitleMapper { get; set; } = []; + public bool IncludeMetadata { get; set; } + public bool UseProblemDetailsFactoryInMvc { get; set; } + public bool UseFirstErrorAsLeadingType { get; set; } + public Func, IActionResult>? CustomToErrorActionResult { get; set; } + public Func, IResult>? CustomToErrorResult { get; set; } + public Func, ProblemDetailsPrototype>? CustomCreatePrototype { get; set; } + public Dictionary ErrorDefaults { get; set; } = + new (ErrorOr.ErrorDefaults.DefaultMappings); } diff --git a/src/ErrorOr.AspNetCore/DependencyInjection/ServiceCollectionExtensions.cs b/src/ErrorOr.AspNetCore/DependencyInjection/ServiceCollectionExtensions.cs index de49716..3bef2c3 100644 --- a/src/ErrorOr.AspNetCore/DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/ErrorOr.AspNetCore/DependencyInjection/ServiceCollectionExtensions.cs @@ -7,9 +7,7 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddErrorOr(this IServiceCollection services, Action options) { options.Invoke(ErrorOrOptions.Instance); - services.AddSingleton(ErrorOrOptions.Instance); - return services; } } diff --git a/src/ErrorOr.AspNetCore/Extensions/ErrorToActionResultExtensions.cs b/src/ErrorOr.AspNetCore/Extensions/ErrorToActionResultExtensions.cs new file mode 100644 index 0000000..ec02a42 --- /dev/null +++ b/src/ErrorOr.AspNetCore/Extensions/ErrorToActionResultExtensions.cs @@ -0,0 +1,77 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using Microsoft.Extensions.DependencyInjection; + +namespace ErrorOr; + +public static class ErrorToActionResultExtensions +{ + public static IActionResult CreateErrorActionResult( + this ControllerBase controller, + Error error, + ErrorOrOptions? options = null) => + error.ToErrorActionResult(controller.HttpContext, options); + + public static IActionResult CreateErrorActionResult( + this ControllerBase controller, + List errors, + ErrorOrOptions? options = null) => + errors.ToErrorActionResult(controller.HttpContext, options); + + public static IActionResult ToErrorActionResult( + this Error error, + HttpContext? httpContext = null, + ErrorOrOptions? options = null) => + ToErrorActionResult([error], httpContext, options); + + public static IActionResult ToErrorActionResult( + this List errors, + HttpContext? httpContext = null, + ErrorOrOptions? options = null) + { + options ??= ErrorOrOptions.Instance; + if (options.CustomToErrorActionResult is not null) + { + return options.CustomToErrorActionResult(errors); + } + + var prototype = + options.CustomCreatePrototype?.Invoke(errors) ?? + ProblemDetailsPrototype.CreateDefaultFromErrors( + errors, + options.ErrorDefaults, + options.UseFirstErrorAsLeadingType); + + ProblemDetails problemDetails; + if (options.UseProblemDetailsFactoryInMvc && + httpContext?.RequestServices.GetService() is { } factory) + { + problemDetails = prototype.ConvertToProblemDetails( + httpContext, + factory, + options.IncludeMetadata); + } + else + { + problemDetails = prototype.ConvertToProblemDetails(options.IncludeMetadata); + } + + return problemDetails.CreateDefaultErrorActionResult(); + } + + public static IActionResult CreateDefaultErrorActionResult(this ProblemDetails problemDetails) + { + return problemDetails.Status switch + { + StatusCodes.Status400BadRequest => new BadRequestObjectResult(problemDetails), + StatusCodes.Status401Unauthorized => new UnauthorizedObjectResult(problemDetails), + + // TODO: ForbidResult does not support ProblemDetails - should we process it differently? + StatusCodes.Status404NotFound => new NotFoundObjectResult(problemDetails), + StatusCodes.Status409Conflict => new ConflictObjectResult(problemDetails), + StatusCodes.Status422UnprocessableEntity => new UnprocessableEntityObjectResult(problemDetails), + _ => new ObjectResult(problemDetails), + }; + } +} diff --git a/src/ErrorOr.AspNetCore/Extensions/ErrorToResultExtensions.cs b/src/ErrorOr.AspNetCore/Extensions/ErrorToResultExtensions.cs new file mode 100644 index 0000000..cc2380a --- /dev/null +++ b/src/ErrorOr.AspNetCore/Extensions/ErrorToResultExtensions.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace ErrorOr; + +public static class ErrorToResultExtensions +{ + public static IResult ToErrorResult(this Error error, ErrorOrOptions? options = null) => + ToErrorResult([error], options); + + public static IResult ToErrorResult(this List errors, ErrorOrOptions? options = null) + { + options ??= ErrorOrOptions.Instance; + if (options.CustomToErrorResult is not null) + { + return options.CustomToErrorResult(errors); + } + + var prototype = options.CustomCreatePrototype?.Invoke(errors) ?? + ProblemDetailsPrototype.CreateDefaultFromErrors(errors); + var problemDetails = prototype.ConvertToProblemDetails(); + return problemDetails.CreateDefaultErrorResult(); + } + + public static IResult CreateDefaultErrorResult(this ProblemDetails problemDetails) + { + return problemDetails.Status switch + { + // TODO: Unauthorized and forbid do not support problem details, should we process it differently? + StatusCodes.Status404NotFound => TypedResults.NotFound(problemDetails), + StatusCodes.Status409Conflict => TypedResults.Conflict(problemDetails), + StatusCodes.Status422UnprocessableEntity => TypedResults.UnprocessableEntity(problemDetails), + _ => Results.Problem(problemDetails), + }; + } +} diff --git a/src/ErrorOr.AspNetCore/Extensions/ToActionResult.cs b/src/ErrorOr.AspNetCore/Extensions/ToActionResult.cs deleted file mode 100644 index 5d8f082..0000000 --- a/src/ErrorOr.AspNetCore/Extensions/ToActionResult.cs +++ /dev/null @@ -1,51 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Infrastructure; -using Microsoft.Extensions.DependencyInjection; - -namespace ErrorOr; - -public static class ErrorToActionResultExtensions -{ - public static IActionResult ToActionResult(this Error error, HttpContext? httpContext = null) - { - foreach (var mapping in ErrorOrOptions.Instance.ErrorToActionResultMapper) - { - if (mapping(error) is IActionResult actionResult) - { - return actionResult; - } - } - - return ToActionResult([error], httpContext); - } - - public static IActionResult ToActionResult(this List errors, HttpContext? httpContext = null) - { - foreach (var mapping in ErrorOrOptions.Instance.ErrorListToActionResultMapper) - { - if (mapping(errors) is IActionResult actionResult) - { - return actionResult; - } - } - - var problemDetails = errors.ToProblemDetails(); - - if (httpContext?.RequestServices.GetService() is ProblemDetailsFactory factory) - { - problemDetails = factory.CreateProblemDetails( - httpContext, - problemDetails.Status, - problemDetails.Title, - problemDetails.Type, - problemDetails.Detail, - problemDetails.Instance); - } - - return new ObjectResult(problemDetails) - { - StatusCode = problemDetails.Status, - }; - } -} diff --git a/src/ErrorOr.AspNetCore/Extensions/ToHttpStatusCode.cs b/src/ErrorOr.AspNetCore/Extensions/ToHttpStatusCode.cs deleted file mode 100644 index b3c4730..0000000 --- a/src/ErrorOr.AspNetCore/Extensions/ToHttpStatusCode.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace ErrorOr; - -public static class ErrorToHttpStatusCodeExtensions -{ - public static int ToHttpStatsCode(this Error error) - { - return error.Type switch - { - ErrorType.Conflict => StatusCodes.Status409Conflict, - ErrorType.Validation => StatusCodes.Status400BadRequest, - ErrorType.NotFound => StatusCodes.Status404NotFound, - ErrorType.Unauthorized => StatusCodes.Status401Unauthorized, - ErrorType.Forbidden => StatusCodes.Status403Forbidden, - ErrorType.UnsupportedMediaType => StatusCodes.Status415UnsupportedMediaType, - ErrorType.UnavailableForLegalReasons => StatusCodes.Status451UnavailableForLegalReasons, - ErrorType.BadGateway => StatusCodes.Status502BadGateway, - ErrorType.ServiceUnavailable => StatusCodes.Status503ServiceUnavailable, - ErrorType.GatewayTimeout => StatusCodes.Status504GatewayTimeout, - _ => StatusCodes.Status500InternalServerError, - }; - } -} diff --git a/src/ErrorOr.AspNetCore/Extensions/ToProblemDetails.cs b/src/ErrorOr.AspNetCore/Extensions/ToProblemDetails.cs deleted file mode 100644 index 8b2a93c..0000000 --- a/src/ErrorOr.AspNetCore/Extensions/ToProblemDetails.cs +++ /dev/null @@ -1,55 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; - -namespace ErrorOr; - -public static class ErrorToProblemDetailsExtensions -{ - public static ProblemDetails ToProblemDetails(this List errors) - { - foreach (var mapping in ErrorOrOptions.Instance.ErrorListToProblemDetailsMapper) - { - if (mapping(errors) is ProblemDetails problemDetails) - { - return ErrorOrOptions.Instance.IncludeMetadataInProblemDetails - ? problemDetails.AddExtensions(errors) - : problemDetails; - } - } - - return errors switch - { - { Count: 0 } => new ProblemDetails { Status = StatusCodes.Status500InternalServerError, Title = "Something went wrong" }, - var _ when errors.All(error => error.Type == ErrorType.Validation) => errors.ToValidationProblemDetails(), - _ => errors[0].ToProblemDetails(), - }; - } - - public static ProblemDetails ToProblemDetails(this Error error) - { - foreach (var mapping in ErrorOrOptions.Instance.ErrorToProblemDetailsMapper) - { - if (mapping(error) is ProblemDetails problemDetails) - { - return problemDetails; - } - } - - return new ProblemDetails { Status = error.ToHttpStatsCode(), Title = error.ToTitle() }.AddExtensions(error); - } - - public static ProblemDetails ToValidationProblemDetails(this List errors) - { - var problemDetails = new HttpValidationProblemDetails - { - Errors = errors - .GroupBy(error => error.Code) - .ToDictionary( - group => group.Key, - group => group.Select(error => error.Description) - .ToArray()), - }; - - return problemDetails.AddExtensions(errors); - } -} diff --git a/src/ErrorOr.AspNetCore/Extensions/ToResult.cs b/src/ErrorOr.AspNetCore/Extensions/ToResult.cs deleted file mode 100644 index 6df8021..0000000 --- a/src/ErrorOr.AspNetCore/Extensions/ToResult.cs +++ /dev/null @@ -1,32 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace ErrorOr; - -public static class ErrorToResultExtensions -{ - public static IResult ToResult(this Error error) - { - foreach (var mapping in ErrorOrOptions.Instance.ErrorToResultMapper) - { - if (mapping(error) is IResult result) - { - return result; - } - } - - return ToResult([error]); - } - - public static IResult ToResult(this List errors) - { - foreach (var mapping in ErrorOrOptions.Instance.ErrorListToResultMapper) - { - if (mapping(errors) is IResult result) - { - return result; - } - } - - return Results.Problem(errors.ToProblemDetails()); - } -} diff --git a/src/ErrorOr.AspNetCore/Extensions/ToTitle.cs b/src/ErrorOr.AspNetCore/Extensions/ToTitle.cs deleted file mode 100644 index 9cbd87a..0000000 --- a/src/ErrorOr.AspNetCore/Extensions/ToTitle.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Microsoft.AspNetCore.Http; - -namespace ErrorOr; - -public static class ErrorToTitleExtensions -{ - public static string ToTitle(this Error error) - { - foreach (var mapping in ErrorOrOptions.Instance.ErrorToTitleMapper) - { - if (mapping(error) is string title) - { - return title; - } - } - - return error.Description; - } -} diff --git a/src/ErrorOr/ErrorOr.csproj b/src/ErrorOr/ErrorOr.csproj index 8e77704..814d1eb 100644 --- a/src/ErrorOr/ErrorOr.csproj +++ b/src/ErrorOr/ErrorOr.csproj @@ -24,6 +24,7 @@ all runtime; build; native; contentfiles; analyzers + diff --git a/src/ErrorOr/Errors/Error.cs b/src/ErrorOr/Errors/Error.cs index df9e40d..fcccc86 100644 --- a/src/ErrorOr/Errors/Error.cs +++ b/src/ErrorOr/Errors/Error.cs @@ -1,11 +1,15 @@ +using System.Text.Json.Serialization; +using ErrorOr.JsonSerialization; + namespace ErrorOr; /// /// Represents an error. /// +[JsonConverter(typeof(ErrorConverter))] public readonly record struct Error { - private Error(string code, string description, ErrorType type, Dictionary? metadata) + public Error(string code, string description, ErrorType type, Dictionary? metadata) { Code = code; Description = description; @@ -140,23 +144,17 @@ public static Error Custom( public bool Equals(Error other) { if (Type != other.Type || - NumericType != other.NumericType || Code != other.Code || Description != other.Description) { return false; } - if (Metadata is null) - { - return other.Metadata is null; - } - - return other.Metadata is not null && CompareMetadata(Metadata, other.Metadata); + return CompareMetadata(Metadata, other.Metadata); } public override int GetHashCode() => - Metadata is null ? HashCode.Combine(Code, Description, Type, NumericType) : ComposeHashCode(); + Metadata is null ? HashCode.Combine(Code, Description, Type) : ComposeHashCode(); private int ComposeHashCode() { @@ -167,9 +165,8 @@ private int ComposeHashCode() hashCode.Add(Code); hashCode.Add(Description); hashCode.Add(Type); - hashCode.Add(NumericType); - foreach (var keyValuePair in Metadata!) + foreach (var keyValuePair in Metadata) { hashCode.Add(keyValuePair.Key); hashCode.Add(keyValuePair.Value); diff --git a/src/ErrorOr/Errors/ErrorType.cs b/src/ErrorOr/Errors/ErrorType.cs index a522ce8..e17b230 100644 --- a/src/ErrorOr/Errors/ErrorType.cs +++ b/src/ErrorOr/Errors/ErrorType.cs @@ -12,7 +12,10 @@ public enum ErrorType NotFound, Unauthorized, Forbidden, + Gone, + PreconditionFailed, UnsupportedMediaType, + UnprocessableEntity, UnavailableForLegalReasons, BadGateway, ServiceUnavailable, diff --git a/src/ErrorOr/JsonSerialization/ErrorConverter.cs b/src/ErrorOr/JsonSerialization/ErrorConverter.cs new file mode 100644 index 0000000..16ae851 --- /dev/null +++ b/src/ErrorOr/JsonSerialization/ErrorConverter.cs @@ -0,0 +1,161 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ErrorOr.JsonSerialization; + +public sealed class ErrorConverter : JsonConverter +{ + public override Error Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Expected StartObject token"); + } + + var code = string.Empty; + var description = string.Empty; + ErrorType? type = default; + Dictionary? metadata = null; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + if (string.IsNullOrWhiteSpace(code)) + { + throw new JsonException("Expected value for code"); + } + + if (string.IsNullOrWhiteSpace(description)) + { + throw new JsonException("Expected value for code"); + } + + if (type is null) + { + throw new JsonException("Expected value for type"); + } + + return new Error(code!, description!, type.Value, metadata); + } + + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("Expected PropertyName token"); + } + + var propertyName = reader.GetString(); + reader.Read(); + + switch (propertyName) + { + case "code": + code = reader.GetString(); + break; + case "description": + description = reader.GetString(); + break; + case "type": + var typeValue = reader.GetInt32(); + if (typeValue is < (int)ErrorType.Failure or > (int)ErrorType.GatewayTimeout) + { + throw new JsonException("The specified error type is invalid"); + } + + type = (ErrorType)typeValue; + + break; + case "metadata": + metadata = DeserializeMetadata(ref reader); + break; + default: + throw new JsonException($"Unknown property: {propertyName}"); + } + } + + throw new JsonException("Expected EndObject token"); + } + + public override void Write(Utf8JsonWriter writer, Error value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + writer.WriteString("code", value.Code); + writer.WriteString("description", value.Description); + writer.WriteNumber("type", (int)value.Type); + + if (value.Metadata.Count > 0) + { + writer.WritePropertyName("metadata"); + writer.WriteStartObject(); + + foreach (var kvp in value.Metadata) + { + if (kvp.Value is string stringValue) + { + writer.WriteString(kvp.Key, stringValue); + } + else if (kvp.Value is int intValue) + { + writer.WriteNumber(kvp.Key, intValue); + } + else if (kvp.Value is bool boolValue) + { + writer.WriteBoolean(kvp.Key, boolValue); + } + } + + writer.WriteEndObject(); + } + + writer.WriteEndObject(); + } + + private static Dictionary DeserializeMetadata(ref Utf8JsonReader reader) + { + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException("Expected StartObject token"); + } + + Dictionary metadata = new(); + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + return metadata; + } + + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("Expected PropertyName token"); + } + + var propertyName = reader.GetString(); + if (propertyName is null) + { + throw new JsonException("Expected PropertyName token"); + } + + reader.Read(); + + switch (reader.TokenType) + { + case JsonTokenType.String: + metadata.Add(propertyName, reader.GetString()!); + break; + case JsonTokenType.Number: + metadata.Add(propertyName, reader.GetInt32()); + break; + case JsonTokenType.True: + case JsonTokenType.False: + metadata.Add(propertyName, reader.GetBoolean()); + break; + default: + throw new JsonException($"Unknown token type: {reader.TokenType}"); + } + } + + throw new JsonException("Expected EndObject token"); + } +} diff --git a/tests/ErrorOr.IntegrationTests/ErrorOr.AspNetCore/Extensions/ErrorOrToActionResultTests.cs b/tests/ErrorOr.IntegrationTests/ErrorOr.AspNetCore/Extensions/ErrorOrToActionResultTests.cs deleted file mode 100644 index ebc549a..0000000 --- a/tests/ErrorOr.IntegrationTests/ErrorOr.AspNetCore/Extensions/ErrorOrToActionResultTests.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Tests.ErrorOr.AspNetCore.Utils; - -namespace Tests.ErrorOr.AspNetCore.Extensions; - -public class ErrorOrToActionResultExtensions -{ - [Fact] - public void ErrorToProblemResult_WhenNoCustomizations_ShouldUseDefaultProblemDetailsFactory() - { - // Arrange - Error error = Error.NotFound(); - - // Act - var result = error.ToActionResult(); - - // Assert - result.Validate( - expectedStatusCode: StatusCodes.Status404NotFound, - error.Description); - } - - [Fact] - public void ListOfErrorsToProblemResult_WhenNoCustomizations_ShouldUseDefaultProblemDetailsFactory() - { - // Arrange - List errors = [Error.Validation()]; - - // Act - var result = errors.ToActionResult(); - - // Assert - result.Validate( - expectedStatusCode: StatusCodes.Status400BadRequest, - expectedTitle: TestConstants.DefaultValidationErrorTitle); - } -} diff --git a/tests/ErrorOr.IntegrationTests/ErrorOr.AspNetCore/Utils/TestConstants.cs b/tests/ErrorOr.IntegrationTests/ErrorOr.AspNetCore/Utils/TestConstants.cs deleted file mode 100644 index 5f0dde3..0000000 --- a/tests/ErrorOr.IntegrationTests/ErrorOr.AspNetCore/Utils/TestConstants.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Tests.ErrorOr.AspNetCore.Utils; - -public static class TestConstants -{ - public const string DefaultValidationErrorTitle = "One or more validation errors occurred."; -} diff --git a/tests/ErrorOr.IntegrationTests/ErrorOr.AspNetCore/Utils/ValidationActionResultExtensions.cs b/tests/ErrorOr.IntegrationTests/ErrorOr.AspNetCore/Utils/ValidationActionResultExtensions.cs deleted file mode 100644 index a3dc48f..0000000 --- a/tests/ErrorOr.IntegrationTests/ErrorOr.AspNetCore/Utils/ValidationActionResultExtensions.cs +++ /dev/null @@ -1,20 +0,0 @@ -using FluentAssertions; -using Microsoft.AspNetCore.Mvc; - -namespace Tests.ErrorOr.AspNetCore.Utils; - -public static class ValidationActionResultExtensions -{ - public static void Validate(this IActionResult actionResult, int expectedStatusCode, string expectedTitle) - { - var objectResult = actionResult.Should().BeOfType().Subject; - - objectResult.Should().NotBeNull(); - objectResult.StatusCode.Should().Be(expectedStatusCode); - - var problemDetails = objectResult.Value.Should().BeAssignableTo().Subject; - - problemDetails.Status.Should().Be(expectedStatusCode); - problemDetails.Title.Should().Be(expectedTitle); - } -} diff --git a/tests/ErrorOr.IntegrationTests/ErrorOr.IntegrationTests.csproj b/tests/ErrorOr.IntegrationTests/ErrorOr.IntegrationTests.csproj index 6089113..c8c439e 100644 --- a/tests/ErrorOr.IntegrationTests/ErrorOr.IntegrationTests.csproj +++ b/tests/ErrorOr.IntegrationTests/ErrorOr.IntegrationTests.csproj @@ -11,14 +11,15 @@ - - - - + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/tests/ErrorOr.IntegrationTests/ErrorProblemDetails.cs b/tests/ErrorOr.IntegrationTests/ErrorProblemDetails.cs new file mode 100644 index 0000000..4b9d221 --- /dev/null +++ b/tests/ErrorOr.IntegrationTests/ErrorProblemDetails.cs @@ -0,0 +1,40 @@ +namespace ErrorOr.IntegrationTests; + +public sealed record ErrorProblemDetails : ProblemDetails +{ + public List? Errors { get; init; } + + public bool Equals(ErrorProblemDetails? other) + { + if (!base.Equals(other)) + { + return false; + } + + if (Errors is null) + { + return other.Errors is null; + } + + return other.Errors is not null && Errors.SequenceEqual(other.Errors); + } + + public override int GetHashCode() + { + if (Errors is null) + { + return base.GetHashCode(); + } + +#pragma warning disable SA1129 + var hashCodeBuilder = new HashCode(); +#pragma warning restore SA1129 + hashCodeBuilder.Add(base.GetHashCode()); + foreach (var error in Errors) + { + hashCodeBuilder.Add(error); + } + + return hashCodeBuilder.ToHashCode(); + } +} diff --git a/tests/ErrorOr.IntegrationTests/MinimalApi/MinimalApiFixture.cs b/tests/ErrorOr.IntegrationTests/MinimalApi/MinimalApiFixture.cs new file mode 100644 index 0000000..f538d9b --- /dev/null +++ b/tests/ErrorOr.IntegrationTests/MinimalApi/MinimalApiFixture.cs @@ -0,0 +1,61 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; + +namespace ErrorOr.IntegrationTests.MinimalApi; + +public sealed class MinimalApiFixture : IAsyncLifetime +{ + private readonly WebApplication _app; + + public MinimalApiFixture() + { + const string url = "http://localhost:5071"; + var builder = WebApplication.CreateBuilder(); +#pragma warning disable ASP0013 // we cannot simply call Configuration.Clear() because it is not available + builder.Host.ConfigureAppConfiguration((_, configurationBuilder) => configurationBuilder.Sources.Clear()); +#pragma warning restore ASP0013 + _app = builder.Build(); + _app.Urls.Add(url); + _app.MapGet( + "/api/failure", + () => + { + ErrorOr errorOr = Error.Failure( + "SomeError", + "Some error occurred", + new Dictionary { ["key"] = "value" }); + + return errorOr.Match( + onValue: _ => TypedResults.Ok(), + onError: errors => errors.ToErrorResult()); + }); + _app.MapGet( + "/api/validation", + () => + { + var validationErrors = new List + { + Error.Validation("email", "Email is required"), + Error.Validation( + "password", + "Password needs to have at least 12 characters - use a password manager"), + }; + + return validationErrors.ToErrorResult(); + }); + + HttpClient = new HttpClient(); + HttpClient.BaseAddress = new Uri(url); + } + + public HttpClient HttpClient { get; } + + public Task InitializeAsync() => _app.StartAsync(); + + public async Task DisposeAsync() + { + HttpClient.Dispose(); + await _app.StopAsync(); + await _app.DisposeAsync(); + } +} diff --git a/tests/ErrorOr.IntegrationTests/MinimalApi/MinimalApiTests.cs b/tests/ErrorOr.IntegrationTests/MinimalApi/MinimalApiTests.cs new file mode 100644 index 0000000..3ceb164 --- /dev/null +++ b/tests/ErrorOr.IntegrationTests/MinimalApi/MinimalApiTests.cs @@ -0,0 +1,61 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using Microsoft.AspNetCore.Http; + +namespace ErrorOr.IntegrationTests.MinimalApi; + +public sealed class MinimalApiTests : IClassFixture +{ + private readonly MinimalApiFixture _apiFixture; + + public MinimalApiTests(MinimalApiFixture apiFixture) + { + _apiFixture = apiFixture; + } + + [Fact] + public async Task FailureEndpoint_WhenCalled_ShouldReturnProblemDetails() + { + using var response = await _apiFixture.HttpClient.GetAsync("/api/failure"); + + response.StatusCode.Should().Be(HttpStatusCode.InternalServerError); + var errorProblemDetails = await response.Content.ReadFromJsonAsync(); + var expectedProblemDetails = new ErrorProblemDetails + { + Status = StatusCodes.Status500InternalServerError, + Type = "https://tools.ietf.org/html/rfc9110#section-15.6.1", + Title = "An error occurred while processing your request.", + Detail = "See the errors property for more information.", + Errors = [ + new Error( + "SomeError", + "Some error occurred", + ErrorType.Failure, + new Dictionary { ["key"] = "value" }) + ], + }; + errorProblemDetails.Should().Be(expectedProblemDetails); + } + + [Fact] + public async Task ValidationEndpoint_WhenCalled_ShouldReturnValidationProblemDetails() + { + using var response = await _apiFixture.HttpClient.GetAsync("/api/validation"); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + var errorProblemDetails = await response.Content.ReadFromJsonAsync(); + var expectedProblemDetails = new ValidationProblemDetails + { + Status = StatusCodes.Status400BadRequest, + Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1", + Title = "Bad Request", + Errors = new Dictionary + { + ["email"] = ["Email is required"], + ["password"] = ["Password needs to have at least 12 characters - use a password manager"], + }, + }; + errorProblemDetails.Should().Be(expectedProblemDetails); + } +} diff --git a/tests/ErrorOr.IntegrationTests/ProblemDetails.cs b/tests/ErrorOr.IntegrationTests/ProblemDetails.cs new file mode 100644 index 0000000..eae06f4 --- /dev/null +++ b/tests/ErrorOr.IntegrationTests/ProblemDetails.cs @@ -0,0 +1,10 @@ +namespace ErrorOr.IntegrationTests; + +public abstract record ProblemDetails +{ + public int? Status { get; init; } + public string? Type { get; init; } + public string? Title { get; init; } + public string? Detail { get; init; } + public string? Instance { get; init; } +} diff --git a/tests/ErrorOr.IntegrationTests/ValidationProblemDetails.cs b/tests/ErrorOr.IntegrationTests/ValidationProblemDetails.cs new file mode 100644 index 0000000..ddc74c4 --- /dev/null +++ b/tests/ErrorOr.IntegrationTests/ValidationProblemDetails.cs @@ -0,0 +1,68 @@ +namespace ErrorOr.IntegrationTests; + +public sealed record ValidationProblemDetails : ProblemDetails +{ + public Dictionary? Errors { get; init; } + + public bool Equals(ValidationProblemDetails? other) + { + if (!base.Equals(other)) + { + return false; + } + + if (Errors is null) + { + return other.Errors is null; + } + + return other.Errors is not null && CompareErrors(Errors, other.Errors); + } + + public override int GetHashCode() + { + if (Errors is null) + { + return base.GetHashCode(); + } + +#pragma warning disable SA1129 + var hashCodeBuilder = new HashCode(); +#pragma warning restore SA1129 + hashCodeBuilder.Add(base.GetHashCode()); + + foreach (var error in Errors) + { + hashCodeBuilder.Add(error.Key); + foreach (var value in error.Value) + { + hashCodeBuilder.Add(value); + } + } + + return hashCodeBuilder.ToHashCode(); + } + + private static bool CompareErrors(Dictionary errors, Dictionary otherErrors) + { + if (errors.Count != otherErrors.Count) + { + return false; + } + + foreach (var (key, value) in errors) + { + if (!otherErrors.TryGetValue(key, out var otherValue)) + { + return false; + } + + if (!value.SequenceEqual(otherValue)) + { + return false; + } + } + + return true; + } +} diff --git a/tests/ErrorOr.UnitTests/ErrorOr.AspNetCore/Extensions/ErrorOrToActionResultTests.cs b/tests/ErrorOr.UnitTests/ErrorOr.AspNetCore/Extensions/ErrorOrToActionResultTests.cs deleted file mode 100644 index ebc549a..0000000 --- a/tests/ErrorOr.UnitTests/ErrorOr.AspNetCore/Extensions/ErrorOrToActionResultTests.cs +++ /dev/null @@ -1,37 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Tests.ErrorOr.AspNetCore.Utils; - -namespace Tests.ErrorOr.AspNetCore.Extensions; - -public class ErrorOrToActionResultExtensions -{ - [Fact] - public void ErrorToProblemResult_WhenNoCustomizations_ShouldUseDefaultProblemDetailsFactory() - { - // Arrange - Error error = Error.NotFound(); - - // Act - var result = error.ToActionResult(); - - // Assert - result.Validate( - expectedStatusCode: StatusCodes.Status404NotFound, - error.Description); - } - - [Fact] - public void ListOfErrorsToProblemResult_WhenNoCustomizations_ShouldUseDefaultProblemDetailsFactory() - { - // Arrange - List errors = [Error.Validation()]; - - // Act - var result = errors.ToActionResult(); - - // Assert - result.Validate( - expectedStatusCode: StatusCodes.Status400BadRequest, - expectedTitle: TestConstants.DefaultValidationErrorTitle); - } -} diff --git a/tests/ErrorOr.UnitTests/ErrorOr.AspNetCore/Utils/TestConstants.cs b/tests/ErrorOr.UnitTests/ErrorOr.AspNetCore/Utils/TestConstants.cs deleted file mode 100644 index 5f0dde3..0000000 --- a/tests/ErrorOr.UnitTests/ErrorOr.AspNetCore/Utils/TestConstants.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Tests.ErrorOr.AspNetCore.Utils; - -public static class TestConstants -{ - public const string DefaultValidationErrorTitle = "One or more validation errors occurred."; -} diff --git a/tests/ErrorOr.UnitTests/ErrorOr.AspNetCore/Utils/ValidationActionResultExtensions.cs b/tests/ErrorOr.UnitTests/ErrorOr.AspNetCore/Utils/ValidationActionResultExtensions.cs deleted file mode 100644 index a3dc48f..0000000 --- a/tests/ErrorOr.UnitTests/ErrorOr.AspNetCore/Utils/ValidationActionResultExtensions.cs +++ /dev/null @@ -1,20 +0,0 @@ -using FluentAssertions; -using Microsoft.AspNetCore.Mvc; - -namespace Tests.ErrorOr.AspNetCore.Utils; - -public static class ValidationActionResultExtensions -{ - public static void Validate(this IActionResult actionResult, int expectedStatusCode, string expectedTitle) - { - var objectResult = actionResult.Should().BeOfType().Subject; - - objectResult.Should().NotBeNull(); - objectResult.StatusCode.Should().Be(expectedStatusCode); - - var problemDetails = objectResult.Value.Should().BeAssignableTo().Subject; - - problemDetails.Status.Should().Be(expectedStatusCode); - problemDetails.Title.Should().Be(expectedTitle); - } -} From 9c19b6a1a69ba72dc183e5ccd1b90d5c68faa860 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Tue, 11 Jun 2024 08:59:02 +0200 Subject: [PATCH 3/3] fix: started tests for MVC, Type is now set when creating ValidationProblemDetails Signed-off-by: Kenny Pflug --- .../ProblemDetailsPrototypeExtensions.cs | 1 + .../ErrorOr.IntegrationTests/BaseApiTests.cs | 62 +++++++++++++++++++ tests/ErrorOr.IntegrationTests/IApiFixture.cs | 7 +++ .../MinimalApi/MinimalApiFixture.cs | 2 +- .../MinimalApi/MinimalApiTests.cs | 59 ++---------------- .../Mvc/MvcApiFixture.cs | 32 ++++++++++ .../Mvc/MvcApiTests.cs | 10 +++ .../Mvc/SomeController.cs | 34 ++++++++++ 8 files changed, 151 insertions(+), 56 deletions(-) create mode 100644 tests/ErrorOr.IntegrationTests/BaseApiTests.cs create mode 100644 tests/ErrorOr.IntegrationTests/IApiFixture.cs create mode 100644 tests/ErrorOr.IntegrationTests/Mvc/MvcApiFixture.cs create mode 100644 tests/ErrorOr.IntegrationTests/Mvc/MvcApiTests.cs create mode 100644 tests/ErrorOr.IntegrationTests/Mvc/SomeController.cs diff --git a/src/ErrorOr.AspNetCore/DefaultConversion/ProblemDetailsPrototypeExtensions.cs b/src/ErrorOr.AspNetCore/DefaultConversion/ProblemDetailsPrototypeExtensions.cs index f2a1602..41e12f6 100644 --- a/src/ErrorOr.AspNetCore/DefaultConversion/ProblemDetailsPrototypeExtensions.cs +++ b/src/ErrorOr.AspNetCore/DefaultConversion/ProblemDetailsPrototypeExtensions.cs @@ -70,6 +70,7 @@ private static ValidationProblemDetails ConvertToValidationProblemDetails(this P { var problemDetails = new ValidationProblemDetails { + Type = prototype.Type, Status = prototype.StatusCode, Detail = prototype.Detail, Instance = prototype.Instance, diff --git a/tests/ErrorOr.IntegrationTests/BaseApiTests.cs b/tests/ErrorOr.IntegrationTests/BaseApiTests.cs new file mode 100644 index 0000000..2cca842 --- /dev/null +++ b/tests/ErrorOr.IntegrationTests/BaseApiTests.cs @@ -0,0 +1,62 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using Microsoft.AspNetCore.Http; + +namespace ErrorOr.IntegrationTests; + +public abstract class BaseApiTests + where TFixture : class, IApiFixture +{ + private readonly TFixture _apiFixture; + + protected BaseApiTests(TFixture apiFixture) + { + _apiFixture = apiFixture; + } + + [Fact] + public async Task FailureEndpoint_WhenCalled_ShouldReturnProblemDetails() + { + using var response = await _apiFixture.HttpClient.GetAsync("/api/failure"); + + response.StatusCode.Should().Be(HttpStatusCode.InternalServerError); + var errorProblemDetails = await response.Content.ReadFromJsonAsync(); + var expectedProblemDetails = new ErrorProblemDetails + { + Status = StatusCodes.Status500InternalServerError, + Type = "https://tools.ietf.org/html/rfc9110#section-15.6.1", + Title = "An error occurred while processing your request.", + Detail = "See the errors property for more information.", + Errors = [ + new Error( + "SomeError", + "Some error occurred", + ErrorType.Failure, + new Dictionary { ["key"] = "value" }) + ], + }; + errorProblemDetails.Should().Be(expectedProblemDetails); + } + + [Fact] + public async Task ValidationEndpoint_WhenCalled_ShouldReturnValidationProblemDetails() + { + using var response = await _apiFixture.HttpClient.GetAsync("/api/validation"); + + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + var errorProblemDetails = await response.Content.ReadFromJsonAsync(); + var expectedProblemDetails = new ValidationProblemDetails + { + Status = StatusCodes.Status400BadRequest, + Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1", + Title = "Bad Request", + Errors = new Dictionary + { + ["email"] = ["Email is required"], + ["password"] = ["Password needs to have at least 12 characters - use a password manager"], + }, + }; + errorProblemDetails.Should().Be(expectedProblemDetails); + } +} diff --git a/tests/ErrorOr.IntegrationTests/IApiFixture.cs b/tests/ErrorOr.IntegrationTests/IApiFixture.cs new file mode 100644 index 0000000..c37d9c7 --- /dev/null +++ b/tests/ErrorOr.IntegrationTests/IApiFixture.cs @@ -0,0 +1,7 @@ +namespace ErrorOr.IntegrationTests; + +public interface IApiFixture + where T : IApiFixture +{ + HttpClient HttpClient { get; } +} diff --git a/tests/ErrorOr.IntegrationTests/MinimalApi/MinimalApiFixture.cs b/tests/ErrorOr.IntegrationTests/MinimalApi/MinimalApiFixture.cs index f538d9b..6ff1715 100644 --- a/tests/ErrorOr.IntegrationTests/MinimalApi/MinimalApiFixture.cs +++ b/tests/ErrorOr.IntegrationTests/MinimalApi/MinimalApiFixture.cs @@ -3,7 +3,7 @@ namespace ErrorOr.IntegrationTests.MinimalApi; -public sealed class MinimalApiFixture : IAsyncLifetime +public sealed class MinimalApiFixture : IApiFixture, IAsyncLifetime { private readonly WebApplication _app; diff --git a/tests/ErrorOr.IntegrationTests/MinimalApi/MinimalApiTests.cs b/tests/ErrorOr.IntegrationTests/MinimalApi/MinimalApiTests.cs index 3ceb164..cd8da4b 100644 --- a/tests/ErrorOr.IntegrationTests/MinimalApi/MinimalApiTests.cs +++ b/tests/ErrorOr.IntegrationTests/MinimalApi/MinimalApiTests.cs @@ -1,61 +1,10 @@ -using System.Net; -using System.Net.Http.Json; -using FluentAssertions; -using Microsoft.AspNetCore.Http; +namespace ErrorOr.IntegrationTests.MinimalApi; -namespace ErrorOr.IntegrationTests.MinimalApi; - -public sealed class MinimalApiTests : IClassFixture +// ReSharper disable once UnusedType.Global -- test methods are in base class +public sealed class MinimalApiTests : BaseApiTests, IClassFixture { - private readonly MinimalApiFixture _apiFixture; - public MinimalApiTests(MinimalApiFixture apiFixture) + : base(apiFixture) { - _apiFixture = apiFixture; - } - - [Fact] - public async Task FailureEndpoint_WhenCalled_ShouldReturnProblemDetails() - { - using var response = await _apiFixture.HttpClient.GetAsync("/api/failure"); - - response.StatusCode.Should().Be(HttpStatusCode.InternalServerError); - var errorProblemDetails = await response.Content.ReadFromJsonAsync(); - var expectedProblemDetails = new ErrorProblemDetails - { - Status = StatusCodes.Status500InternalServerError, - Type = "https://tools.ietf.org/html/rfc9110#section-15.6.1", - Title = "An error occurred while processing your request.", - Detail = "See the errors property for more information.", - Errors = [ - new Error( - "SomeError", - "Some error occurred", - ErrorType.Failure, - new Dictionary { ["key"] = "value" }) - ], - }; - errorProblemDetails.Should().Be(expectedProblemDetails); - } - - [Fact] - public async Task ValidationEndpoint_WhenCalled_ShouldReturnValidationProblemDetails() - { - using var response = await _apiFixture.HttpClient.GetAsync("/api/validation"); - - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - var errorProblemDetails = await response.Content.ReadFromJsonAsync(); - var expectedProblemDetails = new ValidationProblemDetails - { - Status = StatusCodes.Status400BadRequest, - Type = "https://tools.ietf.org/html/rfc9110#section-15.5.1", - Title = "Bad Request", - Errors = new Dictionary - { - ["email"] = ["Email is required"], - ["password"] = ["Password needs to have at least 12 characters - use a password manager"], - }, - }; - errorProblemDetails.Should().Be(expectedProblemDetails); } } diff --git a/tests/ErrorOr.IntegrationTests/Mvc/MvcApiFixture.cs b/tests/ErrorOr.IntegrationTests/Mvc/MvcApiFixture.cs new file mode 100644 index 0000000..5b26981 --- /dev/null +++ b/tests/ErrorOr.IntegrationTests/Mvc/MvcApiFixture.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace ErrorOr.IntegrationTests.Mvc; + +public sealed class MvcApiFixture : IApiFixture, IAsyncLifetime +{ + private readonly WebApplication _app; + + public MvcApiFixture() + { + const string url = "http://localhost:5072"; + var builder = WebApplication.CreateBuilder(); + builder.Configuration.Sources.Clear(); + builder.Services.AddControllers().AddApplicationPart(typeof(SomeController).Assembly); + _app = builder.Build(); + _app.MapControllers(); + _app.Urls.Add(url); + HttpClient = new HttpClient(); + HttpClient.BaseAddress = new Uri(url); + } + + public HttpClient HttpClient { get; } + public Task InitializeAsync() => _app.StartAsync(); + + public async Task DisposeAsync() + { + HttpClient.Dispose(); + await _app.StopAsync(); + await _app.DisposeAsync(); + } +} diff --git a/tests/ErrorOr.IntegrationTests/Mvc/MvcApiTests.cs b/tests/ErrorOr.IntegrationTests/Mvc/MvcApiTests.cs new file mode 100644 index 0000000..31316f7 --- /dev/null +++ b/tests/ErrorOr.IntegrationTests/Mvc/MvcApiTests.cs @@ -0,0 +1,10 @@ +namespace ErrorOr.IntegrationTests.Mvc; + +// ReSharper disable once UnusedType.Global -- test methods are in base class +public sealed class MvcApiTests : BaseApiTests, IClassFixture +{ + public MvcApiTests(MvcApiFixture apiFixture) + : base(apiFixture) + { + } +} diff --git a/tests/ErrorOr.IntegrationTests/Mvc/SomeController.cs b/tests/ErrorOr.IntegrationTests/Mvc/SomeController.cs new file mode 100644 index 0000000..df83589 --- /dev/null +++ b/tests/ErrorOr.IntegrationTests/Mvc/SomeController.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Mvc; + +namespace ErrorOr.IntegrationTests.Mvc; + +[ApiController] +public sealed class SomeController : ControllerBase +{ + [HttpGet("/api/failure")] + public IActionResult Failure() + { + ErrorOr errorOr = Error.Failure( + "SomeError", + "Some error occurred", + new Dictionary { ["key"] = "value" }); + + return errorOr.Match( + _ => Ok(), + errors => errors.ToErrorActionResult()); + } + + [HttpGet("/api/validation")] + public IActionResult Validation() + { + var validationErrors = new List + { + Error.Validation("email", "Email is required"), + Error.Validation( + "password", + "Password needs to have at least 12 characters - use a password manager"), + }; + + return validationErrors.ToErrorActionResult(); + } +}