Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<T>`.
- `.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
Expand Down
37 changes: 29 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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:

Expand All @@ -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`

Expand Down
168 changes: 99 additions & 69 deletions Resulta.AspNetCore/AspNetCoreIntegration.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.Net;

using Microsoft.AspNetCore.Builder;
Expand All @@ -13,59 +12,53 @@
namespace Resulta.AspNetCore
{
/// <summary>
/// Builds RFC 7807 <see cref="ProblemDetails"/> (or <see cref="HttpValidationProblemDetails"/>)
/// responses from a Resulta <see cref="Error"/>, using the error's <see cref="Error.Code"/> to
/// pick the HTTP status, title, and <c>type</c> URI.
/// Maps a Resulta <see cref="Error"/> to an RFC 7807 <see cref="ProblemDetails"/> response.
/// </summary>
/// <remarks>
/// The mapping is:
/// <list type="bullet">
/// <item><description><c>NOT_FOUND</c> → <c>404 Not Found</c></description></item>
/// <item><description><c>VALIDATION_ERROR</c> → <c>400 Bad Request</c> with field/message in <see cref="HttpValidationProblemDetails.Errors"/></description></item>
/// <item><description><c>UNAUTHORIZED</c> → <c>401 Unauthorized</c></description></item>
/// <item><description><c>CONFLICT</c> → <c>409 Conflict</c></description></item>
/// <item><description>Any other code → <c>500 Internal Server Error</c></description></item>
/// </list>
/// The original error code is also attached as the <c>code</c> extension property
/// (<see cref="ProblemDetails.Extensions"/>), so machine-readable callers can branch on it.
/// </remarks>
public static class ResultProblemDetailsFactory
internal interface IResultaErrorMapper
{
/// <summary>JSON extension key under which the original <see cref="Error.Code"/> is exposed on the problem object.</summary>
public const string CodeExtensionKey = "code";

private const string ValidationFieldMetadataKey = "field";

/// <summary>
/// Constructs a <see cref="ProblemDetails"/> (or <see cref="HttpValidationProblemDetails"/> for
/// validation errors) from the given <paramref name="error"/>.
/// Creates a <see cref="ProblemDetails"/> response from the given <paramref name="error"/>.
/// </summary>
/// <param name="error">The error to map.</param>
/// <param name="context">Optional HTTP context; when supplied, <see cref="ProblemDetails.Instance"/> is set to the request path.</param>
/// <remarks>
/// Errors whose <see cref="Error.Code"/> is unknown (anything outside the five recognized codes)
/// are flattened to a generic <c>500 Internal Server Error</c> with code <c>INTERNAL_ERROR</c> and
/// the detail <c>"An internal error occurred."</c>, so that internal error codes or exception
/// messages do not leak to clients.
/// </remarks>
public static ProblemDetails Create(Error error, HttpContext? context = null)
/// <param name="context">Optional HTTP context used to populate <see cref="ProblemDetails.Instance"/>.</param>
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;
}

Expand All @@ -79,47 +72,60 @@ 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();
if (error.Metadata.TryGetValue(ValidationFieldMetadataKey, out var v) && v?.ToString() is { Length: > 0 } field)
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
};
/// <summary>
/// Builds RFC 7807 <see cref="ProblemDetails"/> (or <see cref="HttpValidationProblemDetails"/>)
/// responses from a Resulta <see cref="Error"/>, using the error's <see cref="Error.Code"/> to
/// pick the HTTP status, title, and <c>type</c> URI.
/// </summary>
/// <remarks>
/// The default mapping is:
/// <list type="bullet">
/// <item><description><c>NOT_FOUND</c> maps to <c>404 Not Found</c>.</description></item>
/// <item><description><c>VALIDATION_ERROR</c> maps to <c>400 Bad Request</c> with field/message in <see cref="HttpValidationProblemDetails.Errors"/>.</description></item>
/// <item><description><c>UNAUTHORIZED</c> maps to <c>401 Unauthorized</c>.</description></item>
/// <item><description><c>FORBIDDEN</c> maps to <c>403 Forbidden</c>.</description></item>
/// <item><description><c>CONFLICT</c> maps to <c>409 Conflict</c>.</description></item>
/// <item><description><c>UNPROCESSABLE</c> maps to <c>422 Unprocessable Entity</c>.</description></item>
/// <item><description><c>TOO_MANY_REQUESTS</c> maps to <c>429 Too Many Requests</c>.</description></item>
/// <item><description>Any other code maps to <c>500 Internal Server Error</c>.</description></item>
/// </list>
/// The original error code is also attached as the <c>code</c> extension property
/// (<see cref="ProblemDetails.Extensions"/>), so machine-readable callers can branch on it.
/// </remarks>
public static class ResultProblemDetailsFactory
{
/// <summary>JSON extension key under which the original <see cref="Error.Code"/> is exposed on the problem object.</summary>
public const string CodeExtensionKey = "code";

internal static IResultaErrorMapper Current { get; set; } = new ResultaErrorMapper(new ResultaOptions());

/// <summary>
/// Constructs a <see cref="ProblemDetails"/> (or <see cref="HttpValidationProblemDetails"/> for
/// validation errors) from the given <paramref name="error"/>.
/// </summary>
/// <param name="error">The error to map.</param>
/// <param name="context">Optional HTTP context; when supplied, <see cref="ProblemDetails.Instance"/> is set to the request path.</param>
/// <remarks>
/// Errors whose <see cref="Error.Code"/> is unknown are flattened to a generic
/// <c>500 Internal Server Error</c> with code <c>INTERNAL_ERROR</c> and the detail
/// <c>"An internal error occurred."</c>, so that internal error codes or exception
/// messages do not leak to clients.
/// </remarks>
public static ProblemDetails Create(Error error, HttpContext? context = null)
=> Current.Create(error, context);
}

/// <summary>
Expand Down Expand Up @@ -259,7 +265,7 @@ private static IResult ProblemResultFor(Error err)
/// Use these instead of <see cref="MinimalApiExtensions.ToMinimalApiResult{T}(Result{T})"/> 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
/// <see cref="ProblemHttpResult"/> for 401 and 500.
/// <see cref="ProblemHttpResult"/> for other error responses.
/// </remarks>
public static class TypedMinimalApiExtensions
{
Expand All @@ -268,6 +274,9 @@ public static class TypedMinimalApiExtensions
/// </summary>
/// <typeparam name="T">The success value type.</typeparam>
/// <param name="result">The result to convert.</param>
/// <remarks>
/// 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.
/// </remarks>
public static Results<Ok<T>, NotFound<ProblemDetails>, BadRequest<HttpValidationProblemDetails>, Conflict<ProblemDetails>, ProblemHttpResult> ToTypedResult<T>(this Result<T> result)
{
ArgumentNullException.ThrowIfNull(result);
Expand All @@ -284,6 +293,9 @@ public static Results<Ok<T>, NotFound<ProblemDetails>, BadRequest<HttpValidation
/// Converts a non-generic <see cref="Result"/> to a typed Minimal API result union.
/// </summary>
/// <param name="result">The result to convert.</param>
/// <remarks>
/// 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.
/// </remarks>
public static Results<NoContent, NotFound<ProblemDetails>, BadRequest<HttpValidationProblemDetails>, Conflict<ProblemDetails>, ProblemHttpResult> ToTypedResult(this Result result)
{
ArgumentNullException.ThrowIfNull(result);
Expand All @@ -308,8 +320,26 @@ public static class ServiceCollectionExtensions
/// </summary>
/// <param name="services">The service collection to register into.</param>
public static IServiceCollection AddResulta(this IServiceCollection services)
=> AddResulta(services, configure: null);

/// <summary>
/// Registers Resulta services with the dependency injection container and configures
/// the error-to-HTTP mapping used by Resulta.
/// </summary>
/// <param name="services">The service collection to register into.</param>
/// <param name="configure">An optional callback used to customize Resulta options.</param>
public static IServiceCollection AddResulta(this IServiceCollection services, Action<ResultaOptions>? configure)
{
ArgumentNullException.ThrowIfNull(services);

var options = new ResultaOptions();
configure?.Invoke(options);

var mapper = new ResultaErrorMapper(options);
services.AddSingleton(mapper);
services.AddSingleton<IResultaErrorMapper>(mapper);
ResultProblemDetailsFactory.Current = mapper;

return services;
}

Expand Down
8 changes: 6 additions & 2 deletions Resulta.AspNetCore/OpenApi/RouteHandlerBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,25 @@ namespace Resulta.AspNetCore.OpenApi
{
/// <summary>
/// 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.
/// </summary>
public static class RouteHandlerBuilderExtensions
{
/// <summary>
/// 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).
/// </summary>
public static readonly IReadOnlyList<int> DefaultStatusCodes = new[]
{
StatusCodes.Status400BadRequest,
StatusCodes.Status401Unauthorized,
StatusCodes.Status403Forbidden,
StatusCodes.Status404NotFound,
StatusCodes.Status409Conflict,
StatusCodes.Status422UnprocessableEntity,
StatusCodes.Status429TooManyRequests,
StatusCodes.Status500InternalServerError
};

Expand Down
9 changes: 9 additions & 0 deletions Resulta.AspNetCore/ProblemTypeUris.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,18 @@ public static class ProblemTypeUris
/// <summary>RFC 7235, section 3.1 — <c>401 Unauthorized</c>.</summary>
public const string Unauthorized = "https://tools.ietf.org/html/rfc7235#section-3.1";

/// <summary>RFC 7231, section 6.5.3 - <c>403 Forbidden</c>.</summary>
public const string Forbidden = "https://tools.ietf.org/html/rfc7231#section-6.5.3";

/// <summary>RFC 7231, section 6.5.8 — <c>409 Conflict</c>.</summary>
public const string Conflict = "https://tools.ietf.org/html/rfc7231#section-6.5.8";

/// <summary>RFC 4918, section 11.2 - <c>422 Unprocessable Entity</c>.</summary>
public const string UnprocessableEntity = "https://tools.ietf.org/html/rfc4918#section-11.2";

/// <summary>RFC 6585, section 4 - <c>429 Too Many Requests</c>.</summary>
public const string TooManyRequests = "https://tools.ietf.org/html/rfc6585#section-4";

/// <summary>RFC 7231, section 6.6.1 — <c>500 Internal Server Error</c>.</summary>
public const string InternalServerError = "https://tools.ietf.org/html/rfc7231#section-6.6.1";
}
Expand Down
3 changes: 3 additions & 0 deletions Resulta.AspNetCore/Properties/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("Resulta.Tests")]
2 changes: 1 addition & 1 deletion Resulta.AspNetCore/Resulta.AspNetCore.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<Nullable>enable</Nullable>

<PackageId>Resulta.AspNetCore</PackageId>
<Version>3.1.0</Version>
<Version>3.2.0</Version>
<Authors>Kentaro</Authors>
<Description>ASP.NET Core integration for Resulta, including HTTP result mapping and application setup helpers.</Description>
<PackageTags>result-pattern;aspnetcore;minimal-api;mvc;webapi;http;dotnet;csharp</PackageTags>
Expand Down
Loading
Loading