From 4088e5ca905a7c8f4faad907fc93b6c60d86de85 Mon Sep 17 00:00:00 2001 From: Kentaro Hakase Date: Sat, 23 May 2026 01:32:10 +0200 Subject: [PATCH 1/2] Add configurable error HTTP mapping --- README.md | 37 +++- Resulta.AspNetCore/AspNetCoreIntegration.cs | 168 +++++++++++------- .../OpenApi/RouteHandlerBuilderExtensions.cs | 8 +- Resulta.AspNetCore/ProblemTypeUris.cs | 9 + Resulta.AspNetCore/Properties/AssemblyInfo.cs | 3 + Resulta.AspNetCore/ResultaOptions.cs | 102 +++++++++++ Resulta.Tests/AspNetCoreIntegrationTests.cs | 39 ++++ Resulta.Tests/AssemblyInfo.cs | 3 + Resulta.Tests/ErrorFactoryTests.cs | 53 ++++++ .../RouteHandlerBuilderExtensionsTests.cs | 11 +- Resulta.Tests/ResultaOptionsTests.cs | 120 +++++++++++++ Resulta/src/Error.cs | 27 ++- 12 files changed, 497 insertions(+), 83 deletions(-) create mode 100644 Resulta.AspNetCore/Properties/AssemblyInfo.cs create mode 100644 Resulta.AspNetCore/ResultaOptions.cs create mode 100644 Resulta.Tests/AssemblyInfo.cs create mode 100644 Resulta.Tests/ErrorFactoryTests.cs create mode 100644 Resulta.Tests/ResultaOptionsTests.cs diff --git a/README.md b/README.md index 5480e80..b4dfb17 100644 --- a/README.md +++ b/README.md @@ -144,8 +144,11 @@ var err = new Error("Not found") var err = Error.NotFound("Product"); var err = Error.Validation("email", "Invalid email address"); var err = Error.Unauthorized(); +var err = Error.Forbidden(); var err = Error.Unexpected(exception); var err = Error.Conflict("Name already taken"); +var err = Error.Unprocessable("Cannot process the submitted state"); +var err = Error.TooManyRequests(); // Error chain var err = Error.NotFound("User") @@ -231,13 +234,16 @@ app.MapGet("/api/users/{id}", (int id, UserService svc) .ProducesResultaErrors(); ``` -| `Error.Code` | HTTP Status | -|--------------------|---------------------------| -| `NOT_FOUND` | 404 Not Found | -| `VALIDATION_ERROR` | 400 Bad Request | -| `UNAUTHORIZED` | 401 Unauthorized | -| `CONFLICT` | 409 Conflict | -| _(anything else)_ | 500 Internal Server Error | +| `Error.Code` | HTTP Status | +|---------------------|---------------------------| +| `NOT_FOUND` | 404 Not Found | +| `VALIDATION_ERROR` | 400 Bad Request | +| `UNAUTHORIZED` | 401 Unauthorized | +| `FORBIDDEN` | 403 Forbidden | +| `CONFLICT` | 409 Conflict | +| `UNPROCESSABLE` | 422 Unprocessable Entity | +| `TOO_MANY_REQUESTS` | 429 Too Many Requests | +| _(anything else)_ | 500 Internal Server Error | All failure responses use the RFC 7807 `application/problem+json` format. Validation errors return `HttpValidationProblemDetails` with an `errors` dictionary keyed by field name. Other codes return `ProblemDetails`. The original `Error.Code` is preserved on every response as the `code` extension property: @@ -252,7 +258,22 @@ All failure responses use the RFC 7807 `application/problem+json` format. Valida } ``` -For type-safe Minimal API endpoints with full OpenAPI metadata, use `ToTypedResult()` together with `ProducesResultaErrors()` — the endpoint then advertises every possible response shape (200/204, 404, 400 validation, 409, and `ProblemHttpResult` for 401/500). +For type-safe Minimal API endpoints with full OpenAPI metadata, use `ToTypedResult()` together with `ProducesResultaErrors()` — the endpoint then advertises every possible response shape (200/204, 404, 400 validation, 409, and `ProblemHttpResult` for other error responses). + +### Extending the error map + +```csharp +builder.Services.AddResulta(options => +{ + options.MapError("RATE_LIMITED", StatusCodes.Status429TooManyRequests, + "Rate Limited", "https://example.com/problems/rate-limited"); + + options.ConfigureProblemDetails = (problem, error, http) => + { + problem.Extensions["traceId"] = http?.TraceIdentifier; + }; +}); +``` ### JSON converters for `Result` / `Error` diff --git a/Resulta.AspNetCore/AspNetCoreIntegration.cs b/Resulta.AspNetCore/AspNetCoreIntegration.cs index e37f007..fa554e1 100644 --- a/Resulta.AspNetCore/AspNetCoreIntegration.cs +++ b/Resulta.AspNetCore/AspNetCoreIntegration.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Net; using Microsoft.AspNetCore.Builder; @@ -13,59 +12,53 @@ namespace Resulta.AspNetCore { /// - /// Builds RFC 7807 (or ) - /// responses from a Resulta , using the error's to - /// pick the HTTP status, title, and type URI. + /// Maps a Resulta to an RFC 7807 response. /// - /// - /// The mapping is: - /// - /// NOT_FOUND404 Not Found - /// VALIDATION_ERROR400 Bad Request with field/message in - /// UNAUTHORIZED401 Unauthorized - /// CONFLICT409 Conflict - /// Any other code → 500 Internal Server Error - /// - /// The original error code is also attached as the code extension property - /// (), so machine-readable callers can branch on it. - /// - public static class ResultProblemDetailsFactory + internal interface IResultaErrorMapper { - /// JSON extension key under which the original is exposed on the problem object. - public const string CodeExtensionKey = "code"; - - private const string ValidationFieldMetadataKey = "field"; - /// - /// Constructs a (or for - /// validation errors) from the given . + /// Creates a response from the given . /// /// The error to map. - /// Optional HTTP context; when supplied, is set to the request path. - /// - /// Errors whose is unknown (anything outside the five recognized codes) - /// are flattened to a generic 500 Internal Server Error with code INTERNAL_ERROR and - /// the detail "An internal error occurred.", so that internal error codes or exception - /// messages do not leak to clients. - /// - public static ProblemDetails Create(Error error, HttpContext? context = null) + /// Optional HTTP context used to populate . + ProblemDetails Create(Error error, HttpContext? context); + } + + internal sealed class ResultaErrorMapper : IResultaErrorMapper + { + private const string ValidationFieldMetadataKey = "field"; + + private readonly ResultaOptions _options; + + public ResultaErrorMapper(ResultaOptions options) + { + ArgumentNullException.ThrowIfNull(options); + _options = options; + } + + public ProblemDetails Create(Error error, HttpContext? context) { ArgumentNullException.ThrowIfNull(error); - if (error.Code is null || !IsKnownCode(error.Code)) - return BuildGenericInternalError(context); + if (error.Code is null || !_options.ErrorMap.TryGetValue(error.Code, out var mapping)) + { + var generic = BuildGenericInternalError(context); + _options.ConfigureProblemDetails?.Invoke(generic, error, context); + return generic; + } var problem = error.Code == "VALIDATION_ERROR" ? BuildValidationProblem(error) : new ProblemDetails(); - problem.Status = StatusFor(error.Code); - problem.Title = TitleFor(error.Code); - problem.Type = TypeFor(error.Code); + problem.Status = mapping.StatusCode; + problem.Title = mapping.Title; + problem.Type = mapping.TypeUri; problem.Detail = error.Message; problem.Instance = context?.Request.Path.Value; - problem.Extensions[CodeExtensionKey] = error.Code; + problem.Extensions[ResultProblemDetailsFactory.CodeExtensionKey] = error.Code; + _options.ConfigureProblemDetails?.Invoke(problem, error, context); return problem; } @@ -79,13 +72,10 @@ private static ProblemDetails BuildGenericInternalError(HttpContext? context) Detail = "An internal error occurred.", Instance = context?.Request.Path.Value }; - generic.Extensions[CodeExtensionKey] = "INTERNAL_ERROR"; + generic.Extensions[ResultProblemDetailsFactory.CodeExtensionKey] = "INTERNAL_ERROR"; return generic; } - private static bool IsKnownCode(string code) => code is - "NOT_FOUND" or "VALIDATION_ERROR" or "UNAUTHORIZED" or "CONFLICT"; - private static HttpValidationProblemDetails BuildValidationProblem(Error error) { var validation = new HttpValidationProblemDetails(); @@ -93,33 +83,49 @@ private static HttpValidationProblemDetails BuildValidationProblem(Error error) validation.Errors[field] = new[] { error.Message }; return validation; } + } - internal static int StatusFor(string? code) => code switch - { - "NOT_FOUND" => StatusCodes.Status404NotFound, - "VALIDATION_ERROR" => StatusCodes.Status400BadRequest, - "UNAUTHORIZED" => StatusCodes.Status401Unauthorized, - "CONFLICT" => StatusCodes.Status409Conflict, - _ => StatusCodes.Status500InternalServerError - }; - - private static string TitleFor(string? code) => code switch - { - "NOT_FOUND" => "Not Found", - "VALIDATION_ERROR" => "Validation Error", - "UNAUTHORIZED" => "Unauthorized", - "CONFLICT" => "Conflict", - _ => "Internal Server Error" - }; - - private static string TypeFor(string? code) => code switch - { - "NOT_FOUND" => ProblemTypeUris.NotFound, - "VALIDATION_ERROR" => ProblemTypeUris.BadRequest, - "UNAUTHORIZED" => ProblemTypeUris.Unauthorized, - "CONFLICT" => ProblemTypeUris.Conflict, - _ => ProblemTypeUris.InternalServerError - }; + /// + /// Builds RFC 7807 (or ) + /// responses from a Resulta , using the error's to + /// pick the HTTP status, title, and type URI. + /// + /// + /// The default mapping is: + /// + /// NOT_FOUND maps to 404 Not Found. + /// VALIDATION_ERROR maps to 400 Bad Request with field/message in . + /// UNAUTHORIZED maps to 401 Unauthorized. + /// FORBIDDEN maps to 403 Forbidden. + /// CONFLICT maps to 409 Conflict. + /// UNPROCESSABLE maps to 422 Unprocessable Entity. + /// TOO_MANY_REQUESTS maps to 429 Too Many Requests. + /// Any other code maps to 500 Internal Server Error. + /// + /// The original error code is also attached as the code extension property + /// (), so machine-readable callers can branch on it. + /// + public static class ResultProblemDetailsFactory + { + /// JSON extension key under which the original is exposed on the problem object. + public const string CodeExtensionKey = "code"; + + internal static IResultaErrorMapper Current { get; set; } = new ResultaErrorMapper(new ResultaOptions()); + + /// + /// Constructs a (or for + /// validation errors) from the given . + /// + /// The error to map. + /// Optional HTTP context; when supplied, is set to the request path. + /// + /// Errors whose is unknown are flattened to a generic + /// 500 Internal Server Error with code INTERNAL_ERROR and the detail + /// "An internal error occurred.", so that internal error codes or exception + /// messages do not leak to clients. + /// + public static ProblemDetails Create(Error error, HttpContext? context = null) + => Current.Create(error, context); } /// @@ -259,7 +265,7 @@ private static IResult ProblemResultFor(Error err) /// Use these instead of when you /// want endpoint metadata (and therefore generated client SDKs) to reflect the full response surface /// of a Resulta endpoint: 200/204 success, 404, 400 validation, 409, and a generic - /// for 401 and 500. + /// for other error responses. /// public static class TypedMinimalApiExtensions { @@ -268,6 +274,9 @@ public static class TypedMinimalApiExtensions /// /// The success value type. /// The result to convert. + /// + /// Codes outside Ok/NotFound/BadRequest/Conflict are returned via ProblemHttpResult so the HTTP status and body are correct, but OpenAPI will only document NotFound/BadRequest/Conflict as discrete shapes. + /// public static Results, NotFound, BadRequest, Conflict, ProblemHttpResult> ToTypedResult(this Result result) { ArgumentNullException.ThrowIfNull(result); @@ -284,6 +293,9 @@ public static Results, NotFound, BadRequest to a typed Minimal API result union. /// /// The result to convert. + /// + /// Codes outside Ok/NotFound/BadRequest/Conflict are returned via ProblemHttpResult so the HTTP status and body are correct, but OpenAPI will only document NotFound/BadRequest/Conflict as discrete shapes. + /// public static Results, BadRequest, Conflict, ProblemHttpResult> ToTypedResult(this Result result) { ArgumentNullException.ThrowIfNull(result); @@ -308,8 +320,26 @@ public static class ServiceCollectionExtensions /// /// The service collection to register into. public static IServiceCollection AddResulta(this IServiceCollection services) + => AddResulta(services, configure: null); + + /// + /// Registers Resulta services with the dependency injection container and configures + /// the error-to-HTTP mapping used by Resulta. + /// + /// The service collection to register into. + /// An optional callback used to customize Resulta options. + public static IServiceCollection AddResulta(this IServiceCollection services, Action? configure) { ArgumentNullException.ThrowIfNull(services); + + var options = new ResultaOptions(); + configure?.Invoke(options); + + var mapper = new ResultaErrorMapper(options); + services.AddSingleton(mapper); + services.AddSingleton(mapper); + ResultProblemDetailsFactory.Current = mapper; + return services; } diff --git a/Resulta.AspNetCore/OpenApi/RouteHandlerBuilderExtensions.cs b/Resulta.AspNetCore/OpenApi/RouteHandlerBuilderExtensions.cs index 1da7de9..c50ffc0 100644 --- a/Resulta.AspNetCore/OpenApi/RouteHandlerBuilderExtensions.cs +++ b/Resulta.AspNetCore/OpenApi/RouteHandlerBuilderExtensions.cs @@ -9,21 +9,25 @@ namespace Resulta.AspNetCore.OpenApi { /// /// Extension methods to register the standard Resulta error responses - /// (404, 400 validation, 401, 409, 500) on a Minimal API endpoint + /// (400 validation, 401, 403, 404, 409, 422, 429, 500) on a Minimal API endpoint /// in one call, so OpenAPI / Swagger documentation is complete. /// public static class RouteHandlerBuilderExtensions { /// /// Default set of HTTP status codes that Resulta endpoints return on failure: - /// 400 (validation), 401 (unauthorized), 404 (not found), 409 (conflict), and 500 (internal error). + /// 400 (validation), 401 (unauthorized), 403 (forbidden), 404 (not found), + /// 409 (conflict), 422 (unprocessable entity), 429 (too many requests), and 500 (internal error). /// public static readonly IReadOnlyList DefaultStatusCodes = new[] { StatusCodes.Status400BadRequest, StatusCodes.Status401Unauthorized, + StatusCodes.Status403Forbidden, StatusCodes.Status404NotFound, StatusCodes.Status409Conflict, + StatusCodes.Status422UnprocessableEntity, + StatusCodes.Status429TooManyRequests, StatusCodes.Status500InternalServerError }; diff --git a/Resulta.AspNetCore/ProblemTypeUris.cs b/Resulta.AspNetCore/ProblemTypeUris.cs index e6f0108..ccd89c0 100644 --- a/Resulta.AspNetCore/ProblemTypeUris.cs +++ b/Resulta.AspNetCore/ProblemTypeUris.cs @@ -20,9 +20,18 @@ public static class ProblemTypeUris /// RFC 7235, section 3.1 — 401 Unauthorized. public const string Unauthorized = "https://tools.ietf.org/html/rfc7235#section-3.1"; + /// RFC 7231, section 6.5.3 - 403 Forbidden. + public const string Forbidden = "https://tools.ietf.org/html/rfc7231#section-6.5.3"; + /// RFC 7231, section 6.5.8 — 409 Conflict. public const string Conflict = "https://tools.ietf.org/html/rfc7231#section-6.5.8"; + /// RFC 4918, section 11.2 - 422 Unprocessable Entity. + public const string UnprocessableEntity = "https://tools.ietf.org/html/rfc4918#section-11.2"; + + /// RFC 6585, section 4 - 429 Too Many Requests. + public const string TooManyRequests = "https://tools.ietf.org/html/rfc6585#section-4"; + /// RFC 7231, section 6.6.1 — 500 Internal Server Error. public const string InternalServerError = "https://tools.ietf.org/html/rfc7231#section-6.6.1"; } diff --git a/Resulta.AspNetCore/Properties/AssemblyInfo.cs b/Resulta.AspNetCore/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..1947390 --- /dev/null +++ b/Resulta.AspNetCore/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Resulta.Tests")] diff --git a/Resulta.AspNetCore/ResultaOptions.cs b/Resulta.AspNetCore/ResultaOptions.cs new file mode 100644 index 0000000..c6f0dcf --- /dev/null +++ b/Resulta.AspNetCore/ResultaOptions.cs @@ -0,0 +1,102 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +using Resulta; + +namespace Resulta.AspNetCore +{ + /// + /// Describes how a Resulta maps to an HTTP problem response. + /// + /// The HTTP status code to assign to the problem response. + /// The RFC 7807 problem title to expose to clients. + /// The RFC 7807 problem type URI to expose to clients. + public sealed record ErrorMapping(int StatusCode, string Title, string TypeUri); + + /// + /// Configures how Resulta maps values to ASP.NET Core + /// responses. + /// + /// + /// Customize with + /// or . Error codes that are not present in + /// the map are converted to a generic internal server error response. + /// + public sealed class ResultaOptions + { + /// + /// Gets the configurable mapping from Resulta error codes to HTTP problem details metadata. + /// + public IDictionary ErrorMap { get; } = new Dictionary + { + ["NOT_FOUND"] = new( + StatusCodes.Status404NotFound, + "Not Found", + ProblemTypeUris.NotFound), + ["VALIDATION_ERROR"] = new( + StatusCodes.Status400BadRequest, + "Validation Error", + ProblemTypeUris.BadRequest), + ["UNAUTHORIZED"] = new( + StatusCodes.Status401Unauthorized, + "Unauthorized", + ProblemTypeUris.Unauthorized), + ["FORBIDDEN"] = new( + StatusCodes.Status403Forbidden, + "Forbidden", + ProblemTypeUris.Forbidden), + ["CONFLICT"] = new( + StatusCodes.Status409Conflict, + "Conflict", + ProblemTypeUris.Conflict), + ["UNPROCESSABLE"] = new( + StatusCodes.Status422UnprocessableEntity, + "Unprocessable Entity", + ProblemTypeUris.UnprocessableEntity), + ["TOO_MANY_REQUESTS"] = new( + StatusCodes.Status429TooManyRequests, + "Too Many Requests", + ProblemTypeUris.TooManyRequests) + }; + + /// + /// Gets or sets an optional callback that can customize the generated problem details + /// after Resulta has applied its default mapping. + /// + public Action? ConfigureProblemDetails { get; set; } + + /// + /// Adds or replaces an error-code mapping. + /// + /// The Resulta error code to map. + /// The HTTP status code to assign. + /// The RFC 7807 problem title to expose to clients. + /// The RFC 7807 problem type URI to expose to clients. + /// The current options instance so calls can be chained. + public ResultaOptions MapError(string code, int statusCode, string title, string typeUri) + { + ArgumentNullException.ThrowIfNull(code); + ArgumentNullException.ThrowIfNull(title); + ArgumentNullException.ThrowIfNull(typeUri); + + return MapError(code, new ErrorMapping(statusCode, title, typeUri)); + } + + /// + /// Adds or replaces an error-code mapping. + /// + /// The Resulta error code to map. + /// The mapping to apply for the error code. + /// The current options instance so calls can be chained. + public ResultaOptions MapError(string code, ErrorMapping mapping) + { + ArgumentNullException.ThrowIfNull(code); + ArgumentNullException.ThrowIfNull(mapping); + ArgumentNullException.ThrowIfNull(mapping.Title); + ArgumentNullException.ThrowIfNull(mapping.TypeUri); + + ErrorMap[code] = mapping; + return this; + } + } +} diff --git a/Resulta.Tests/AspNetCoreIntegrationTests.cs b/Resulta.Tests/AspNetCoreIntegrationTests.cs index e023f56..0e9c498 100644 --- a/Resulta.Tests/AspNetCoreIntegrationTests.cs +++ b/Resulta.Tests/AspNetCoreIntegrationTests.cs @@ -122,6 +122,19 @@ public void ToActionResult_Should_Return_401_ProblemDetails_For_UNAUTHORIZED() Assert.Equal("UNAUTHORIZED", CodeExtension(problem)); } + [Fact] + public void ToActionResult_Should_Return_403_ProblemDetails_For_FORBIDDEN() + { + var result = Result.Fail(Error.Forbidden("nope")); + + var problem = ProblemFrom(result.ToActionResult(_controller)); + + Assert.Equal(StatusCodes.Status403Forbidden, problem.Status); + Assert.Equal("Forbidden", problem.Title); + Assert.Equal(ProblemTypeUris.Forbidden, problem.Type); + Assert.Equal("FORBIDDEN", CodeExtension(problem)); + } + [Fact] public void ToActionResult_Should_Return_409_ProblemDetails_For_CONFLICT() { @@ -134,6 +147,32 @@ public void ToActionResult_Should_Return_409_ProblemDetails_For_CONFLICT() Assert.Equal("CONFLICT", CodeExtension(problem)); } + [Fact] + public void ToActionResult_Should_Return_422_ProblemDetails_For_UNPROCESSABLE() + { + var result = Result.Fail(Error.Unprocessable("Cannot apply change")); + + var problem = ProblemFrom(result.ToActionResult(_controller)); + + Assert.Equal(StatusCodes.Status422UnprocessableEntity, problem.Status); + Assert.Equal("Unprocessable Entity", problem.Title); + Assert.Equal(ProblemTypeUris.UnprocessableEntity, problem.Type); + Assert.Equal("UNPROCESSABLE", CodeExtension(problem)); + } + + [Fact] + public void ToActionResult_Should_Return_429_ProblemDetails_For_TOO_MANY_REQUESTS() + { + var result = Result.Fail(Error.TooManyRequests("Try later")); + + var problem = ProblemFrom(result.ToActionResult(_controller)); + + Assert.Equal(StatusCodes.Status429TooManyRequests, problem.Status); + Assert.Equal("Too Many Requests", problem.Title); + Assert.Equal(ProblemTypeUris.TooManyRequests, problem.Type); + Assert.Equal("TOO_MANY_REQUESTS", CodeExtension(problem)); + } + [Fact] public void ToActionResult_Should_Return_500_With_Generic_Code_For_Unknown_Error_Code() { diff --git a/Resulta.Tests/AssemblyInfo.cs b/Resulta.Tests/AssemblyInfo.cs new file mode 100644 index 0000000..2171200 --- /dev/null +++ b/Resulta.Tests/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using Xunit; + +[assembly: CollectionBehavior(DisableTestParallelization = true)] diff --git a/Resulta.Tests/ErrorFactoryTests.cs b/Resulta.Tests/ErrorFactoryTests.cs new file mode 100644 index 0000000..98d01f0 --- /dev/null +++ b/Resulta.Tests/ErrorFactoryTests.cs @@ -0,0 +1,53 @@ +using Resulta; + +using Xunit; + +namespace Resulta.Tests; + +public sealed class ErrorFactoryTests +{ + [Fact] + public void Forbidden_Should_Use_FORBIDDEN_Code() + { + var error = Error.Forbidden("nope"); + + Assert.Equal("FORBIDDEN", error.Code); + Assert.Contains("nope", error.Message); + } + + [Fact] + public void Forbidden_Should_Use_Default_Message_When_Reason_Null() + { + var error = Error.Forbidden(); + + Assert.Equal("FORBIDDEN", error.Code); + Assert.Equal("You are not allowed to perform this action.", error.Message); + } + + [Fact] + public void Unprocessable_Should_Use_UNPROCESSABLE_Code() + { + var error = Error.Unprocessable("bad semantic request"); + + Assert.Equal("UNPROCESSABLE", error.Code); + Assert.Contains("bad semantic request", error.Message); + } + + [Fact] + public void TooManyRequests_Should_Use_TOO_MANY_REQUESTS_Code() + { + var error = Error.TooManyRequests("slow down"); + + Assert.Equal("TOO_MANY_REQUESTS", error.Code); + Assert.Contains("slow down", error.Message); + } + + [Fact] + public void TooManyRequests_Should_Use_Default_Message_When_Reason_Null() + { + var error = Error.TooManyRequests(); + + Assert.Equal("TOO_MANY_REQUESTS", error.Code); + Assert.Equal("Too many requests. Please try again later.", error.Message); + } +} diff --git a/Resulta.Tests/OpenApi/RouteHandlerBuilderExtensionsTests.cs b/Resulta.Tests/OpenApi/RouteHandlerBuilderExtensionsTests.cs index 883e56a..b6400a6 100644 --- a/Resulta.Tests/OpenApi/RouteHandlerBuilderExtensionsTests.cs +++ b/Resulta.Tests/OpenApi/RouteHandlerBuilderExtensionsTests.cs @@ -30,16 +30,20 @@ private static IProducesResponseTypeMetadata[] MetadataFor(Action b.ProducesResultaErrors()); var statusCodes = metadata.Select(m => m.StatusCode).Distinct().ToHashSet(); + Assert.Equal(8, statusCodes.Count); Assert.Contains(StatusCodes.Status400BadRequest, statusCodes); Assert.Contains(StatusCodes.Status401Unauthorized, statusCodes); + Assert.Contains(StatusCodes.Status403Forbidden, statusCodes); Assert.Contains(StatusCodes.Status404NotFound, statusCodes); Assert.Contains(StatusCodes.Status409Conflict, statusCodes); + Assert.Contains(StatusCodes.Status422UnprocessableEntity, statusCodes); + Assert.Contains(StatusCodes.Status429TooManyRequests, statusCodes); Assert.Contains(StatusCodes.Status500InternalServerError, statusCodes); } @@ -57,7 +61,7 @@ public void ProducesResultaErrors_Should_Use_ProblemDetails_For_Non_Validation_C { var metadata = MetadataFor(b => b.ProducesResultaErrors()); - foreach (var code in new[] { 401, 404, 409, 500 }) + foreach (var code in new[] { 401, 403, 404, 409, 422, 429, 500 }) { var entry = metadata.Single(m => m.StatusCode == code); Assert.Equal(typeof(ProblemDetails), entry.Type); @@ -83,6 +87,9 @@ public void ProducesResultaErrors_With_Subset_Should_Register_Only_Selected_Code Assert.Contains(409, statusCodes); Assert.DoesNotContain(400, statusCodes); Assert.DoesNotContain(401, statusCodes); + Assert.DoesNotContain(403, statusCodes); + Assert.DoesNotContain(422, statusCodes); + Assert.DoesNotContain(429, statusCodes); Assert.DoesNotContain(500, statusCodes); } } diff --git a/Resulta.Tests/ResultaOptionsTests.cs b/Resulta.Tests/ResultaOptionsTests.cs new file mode 100644 index 0000000..4c21450 --- /dev/null +++ b/Resulta.Tests/ResultaOptionsTests.cs @@ -0,0 +1,120 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; + +using Resulta; +using Resulta.AspNetCore; + +using Xunit; + +namespace Resulta.Tests; + +public sealed class ResultaOptionsTests +{ + private static string? CodeExtension(ProblemDetails problem) => + problem.Extensions.TryGetValue(ResultProblemDetailsFactory.CodeExtensionKey, out var v) ? v?.ToString() : null; + + [Fact] + public void AddResulta_With_Custom_Code_Should_Map_To_Configured_Status() + { + var previous = ResultProblemDetailsFactory.Current; + try + { + var services = new ServiceCollection(); + services.AddResulta(o => o.MapError("RATE_LIMITED", 429, "Rate Limited", "https://example.com/p/rl")); + + using var provider = services.BuildServiceProvider(); + var mapper = provider.GetRequiredService(); + + var problem = mapper.Create(new Error("over limit", "RATE_LIMITED"), context: null); + + Assert.Equal(429, problem.Status); + Assert.Equal("Rate Limited", problem.Title); + Assert.Equal("RATE_LIMITED", CodeExtension(problem)); + } + finally + { + ResultProblemDetailsFactory.Current = previous; + } + } + + [Fact] + public void AddResulta_Should_Override_Default_Mapping() + { + var previous = ResultProblemDetailsFactory.Current; + try + { + var services = new ServiceCollection(); + services.AddResulta(o => o.MapError( + "NOT_FOUND", + StatusCodes.Status410Gone, + "Gone", + "https://example.com/p/gone")); + + using var provider = services.BuildServiceProvider(); + var mapper = provider.GetRequiredService(); + + var problem = mapper.Create(Error.NotFound("User"), context: null); + + Assert.Equal(StatusCodes.Status410Gone, problem.Status); + Assert.Equal("Gone", problem.Title); + Assert.Equal("https://example.com/p/gone", problem.Type); + Assert.Equal("NOT_FOUND", CodeExtension(problem)); + } + finally + { + ResultProblemDetailsFactory.Current = previous; + } + } + + [Fact] + public void AddResulta_ConfigureProblemDetails_Should_Run_After_Build() + { + var previous = ResultProblemDetailsFactory.Current; + try + { + var services = new ServiceCollection(); + services.AddResulta(o => + { + o.ConfigureProblemDetails = (problem, error, http) => + { + problem.Extensions["traceId"] = "abc"; + }; + }); + + using var provider = services.BuildServiceProvider(); + var mapper = provider.GetRequiredService(); + + var problem = mapper.Create(new Error("secret", "UNKNOWN"), context: null); + + Assert.Equal(StatusCodes.Status500InternalServerError, problem.Status); + Assert.Equal("INTERNAL_ERROR", CodeExtension(problem)); + Assert.Equal("abc", problem.Extensions["traceId"]); + } + finally + { + ResultProblemDetailsFactory.Current = previous; + } + } + + [Fact] + public void Static_API_Should_Pick_Up_DI_Configuration() + { + var previous = ResultProblemDetailsFactory.Current; + try + { + var services = new ServiceCollection(); + services.AddResulta(o => o.MapError("MY", 418, "Teapot", "about:blank")); + + var problem = ResultProblemDetailsFactory.Create(new Error("hi", "MY")); + + Assert.Equal(418, problem.Status); + Assert.Equal("Teapot", problem.Title); + Assert.Equal("MY", CodeExtension(problem)); + } + finally + { + ResultProblemDetailsFactory.Current = previous; + } + } +} diff --git a/Resulta/src/Error.cs b/Resulta/src/Error.cs index 552d5cb..21a8bee 100644 --- a/Resulta/src/Error.cs +++ b/Resulta/src/Error.cs @@ -6,7 +6,9 @@ namespace Resulta /// /// is immutable — all fluent builder methods return a new instance. /// Use the predefined factory methods such as , , - /// , , and for common error types. + /// , , , + /// , , and + /// for common error types. /// public sealed class Error { @@ -104,6 +106,13 @@ public static Error NotFound(string resource) public static Error Unauthorized(string? reason = null) => new Error(reason ?? "You are not authorized to perform this action.", code: "UNAUTHORIZED"); + /// + /// Creates a FORBIDDEN error with an optional . + /// + /// An optional message explaining why the action is forbidden. + public static Error Forbidden(string? reason = null) + => new Error(reason ?? "You are not allowed to perform this action.", code: "FORBIDDEN"); + /// /// Creates a VALIDATION_ERROR for a specific . /// Attaches the field name as metadata under the key "field". @@ -114,6 +123,13 @@ public static Error Validation(string field, string message) => new Error($"Validation failed for '{field}': {message}", code: "VALIDATION_ERROR") .WithMetadata("field", field); + /// + /// Creates an UNPROCESSABLE error with the given . + /// + /// A message describing why the request cannot be processed. + public static Error Unprocessable(string message) + => new Error(message, code: "UNPROCESSABLE"); + /// /// Creates an UNEXPECTED_ERROR from an . /// Attaches the exception to the error for diagnostic purposes. @@ -129,6 +145,13 @@ public static Error Unexpected(Exception ex) public static Error Conflict(string message) => new Error(message, code: "CONFLICT"); + /// + /// Creates a TOO_MANY_REQUESTS error with an optional . + /// + /// An optional message explaining the rate-limit condition. + public static Error TooManyRequests(string? reason = null) + => new Error(reason ?? "Too many requests. Please try again later.", code: "TOO_MANY_REQUESTS"); + // ── Formatting ─────────────────────────────────────────────────────── /// @@ -161,4 +184,4 @@ public string ToDetailedString() return string.Join(Environment.NewLine, lines); } } -} \ No newline at end of file +} From 194ff68a2329efd70fe4477e5402ae0432ddd3ad Mon Sep 17 00:00:00 2001 From: Kentaro Hakase Date: Sat, 23 May 2026 01:37:18 +0200 Subject: [PATCH 2/2] chore: bump version to 3.2.0 --- CHANGELOG.md | 13 ++++++++++++- Resulta.AspNetCore/Resulta.AspNetCore.csproj | 2 +- .../Resulta.FluentValidation.csproj | 2 +- Resulta/Resulta.csproj | 2 +- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b678b94..2ecb469 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +## [3.2.0] - 2026-05-23 + +### Added +- **Core**: `Error.Forbidden()`, `Error.Unprocessable(...)`, and `Error.TooManyRequests()` factories for common HTTP-oriented error codes. +- **AspNetCore**: configurable `ResultaOptions` error-to-HTTP mapping via `AddResulta(options => ...)`, including custom code mappings and a post-build `ConfigureProblemDetails` hook. +- **AspNetCore**: default RFC 7807 mappings for `FORBIDDEN` (403), `UNPROCESSABLE` (422), and `TOO_MANY_REQUESTS` (429). + +### Changed +- **AspNetCore**: OpenAPI helpers now document the expanded default error set: 400, 401, 403, 404, 409, 422, 429, and 500. + ## [3.1.0] - 2026-05-21 ### Changed @@ -168,7 +178,8 @@ for `Result`/`Error`, and adds OpenAPI helpers for the standard Resulta error re - Implicit conversions from values and errors to `Result`. - `.NET 10` support. -[Unreleased]: https://github.com/Kentarohakase/Resulta/compare/v3.1.0...HEAD +[Unreleased]: https://github.com/Kentarohakase/Resulta/compare/v3.2.0...HEAD +[3.2.0]: https://github.com/Kentarohakase/Resulta/compare/v3.1.0...v3.2.0 [3.1.0]: https://github.com/Kentarohakase/Resulta/compare/v3.0.0...v3.1.0 [3.0.0]: https://github.com/Kentarohakase/Resulta/compare/v2.1.7...v3.0.0 [2.1.7]: https://github.com/Kentarohakase/Resulta/compare/v2.1.1...v2.1.7 diff --git a/Resulta.AspNetCore/Resulta.AspNetCore.csproj b/Resulta.AspNetCore/Resulta.AspNetCore.csproj index f639b67..710c4ae 100644 --- a/Resulta.AspNetCore/Resulta.AspNetCore.csproj +++ b/Resulta.AspNetCore/Resulta.AspNetCore.csproj @@ -6,7 +6,7 @@ enable Resulta.AspNetCore - 3.1.0 + 3.2.0 Kentaro ASP.NET Core integration for Resulta, including HTTP result mapping and application setup helpers. result-pattern;aspnetcore;minimal-api;mvc;webapi;http;dotnet;csharp diff --git a/Resulta.FluentValidation/Resulta.FluentValidation.csproj b/Resulta.FluentValidation/Resulta.FluentValidation.csproj index c530e1b..53b9ded 100644 --- a/Resulta.FluentValidation/Resulta.FluentValidation.csproj +++ b/Resulta.FluentValidation/Resulta.FluentValidation.csproj @@ -6,7 +6,7 @@ enable Resulta.FluentValidation - 3.1.0 + 3.2.0 Kentaro FluentValidation integration for Resulta, including helpers for converting validation output into Result and ValidationResult. result-pattern;fluentvalidation;validation;dotnet;csharp diff --git a/Resulta/Resulta.csproj b/Resulta/Resulta.csproj index 973850e..f31bf60 100644 --- a/Resulta/Resulta.csproj +++ b/Resulta/Resulta.csproj @@ -6,7 +6,7 @@ enable Resulta - 3.1.0 + 3.2.0 Kentaro Lightweight Result pattern library for C# with structured errors, validation primitives, helper methods, and pipelines. result-pattern;results;error-handling;railway-oriented-programming;validation;dotnet;csharp