Pitasoft.Result.AspNetCore 2.1.2

dotnet add package Pitasoft.Result.AspNetCore --version 2.1.2
                    
NuGet\Install-Package Pitasoft.Result.AspNetCore -Version 2.1.2
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Pitasoft.Result.AspNetCore" Version="2.1.2" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Pitasoft.Result.AspNetCore" Version="2.1.2" />
                    
Directory.Packages.props
<PackageReference Include="Pitasoft.Result.AspNetCore" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Pitasoft.Result.AspNetCore --version 2.1.2
                    
#r "nuget: Pitasoft.Result.AspNetCore, 2.1.2"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Pitasoft.Result.AspNetCore@2.1.2
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Pitasoft.Result.AspNetCore&version=2.1.2
                    
Install as a Cake Addin
#tool nuget:?package=Pitasoft.Result.AspNetCore&version=2.1.2
                    
Install as a Cake Tool

Pitasoft.Result.AspNetCore

Build Status NuGet version NuGet Downloads License Target Framework ASP.NET Core

English | Castellano


English

Pitasoft.Result.AspNetCore adapts Pitasoft.Result contracts to ASP.NET Core so the same domain/application result can be returned consistently from:

  • MVC / Web API controllers through IActionResult
  • Minimal APIs through Microsoft.AspNetCore.Http.IResult
  • .NET clients such as Pitasoft.Client, preserving payload shape for entities and collections in Avalonia UI, MAUI, WPF, and Blazor

This package is especially useful when your application services already return Result, ResultEntity<T>, ResultEntities<T> or ResultPaged<T> and you want a thin HTTP boundary instead of duplicating status mapping in every endpoint.

Installation

dotnet add package Pitasoft.Result.AspNetCore

Quick start

Minimal API:

using Pitasoft.Result;
using Pitasoft.Result.AspNetCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddResultAspNetCore();

var app = builder.Build();

app.MapGet("/products/{id:int}", (int id, IProductService service) =>
{
    var result = service.GetById(id);
    return result.ToHttpResult();
});

app.Run();

MVC / Web API:

[HttpGet("{id:int}")]
public IActionResult GetProduct(int id)
{
    var result = _service.GetById(id);
    return result.ToActionResult();
}

This is the recommended starting point:

  • register AddResultAspNetCore() once
  • return Pitasoft.Result contracts from the application layer
  • use ToActionResult() or ToHttpResult() at the HTTP boundary

Configuration precedence

When more than one configuration source is available, the adapter applies them in this order:

  1. explicit per-call overrides such as result.ToHttpResult(customMapper, customOptions)
  2. global DI configuration registered through AddResultAspNetCore(...)
  3. built-in defaults from ResultHttpStatusCodes and ResultJsonSerializerOptions

What it provides

  • ToActionResult()
    • converts a Pitasoft.Result.IResult into an ASP.NET Core IActionResult
  • ToHttpResult()
    • converts a Pitasoft.Result.IResult into an ASP.NET Core Minimal API result
  • AddResultAspNetCore(...)
    • registers a global adapter configuration through dependency injection for status mapping and JSON serialization
  • AddResultExceptionHandling()
    • registers a global exception handler that maps unhandled exceptions to Pitasoft.Result contracts; returns an IResultExceptionHandlingBuilder so additional mappers can be chained via .AddExceptionMapper<T>()
  • IResultExceptionMapper
    • implement this interface to plug a custom mapper into the chain; CanMap(exception) controls whether your mapper handles a given exception type
  • ResultJsonSerializerOptions
    • creates or enriches JsonSerializerOptions so Pitasoft.Result entity, collection, paged, and structured-error payloads serialize and deserialize correctly
  • ResultHttpStatusCodes
    • exposes the default StatusResult -> HTTP mapping and can be reused as a customization baseline
  • ToActionResultAsync() / ToHttpResultAsync()
    • async variants for Task<IResult> that eliminate the await boilerplate at the HTTP boundary
  • AddResultFilter() on IMvcBuilder
    • registers ResultActionFilter globally so MVC actions can return IResult directly without calling ToActionResult()
  • AddResultFilter() on RouteHandlerBuilder / RouteGroupBuilder
    • adds ResultEndpointFilter to Minimal API endpoints or groups so handlers can return IResult directly without calling ToHttpResult()
  • [ProducesResult(...)] attribute + AddResultDocumentation()
    • declares the StatusResult values an MVC action or controller can return and automatically registers the corresponding HTTP status codes as ProducesResponseType entries in the OpenAPI description
  • WithResultDocumentation(...) on RouteHandlerBuilder / RouteGroupBuilder
    • registers the declared StatusResult values as HTTP response type metadata on Minimal API endpoints so they appear in OpenAPI / Swagger documentation
  • AddResultValidationFilter()
    • replaces ASP.NET Core's default ModelStateInvalidFilter so that invalid model state produces a Pitasoft.Result ValidationError response instead of ValidationProblemDetails, keeping the error contract consistent
  • SuppressModelStateValidation()
    • suppresses the built-in ModelStateInvalidFilter without adding any replacement — use this when validation runs inside action bodies via Pitasoft.Validation or Pitasoft.FluentValidation
  • QueryParameters, EntityParameters, and PagingParameters
    • Minimal API bindable parameter objects with BindAsync(...) implementations for query/filter/search/sort, paging-only, or combined scenarios
  • HttpContextQueryExtensions
    • helper methods such as TryGetQueryValue<T>(), GetQueryValue<T>(), and GetQueryValueOrDefault<T>() to simplify typed query-string access when implementing custom Minimal API parameter binding

Why it matters

Without a shared adapter, it is easy for ASP.NET Core endpoints to drift from the semantics already expressed by StatusResult.

Examples of drift this package helps avoid:

  • returning 200 OK for every result, even when the domain says NotFound or Conflict
  • losing NoExist vs NotFound intent at the HTTP boundary
  • breaking collection payloads consumed by Pitasoft.Client
  • reimplementing the same switch over StatusResult in many controllers or endpoints

Default HTTP mapping

ToHttpResult() and ToActionResult() map StatusResult to HTTP response codes using the following default policy:

StatusResult HTTP
Ok, Warning, NoExist 200 OK
Added 201 Created
Updated 200 OK
Deleted 204 No Content by default, 200 OK when the result carries a payload
ValidationError 400 Bad Request
Unauthorized, ChangePassword 401 Unauthorized
Forbidden 403 Forbidden
NotFound 404 Not Found
Conflict, ConcurrencyError 409 Conflict
UnprocessableEntity 422 Unprocessable Entity
TooManyRequests 429 Too Many Requests
ServiceUnavailable, ConnectionError, HttpError 503 Service Unavailable
None, CancelOperation, DataError, DatabaseError, Error, Exception 500 Internal Server Error

Important semantic note

NoExist is intentionally mapped to 200 OK by default.

That is not an accident. In the Pitasoft result model, NoExist usually means:

  • the query completed correctly
  • the requested data does not exist
  • the outcome is still functional, not an HTTP transport failure

If you need explicit HTTP 404 semantics, return StatusResult.NotFound instead.

Deleted also deserves a note:

  • plain Result.Deleted() maps to 204 No Content
  • Result.Deleted(entity) or Result.DeletedEntities(...) maps to 200 OK so the payload can be preserved

Global configuration with DI

If you want to change the default behavior once for the whole application, register the adapter in IServiceCollection.

using Microsoft.AspNetCore.Http;
using Pitasoft.Result.AspNetCore;

builder.Services.AddResultAspNetCore(options =>
{
    options.StatusCodeMapper = status =>
        status == StatusResult.NoExist
            ? StatusCodes.Status404NotFound
            : ResultHttpStatusCodes.Get(status);

    options.SerializerOptions = ResultJsonSerializerOptions.Create(o =>
    {
        o.PropertyNamingPolicy = null;
    });
});

Once registered, plain calls such as result.ToActionResult() and result.ToHttpResult() will use the configured mapping and serializer options automatically when executed inside ASP.NET Core.

Global exception handling

If your application throws unhandled exceptions, you can convert them to Pitasoft.Result responses with:

builder.Services.AddResultAspNetCore();
builder.Services.AddResultExceptionHandling();

var app = builder.Build();
app.UseExceptionHandler(_ => { });

Default exception mapping:

  • ValidationExceptionValidationError
  • UnauthorizedAccessExceptionUnauthorized
  • KeyNotFoundExceptionNotFound
  • OperationCanceledExceptionCancelOperation
  • any other exception → Exception

Exception mapper chain

AddResultExceptionHandling() returns an IResultExceptionHandlingBuilder. Call .AddExceptionMapper<T>() to register additional mappers before the built-in fallback:

builder.Services.AddResultExceptionHandling()
    .AddExceptionMapper<DbExceptionMapper>()
    .AddExceptionMapper<HttpClientExceptionMapper>();

Mappers are evaluated in registration order. The first whose CanMap(exception) returns true handles the exception. DefaultResultExceptionMapper is always the final fallback and handles any exception not matched by an earlier mapper.

Implement IResultExceptionMapper to create a custom mapper:

public sealed class DbExceptionMapper : IResultExceptionMapper
{
    public bool CanMap(Exception exception) =>
        exception is TimeoutException;

    public IResult Map(Exception exception) =>
        new Result(StatusResult.ServiceUnavailable);
}

Register it before UseExceptionHandler:

builder.Services.AddResultExceptionHandling()
    .AddExceptionMapper<DbExceptionMapper>();

Host styles

Pitasoft.Result.AspNetCore is designed to support both common ASP.NET Core styles without changing your application-layer result contracts:

  • ToHttpResult() for Minimal APIs
  • ToActionResult() for MVC / Web API controllers
  • AddResultFilter() for automatic conversion without an explicit call in either style
  • AddResultExceptionHandling() for shared exception-to-result behavior in either style
  • [ProducesResult] / WithResultDocumentation() for OpenAPI documentation in either style
  • AddResultValidationFilter() / SuppressModelStateValidation() for unified validation error contracts in MVC

Automatic result conversion

Instead of calling ToActionResult() or ToHttpResult() at every endpoint, register a filter once and let handlers return IResult directly.

MVC / Web API — ResultActionFilter

Register globally via AddResultFilter() after AddControllers():

builder.Services.AddControllers().AddResultFilter();

Actions then return IResult directly — the same three-level configuration precedence applies:

[HttpGet("{id:int}")]
public IResult GetProduct(int id) => _service.GetById(id);

[HttpPost]
public Task<IResult> CreateProduct([FromBody] CreateRequest request) =>
    _service.CreateAsync(request);

Minimal APIs — ResultEndpointFilter

Apply per-endpoint, per-group, or globally:

// Per-endpoint
app.MapGet("/products/{id:int}", (int id, IProductService service) =>
    service.GetById(id)).AddResultFilter();

// Per-group
var api = app.MapGroup("/api").AddResultFilter();
api.MapGet("/products/{id:int}", (int id, IProductService service) => service.GetById(id));

// Global — all endpoints under the root
var api = app.MapGroup("").AddResultFilter();

Return values that are not Pitasoft.Result.IResult — such as strings or TypedResults — are passed through unchanged.

OpenAPI documentation

Declare the StatusResult values each endpoint can return and let the package register the corresponding HTTP status codes automatically in the OpenAPI description.

MVC / Web API — [ProducesResult] attribute

Enable the convention once in Program.cs:

builder.Services.AddControllers()
    .AddResultFilter()
    .AddResultDocumentation();

Then annotate each action or controller with [ProducesResult]:

[HttpGet("{id:int}")]
[ProducesResult(StatusResult.Ok, StatusResult.NoExist)]
public IResult GetProduct(int id) => _service.GetById(id);

[HttpPost]
[ProducesResult(StatusResult.Added, StatusResult.ValidationError)]
public IResult CreateProduct([FromBody] CreateRequest request) =>
    _service.Create(request);

When applied to a controller class the declared statuses are inherited by all actions that do not carry their own [ProducesResult] attribute.

Duplicate HTTP codes — for example StatusResult.Ok and StatusResult.NoExist both map to 200 — are deduplicated automatically.

Minimal APIs — WithResultDocumentation()

Chain WithResultDocumentation() after the endpoint mapping:

api.MapGet("/products/{id:int}", (int id, IProductService service) =>
    service.GetById(id))
    .WithResultDocumentation(StatusResult.Ok, StatusResult.NoExist);

api.MapPost("/products", (CreateRequest request, IProductService service) =>
    service.Create(request))
    .WithResultDocumentation(StatusResult.Added, StatusResult.ValidationError);

Apply to a whole group to share documentation across endpoints:

var api = app.MapGroup("/api")
    .AddResultFilter()
    .WithResultDocumentation(StatusResult.Ok, StatusResult.NotFound);

ModelState validation

By default, ASP.NET Core's [ApiController] attribute intercepts invalid model state before the action executes and returns a ValidationProblemDetails payload (application/problem+json). This format differs from the Pitasoft.Result contract used everywhere else.

AddResultValidationFilter() replaces that behavior so validation errors are returned as a standard ValidationError result:

builder.Services.AddControllers()
    .AddResultFilter()
    .AddResultDocumentation()
    .AddResultValidationFilter();   // replaces ModelStateInvalidFilter

A POST /products/validate with a missing Name field will now return:

{
  "status": 7,
  "errors": {
    "Name": ["The Name field is required."]
  }
}

instead of the default ValidationProblemDetails shape.

When validation runs inside the action — SuppressModelStateValidation()

When you use Pitasoft.Validation, Pitasoft.FluentValidation, or any other library that validates inside the action body, the built-in ModelStateInvalidFilter would still short-circuit the request before the action can validate. Call SuppressModelStateValidation() to disable it without registering ResultValidationFilter:

builder.Services.AddControllers()
    .AddResultFilter()
    .SuppressModelStateValidation();   // disables built-in filter, validation runs in action body

Async extension methods

When application services return Task<IResult>, chain the conversion without an intermediate await:

// MVC / Web API
[HttpGet("{id:int}")]
public Task<IActionResult> GetProduct(int id) =>
    _service.GetByIdAsync(id).ToActionResultAsync();

// Minimal API
app.MapGet("/products/{id:int}", (int id, IProductService service) =>
    service.GetByIdAsync(id).ToHttpResultAsync());

All overloads from the synchronous methods are available in async form:

Method Variants
ToActionResultAsync() default, custom mapper, IServiceProvider, HttpContext
ToHttpResultAsync() default, JsonSerializerOptions, custom mapper, IServiceProvider, HttpContext

Usage in MVC / Web API

using Microsoft.AspNetCore.Mvc;
using Pitasoft.Result.AspNetCore;

[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    private readonly IUserService _userService;

    public UsersController(IUserService userService)
    {
        _userService = userService;
    }

    [HttpGet("{id:int}")]
    public IActionResult GetUser(int id)
    {
        var result = _userService.GetUserById(id);
        return result.ToActionResult();
    }
}

Practical MVC example

[HttpPut("{id:int}")]
public IActionResult UpdateUser(int id, UpdateUserRequest request)
{
    var result = _userService.UpdateUser(id, request);

    // The HTTP code is chosen from result.Status:
    // Updated -> 200
    // ValidationError -> 400
    // NotFound -> 404
    // Conflict -> 409
    return result.ToActionResult();
}

Custom MVC status mapping

[HttpGet("{id:int}")]
public IActionResult GetUser(int id)
{
    var result = _userService.GetUserById(id);

    return result.ToActionResult(status =>
        status == StatusResult.NoExist
            ? StatusCodes.Status404NotFound
            : ResultHttpStatusCodes.Get(status));
}

Usage in Minimal APIs

using Pitasoft.Result.AspNetCore;

app.MapGet("/users/{id:int}", (int id, IUserService userService) =>
{
    var result = userService.GetUserById(id);
    return result.ToHttpResult();
});

Practical Minimal API example

app.MapPost("/users", (CreateUserRequest request, IUserService userService) =>
{
    var result = userService.CreateUser(request);

    // Added -> 201
    // ValidationError -> 400
    // Conflict -> 409
    return result.ToHttpResult();
});

Custom Minimal API status mapping

app.MapGet("/legacy-products/{id:int}", (int id, IProductService service) =>
{
    var result = service.GetById(id);

    return result.ToHttpResult(
        status => status == StatusResult.NoExist
            ? StatusCodes.Status404NotFound
            : ResultHttpStatusCodes.Get(status));
});

Working with collections

When you want to return a collection, prefer an explicit collection result:

app.MapGet("/products", (IProductService service) =>
{
    var products = service.GetProducts();
    return Result.OkEntities(products).ToHttpResult();
});

This is especially important when the response will be consumed by Pitasoft.Client, because the payload shape must remain a ResultEntities<T> contract.

var listResult = Result.OkEntities(products);
var pagedResult = Result.OkPaged(products, totalCount, page, pageSize);

Avoid relying on ambiguous overload resolution for collection-like objects such as List<T> if you need an explicit collection result contract at the HTTP boundary.

Working with paged results

Paged endpoints should return ResultPaged<T> explicitly:

app.MapGet("/products/paged", (int page, int pageSize, IProductService service) =>
{
    var result = service.GetPaged(page, pageSize);
    return result.ToHttpResult();
});

Recommended creation pattern:

var pagedResult = Result.OkPaged(items, totalCount, page, pageSize);

This preserves:

  • Entities
  • TotalCount
  • Page
  • PageSize
  • pagination-derived helper metadata

Minimal API query binding helpers

The package also includes Minimal API-friendly parameter binders for common query scenarios.

QueryParameters

QueryParameters is the query-only contract. Use it when you need filtering, search, sorting, or attribute selection without paging:

  • attrs
  • query
  • search
  • order
using Pitasoft.Result.AspNetCore.Parameters;

app.MapGet("/products/search", (QueryParameters parameters, IProductService service) =>
{
    var result = service.Search(parameters);
    return result.ToHttpResult();
});

PagingParameters

PagingParameters can be used directly in Minimal API handlers. It binds:

  • page
  • pageSize
  • legacy aliases index and size
using Pitasoft.Result.AspNetCore.Parameters;

app.MapGet("/products", (PagingParameters parameters, IProductService service) =>
{
    var result = service.GetPaged(parameters.Page, parameters.PageSize);
    return result.ToHttpResult();
});

EntityParameters

EntityParameters combines both concerns in a single contract:

  • attrs
  • query
  • search
  • order
  • page / pageSize
  • index / size
using Pitasoft.Result.AspNetCore.Parameters;

app.MapGet("/products/search", (EntityParameters parameters, IProductService service) =>
{
    var result = service.Search(parameters);
    return result.ToHttpResult();
});

This is a realistic Minimal API endpoint that combines query binding with an explicit paged result contract:

using Pitasoft.Result;
using Pitasoft.Result.AspNetCore;
using Pitasoft.Result.AspNetCore.Parameters;

app.MapGet("/products", (EntityParameters parameters, IProductService service) =>
{
    var items = service.Search(
        search: parameters.Search,
        query: parameters.Query,
        order: parameters.Order,
        attrs: parameters.Attrs,
        page: parameters.Page ?? 1,
        pageSize: parameters.PageSize ?? 25);

    var totalCount = service.Count(parameters);

    return Result.OkPaged(
        items,
        totalCount,
        parameters.Page ?? 1,
        parameters.PageSize ?? 25)
        .ToHttpResult();
});

Example query string:

/products?search=laptop&order=name&page=2&pageSize=20

In that example:

  • search binds to EntityParameters.Search
  • order binds to EntityParameters.Order
  • page binds to EntityParameters.Page
  • pageSize binds to EntityParameters.PageSize

The same endpoint also accepts the aliases:

/products?search=laptop&order=name&index=2&size=20

This keeps Minimal API handlers concise while preserving a client-friendly ResultPaged<T> payload.

Custom BindAsync helpers

If you create your own Minimal API parameter object, use HttpContextQueryExtensions to keep typed query parsing small and predictable:

public sealed class ProductFilter
{
    public string? Category { get; init; }
    public bool? ActiveOnly { get; init; }

    public static ValueTask<ProductFilter?> BindAsync(HttpContext context, ParameterInfo _)
    {
        var filter = new ProductFilter
        {
            Category = context.GetQueryValueOrDefault<string>("category"),
            ActiveOnly = context.GetQueryValueOrDefault<bool?>("activeOnly")
        };

        return ValueTask.FromResult<ProductFilter?>(filter);
    }
}

These helpers are especially useful when you want consistent query parsing without repeating Request.Query plumbing inside every endpoint.

ResultJsonSerializerOptions

ResultJsonSerializerOptions is the helper provided by this package to configure JsonSerializerOptions for Pitasoft result payloads.

Available methods

Create(...)

Creates a new JsonSerializerOptions instance using JsonSerializerDefaults.Web and adds the converters required for:

  • ResultEntities<T>
  • ResultPaged<T>
  • structured error payloads such as ErrorCollection
using Pitasoft.Result.AspNetCore;

var options = ResultJsonSerializerOptions.Create();

You can also customize the options:

var options = ResultJsonSerializerOptions.Create(o =>
{
    o.WriteIndented = true;
    o.PropertyNameCaseInsensitive = true;
});
Add(...)

Adds the required Pitasoft converters to an existing JsonSerializerOptions instance.

using System.Text.Json;
using Pitasoft.Result.AspNetCore;

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;

ResultJsonSerializerOptions.Add(options);

ResultHttpStatusCodes

ResultHttpStatusCodes.Get(status) exposes the default status mapping used by the adapter.

This is useful when you want to customize one or two statuses while preserving the default behavior for the rest.

var httpCode = ResultHttpStatusCodes.Get(StatusResult.Conflict);

Using with Pitasoft.Client

If your client customizes JsonSerializerOptions, use ResultJsonSerializerOptions to keep collection payloads compatible.

This is the recommended integration model when your ASP.NET Core backend is consumed by .NET frontends such as:

  • Avalonia UI
  • MAUI
  • WPF
  • Blazor

The intended contract is:

  • the API returns Pitasoft.Result contracts adapted through Pitasoft.Result.AspNetCore
  • the frontend consumes those contracts through Pitasoft.Client
  • the UI interprets StatusResult instead of inventing a second error model

Example with an existing HttpClient

using Microsoft.Extensions.Logging.Abstractions;
using Pitasoft.Client;
using Pitasoft.Result;
using Pitasoft.Result.AspNetCore;

public sealed class ProductsClient : RestServiceBase
{
    public ProductsClient(HttpClient client)
        : base(client, ResultJsonSerializerOptions.Create(), NullLogger.Instance)
    {
    }

    public Task<ResultEntities<ProductDto>> GetProductsAsync(CancellationToken cancellationToken = default) =>
        GetAsync("products", new ResultEntities<ProductDto>(), cancellationToken);

    public Task<ResultPaged<ProductDto>> GetProductsPagedAsync(int page, int pageSize, CancellationToken cancellationToken = default) =>
        GetAsync($"products/paged?page={page}&pageSize={pageSize}", new ResultPaged<ProductDto>(), cancellationToken);

    public Task<ResultEntity<ProductDto>> GetProductAsync(int id, CancellationToken cancellationToken = default) =>
        GetAsync($"products/{id}", new ResultEntity<ProductDto>(), cancellationToken);
}

public sealed record ProductDto(int Id, string Name, decimal Price);

Frontend consumption guideline

For .NET frontends, keep the same interpretation rules regardless of UI framework:

  • Ok, Added, Updated, Deleted
    • normal success flow
  • NoExist
    • empty state or "no data" state
  • ValidationError
    • form validation or inline error display
  • Conflict, ConcurrencyError
    • refresh, retry, or conflict message
  • Unauthorized
    • sign-in flow
  • Forbidden
    • access denied UI
  • NotFound
    • missing resource or navigation fallback
  • TooManyRequests, ServiceUnavailable
    • retry UX or temporary unavailable message

This allows the same client service layer to be reused across Avalonia UI, MAUI, WPF, and Blazor.

Example starting from custom options

using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using Pitasoft.Client;
using Pitasoft.Result.AspNetCore;

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
    WriteIndented = true
};

ResultJsonSerializerOptions.Add(options);

var client = new ProductsClient(httpClient, options);

End-to-end examples

Example 1: NoExist as a functional result

app.MapGet("/products/{id:int}", (int id, IProductService service) =>
{
    var product = service.Find(id);
    var result = product is null
        ? Result.NotExists<ProductDto>()
        : Result.Ok(product);

    return result.ToHttpResult();
});

Expected behavior:

  • if product exists → 200 OK with Status = Ok
  • if product does not exist → 200 OK with Status = NoExist

Example 2: explicit HTTP 404

app.MapGet("/customers/{id:int}", (int id, ICustomerService service) =>
{
    var customer = service.Find(id);
    var result = customer is null
        ? Result.NotFound<CustomerDto>()
        : Result.Ok(customer);

    return result.ToHttpResult();
});

Expected behavior:

  • if customer exists → 200 OK
  • if customer does not exist → 404 Not Found with Status = NotFound

Example 3: validation failure

app.MapPost("/orders", (CreateOrderRequest request, IOrderService service) =>
{
    var result = service.Create(request);
    return result.ToHttpResult();
});

Expected behavior:

  • valid request → 201 or 200 depending on returned status
  • invalid request → 400 Bad Request with Status = ValidationError

Example 4: paged endpoint

app.MapGet("/products/paged", (int page, int pageSize, IProductService service) =>
{
    var result = service.GetPaged(page, pageSize);
    return result.ToHttpResult();
});

Expected behavior:

  • the response body remains a ResultPaged<T> contract
  • pagination metadata is preserved for clients
  • Pitasoft.Client can deserialize the paged result when using ResultJsonSerializerOptions

Example 5: conflict with structured errors

app.MapPost("/products/conflict", () =>
{
    var errors = ErrorCollection.Create("sku", "A product with the same SKU already exists.");
    return Result.Conflict<ProductDto>(errors).ToHttpResult();
});

Expected behavior:

  • HTTP 409 Conflict
  • Status = Conflict
  • structured errors remain in the response payload

Testing guidance

The repository includes tests for:

  • MVC and Minimal API status mapping
  • collection and single-entity serialization
  • paged result serialization
  • compatibility with Pitasoft.Client
  • NoExist vs NotFound semantics
  • structured validation and conflict payloads at the HTTP boundary
  • ResultActionFilter and ResultEndpointFilter conversion behavior
  • async extension methods (ToActionResultAsync, ToHttpResultAsync)
  • [ProducesResult] attribute storage and ProducesResultConvention behavior (action/controller precedence, deduplication, multi-status)
  • ResultValidationFilter short-circuit behavior, ErrorCollection construction from ModelState, empty key normalization, and blank message filtering
  • exception mapper chain: CanMap routing, fallback to DefaultResultExceptionMapper, AddExceptionMapper<T>() registration order

If you add new result mapping behavior, update tests accordingly.

License

This project is licensed under the terms described in LICENSE.txt.


Castellano

Pitasoft.Result.AspNetCore adapta los contratos de Pitasoft.Result a ASP.NET Core para que un mismo resultado de dominio o aplicación pueda devolverse de forma coherente desde:

  • controladores MVC / Web API mediante IActionResult
  • Minimal APIs mediante Microsoft.AspNetCore.Http.IResult
  • clientes .NET como Pitasoft.Client, preservando la forma del payload para entidades y colecciones en Avalonia UI, MAUI, WPF y Blazor

Este paquete es especialmente útil cuando tus servicios de aplicación ya devuelven Result, ResultEntity<T>, ResultEntities<T> o ResultPaged<T> y quieres una frontera HTTP fina, sin repetir el mapeo de estados en cada endpoint.

Instalación

dotnet add package Pitasoft.Result.AspNetCore

Inicio rápido

Minimal API:

using Pitasoft.Result;
using Pitasoft.Result.AspNetCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddResultAspNetCore();

var app = builder.Build();

app.MapGet("/products/{id:int}", (int id, IProductService service) =>
{
    var result = service.GetById(id);
    return result.ToHttpResult();
});

app.Run();

MVC / Web API:

[HttpGet("{id:int}")]
public IActionResult GetProduct(int id)
{
    var result = _service.GetById(id);
    return result.ToActionResult();
}

Este es el punto de partida recomendado:

  • registra AddResultAspNetCore() una sola vez
  • devuelve contratos Pitasoft.Result desde la capa de aplicación
  • usa ToActionResult() o ToHttpResult() en la frontera HTTP

Precedencia de configuración

Cuando hay más de una fuente de configuración, el adaptador aplica este orden:

  1. overrides explícitos por llamada como result.ToHttpResult(customMapper, customOptions)
  2. configuración global registrada con AddResultAspNetCore(...)
  3. valores por defecto de ResultHttpStatusCodes y ResultJsonSerializerOptions

Qué ofrece

  • ToActionResult()
    • convierte un Pitasoft.Result.IResult en un IActionResult de ASP.NET Core
  • ToHttpResult()
    • convierte un Pitasoft.Result.IResult en un resultado de Minimal API
  • AddResultAspNetCore(...)
    • registra una configuración global del adaptador mediante inyección de dependencias para el mapeo HTTP y la serialización JSON
  • AddResultExceptionHandling()
    • registra un manejador global de excepciones que transforma errores no controlados en contratos Pitasoft.Result; retorna un IResultExceptionHandlingBuilder para encadenar mappers adicionales mediante .AddExceptionMapper<T>()
  • IResultExceptionMapper
    • implementa esta interfaz para conectar un mapper personalizado a la cadena; CanMap(exception) controla si tu mapper gestiona un tipo de excepción concreto
  • ResultJsonSerializerOptions
    • crea o enriquece JsonSerializerOptions para que los payloads de entidad, colección, paginación y errores estructurados de Pitasoft.Result se serialicen y deserialicen correctamente
  • ResultHttpStatusCodes
    • expone el mapeo por defecto StatusResult -> HTTP y puede reutilizarse como base de personalización
  • ToActionResultAsync() / ToHttpResultAsync()
    • variantes asíncronas para Task<IResult> que eliminan el boilerplate de await en la frontera HTTP
  • AddResultFilter() sobre IMvcBuilder
    • registra ResultActionFilter globalmente para que los actions de MVC puedan retornar IResult directamente sin llamar a ToActionResult()
  • AddResultFilter() sobre RouteHandlerBuilder / RouteGroupBuilder
    • añade ResultEndpointFilter a endpoints o grupos de Minimal APIs para que los handlers retornen IResult directamente sin llamar a ToHttpResult()
  • atributo [ProducesResult(...)] + AddResultDocumentation()
    • declara los valores StatusResult que un action o controlador puede devolver y registra automáticamente los códigos HTTP correspondientes como entradas ProducesResponseType en la descripción OpenAPI
  • WithResultDocumentation(...) sobre RouteHandlerBuilder / RouteGroupBuilder
    • registra los valores StatusResult declarados como metadata de tipo de respuesta HTTP en endpoints de Minimal APIs para que aparezcan en la documentación OpenAPI / Swagger
  • AddResultValidationFilter()
    • sustituye el ModelStateInvalidFilter por defecto de ASP.NET Core para que el estado de modelo inválido devuelva una respuesta ValidationError de Pitasoft.Result en lugar de ValidationProblemDetails, manteniendo el contrato de error consistente
  • SuppressModelStateValidation()
    • suprime el filtro incorporado sin añadir ningún reemplazo — úsalo cuando la validación se ejecute dentro del action mediante Pitasoft.Validation o Pitasoft.FluentValidation

Por qué importa

Sin un adaptador compartido, es muy fácil que ASP.NET Core se desalineé de la semántica que ya expresa StatusResult.

Ejemplos de desalineación que este paquete ayuda a evitar:

  • devolver siempre 200 OK, incluso cuando el dominio dice NotFound o Conflict
  • perder la diferencia entre NoExist y NotFound
  • romper payloads de colección consumidos por Pitasoft.Client
  • reimplementar el mismo switch sobre StatusResult en muchos endpoints

Mapeo HTTP por defecto

ToHttpResult() y ToActionResult() convierten StatusResult a HTTP con esta política por defecto:

StatusResult HTTP
Ok, Warning, NoExist 200 OK
Added 201 Created
Updated 200 OK
Deleted 204 No Content por defecto, 200 OK cuando el resultado lleva payload
ValidationError 400 Bad Request
Unauthorized, ChangePassword 401 Unauthorized
Forbidden 403 Forbidden
NotFound 404 Not Found
Conflict, ConcurrencyError 409 Conflict
UnprocessableEntity 422 Unprocessable Entity
TooManyRequests 429 Too Many Requests
ServiceUnavailable, ConnectionError, HttpError 503 Service Unavailable
None, CancelOperation, DataError, DatabaseError, Error, Exception 500 Internal Server Error

Nota semántica importante

NoExist se mapea a 200 OK de forma intencionada.

En el modelo de resultados de Pitasoft, normalmente significa que:

  • la consulta se ejecutó correctamente
  • los datos solicitados no existen
  • el resultado sigue siendo funcional, no un fallo de transporte HTTP

Si necesitas semántica HTTP 404 explícita, devuelve StatusResult.NotFound.

Deleted también tiene un matiz importante:

  • Result.Deleted() plano se mapea a 204 No Content
  • Result.Deleted(entity) o Result.DeletedEntities(...) se mapea a 200 OK para poder conservar el payload

Configuración global con DI

Si quieres cambiar el comportamiento por defecto una sola vez para toda la aplicación, registra el adaptador en IServiceCollection.

using Microsoft.AspNetCore.Http;
using Pitasoft.Result.AspNetCore;

builder.Services.AddResultAspNetCore(options =>
{
    options.StatusCodeMapper = status =>
        status == StatusResult.NoExist
            ? StatusCodes.Status404NotFound
            : ResultHttpStatusCodes.Get(status);

    options.SerializerOptions = ResultJsonSerializerOptions.Create(o =>
    {
        o.PropertyNamingPolicy = null;
    });
});

Una vez registrado, llamadas normales como result.ToActionResult() y result.ToHttpResult() usarán automáticamente el mapping y las opciones JSON configuradas cuando se ejecuten dentro de ASP.NET Core.

Manejo global de excepciones

Si tu aplicación lanza excepciones no controladas, puedes convertirlas a respuestas Pitasoft.Result con:

builder.Services.AddResultAspNetCore();
builder.Services.AddResultExceptionHandling();

var app = builder.Build();
app.UseExceptionHandler(_ => { });

Mapeo por defecto de excepciones:

  • ValidationExceptionValidationError
  • UnauthorizedAccessExceptionUnauthorized
  • KeyNotFoundExceptionNotFound
  • OperationCanceledExceptionCancelOperation
  • cualquier otra excepción → Exception

Cadena de mappers de excepción

AddResultExceptionHandling() retorna un IResultExceptionHandlingBuilder. Llama a .AddExceptionMapper<T>() para registrar mappers adicionales antes del fallback incorporado:

builder.Services.AddResultExceptionHandling()
    .AddExceptionMapper<DbExceptionMapper>()
    .AddExceptionMapper<HttpClientExceptionMapper>();

Los mappers se evalúan en orden de registro. El primero cuyo CanMap(exception) retorne true gestiona la excepción. DefaultResultExceptionMapper es siempre el fallback final y gestiona cualquier excepción que los mappers anteriores no hayan manejado.

Implementa IResultExceptionMapper para crear un mapper personalizado:

public sealed class DbExceptionMapper : IResultExceptionMapper
{
    public bool CanMap(Exception exception) =>
        exception is TimeoutException;

    public IResult Map(Exception exception) =>
        new Result(StatusResult.ServiceUnavailable);
}

Regístralo antes de UseExceptionHandler:

builder.Services.AddResultExceptionHandling()
    .AddExceptionMapper<DbExceptionMapper>();

Estilos de host

Pitasoft.Result.AspNetCore está pensado para soportar los dos estilos habituales de ASP.NET Core sin cambiar los contratos de resultados de tu capa de aplicación:

  • ToHttpResult() para Minimal APIs
  • ToActionResult() para controladores MVC / Web API
  • AddResultFilter() para conversión automática sin llamada explícita en cualquiera de los dos estilos
  • AddResultExceptionHandling() para un comportamiento compartido de excepción a resultado en cualquiera de los dos estilos
  • [ProducesResult] / WithResultDocumentation() para documentación OpenAPI en cualquiera de los dos estilos
  • AddResultValidationFilter() / SuppressModelStateValidation() para un contrato de error de validación unificado en MVC

Conversión automática de resultados

En lugar de llamar a ToActionResult() o ToHttpResult() en cada endpoint, registra un filtro una sola vez y deja que los handlers retornen IResult directamente.

MVC / Web API — ResultActionFilter

Registra globalmente mediante AddResultFilter() tras AddControllers():

builder.Services.AddControllers().AddResultFilter();

Los actions retornan IResult directamente — se aplica la misma precedencia de configuración de tres niveles:

[HttpGet("{id:int}")]
public IResult GetProduct(int id) => _service.GetById(id);

[HttpPost]
public Task<IResult> CreateProduct([FromBody] CreateRequest request) =>
    _service.CreateAsync(request);

Minimal APIs — ResultEndpointFilter

Aplica por endpoint, por grupo o globalmente:

// Por endpoint
app.MapGet("/products/{id:int}", (int id, IProductService service) =>
    service.GetById(id)).AddResultFilter();

// Por grupo
var api = app.MapGroup("/api").AddResultFilter();
api.MapGet("/products/{id:int}", (int id, IProductService service) => service.GetById(id));

// Global — todos los endpoints bajo la raíz
var api = app.MapGroup("").AddResultFilter();

Los valores de retorno que no sean Pitasoft.Result.IResult — como cadenas de texto o resultados TypedResults — se pasan sin modificar.

Documentación OpenAPI

Declara los valores StatusResult que puede devolver cada endpoint y deja que el paquete registre automáticamente los códigos HTTP correspondientes en la descripción OpenAPI.

MVC / Web API — atributo [ProducesResult]

Activa la convención una sola vez en Program.cs:

builder.Services.AddControllers()
    .AddResultFilter()
    .AddResultDocumentation();

Luego anota cada action o controlador con [ProducesResult]:

[HttpGet("{id:int}")]
[ProducesResult(StatusResult.Ok, StatusResult.NoExist)]
public IResult GetProduct(int id) => _service.GetById(id);

[HttpPost]
[ProducesResult(StatusResult.Added, StatusResult.ValidationError)]
public IResult CreateProduct([FromBody] CreateRequest request) =>
    _service.Create(request);

Cuando se aplica a la clase del controlador, los estados declarados son heredados por todos los actions que no lleven su propio atributo [ProducesResult].

Los códigos HTTP duplicados — por ejemplo StatusResult.Ok y StatusResult.NoExist se mapean ambos a 200 — se deduplicanan de forma automática.

Minimal APIs — WithResultDocumentation()

Encadena WithResultDocumentation() tras el mapeo del endpoint:

api.MapGet("/products/{id:int}", (int id, IProductService service) =>
    service.GetById(id))
    .WithResultDocumentation(StatusResult.Ok, StatusResult.NoExist);

api.MapPost("/products", (CreateRequest request, IProductService service) =>
    service.Create(request))
    .WithResultDocumentation(StatusResult.Added, StatusResult.ValidationError);

Aplícalo a un grupo entero para compartir documentación entre endpoints:

var api = app.MapGroup("/api")
    .AddResultFilter()
    .WithResultDocumentation(StatusResult.Ok, StatusResult.NotFound);

Validación de ModelState

Por defecto, el atributo [ApiController] de ASP.NET Core intercepta el estado de modelo inválido antes de que se ejecute el action y devuelve un payload ValidationProblemDetails (application/problem+json). Este formato difiere del contrato Pitasoft.Result utilizado en el resto de la aplicación.

AddResultValidationFilter() sustituye ese comportamiento para que los errores de validación se devuelvan como un resultado ValidationError estándar:

builder.Services.AddControllers()
    .AddResultFilter()
    .AddResultDocumentation()
    .AddResultValidationFilter();   // sustituye ModelStateInvalidFilter

Un POST /products/validate con el campo Name vacío devolverá ahora:

{
  "status": 7,
  "errors": {
    "Name": ["The Name field is required."]
  }
}

en lugar del formato ValidationProblemDetails por defecto.

Cuando la validación ocurre dentro del action — SuppressModelStateValidation()

Cuando se usa Pitasoft.Validation, Pitasoft.FluentValidation u otra librería que valida dentro del action, el ModelStateInvalidFilter incorporado cortocircuitaría la petición antes de que el action pueda aplicar su propia lógica. Llama a SuppressModelStateValidation() para desactivarlo sin registrar ResultValidationFilter:

builder.Services.AddControllers()
    .AddResultFilter()
    .SuppressModelStateValidation();   // desactiva el filtro incorporado, la validación ocurre en el action

Extensiones asíncronas

Cuando los servicios de aplicación retornan Task<IResult>, encadena la conversión sin un await intermedio:

// MVC / Web API
[HttpGet("{id:int}")]
public Task<IActionResult> GetProduct(int id) =>
    _service.GetByIdAsync(id).ToActionResultAsync();

// Minimal API
app.MapGet("/products/{id:int}", (int id, IProductService service) =>
    service.GetByIdAsync(id).ToHttpResultAsync());

Todas las sobrecargas de los métodos síncronos están disponibles en forma asíncrona:

Método Variantes
ToActionResultAsync() default, mapper personalizado, IServiceProvider, HttpContext
ToHttpResultAsync() default, JsonSerializerOptions, mapper personalizado, IServiceProvider, HttpContext

Uso en MVC / Web API

using Microsoft.AspNetCore.Mvc;
using Pitasoft.Result.AspNetCore;

[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    private readonly IUserService _userService;

    public UsersController(IUserService userService)
    {
        _userService = userService;
    }

    [HttpGet("{id:int}")]
    public IActionResult GetUser(int id)
    {
        var result = _userService.GetUserById(id);
        return result.ToActionResult();
    }
}

Ejemplo práctico en MVC

[HttpPut("{id:int}")]
public IActionResult UpdateUser(int id, UpdateUserRequest request)
{
    var result = _userService.UpdateUser(id, request);

    // Updated -> 200
    // ValidationError -> 400
    // NotFound -> 404
    // Conflict -> 409
    return result.ToActionResult();
}

Mapeo MVC personalizado

[HttpGet("{id:int}")]
public IActionResult GetUser(int id)
{
    var result = _userService.GetUserById(id);

    return result.ToActionResult(status =>
        status == StatusResult.NoExist
            ? StatusCodes.Status404NotFound
            : ResultHttpStatusCodes.Get(status));
}

Uso en Minimal APIs

using Pitasoft.Result.AspNetCore;

app.MapGet("/users/{id:int}", (int id, IUserService userService) =>
{
    var result = userService.GetUserById(id);
    return result.ToHttpResult();
});

Ejemplo práctico en Minimal APIs

app.MapPost("/users", (CreateUserRequest request, IUserService userService) =>
{
    var result = userService.CreateUser(request);

    // Added -> 201
    // ValidationError -> 400
    // Conflict -> 409
    return result.ToHttpResult();
});

Mapeo personalizado en Minimal APIs

app.MapGet("/legacy-products/{id:int}", (int id, IProductService service) =>
{
    var result = service.GetById(id);

    return result.ToHttpResult(
        status => status == StatusResult.NoExist
            ? StatusCodes.Status404NotFound
            : ResultHttpStatusCodes.Get(status));
});

Trabajo con colecciones

Cuando quieras devolver una colección, es preferible crear el resultado de colección de forma explícita:

app.MapGet("/products", (IProductService service) =>
{
    var products = service.GetProducts();
    return Result.OkEntities(products).ToHttpResult();
});

Esto es especialmente importante si la respuesta va a ser consumida por Pitasoft.Client, porque la forma del payload debe seguir siendo un contrato ResultEntities<T>.

Patrones recomendados para colecciones

var listResult = Result.OkEntities(products);
var pagedResult = Result.OkPaged(products, totalCount, page, pageSize);

Evita depender de una resolución ambigua de sobrecargas para objetos tipo List<T> si necesitas un contrato de colección explícito en la frontera HTTP.

Trabajo con resultados paginados

Los endpoints paginados deberían devolver ResultPaged<T> de forma explícita:

app.MapGet("/products/paged", (int page, int pageSize, IProductService service) =>
{
    var result = service.GetPaged(page, pageSize);
    return result.ToHttpResult();
});

Patrón recomendado de creación:

var pagedResult = Result.OkPaged(items, totalCount, page, pageSize);

Esto preserva:

  • Entities
  • TotalCount
  • Page
  • PageSize
  • la metadata auxiliar derivada de la paginación

ResultJsonSerializerOptions

ResultJsonSerializerOptions es la utilidad que proporciona este paquete para configurar JsonSerializerOptions en payloads de Pitasoft.Result.

Métodos disponibles

Create(...)

Crea una nueva instancia de JsonSerializerOptions usando JsonSerializerDefaults.Web y añade los conversores necesarios para:

  • ResultEntities<T>
  • ResultPaged<T>
  • payloads de error estructurado como ErrorCollection
using Pitasoft.Result.AspNetCore;

var options = ResultJsonSerializerOptions.Create();

También puedes personalizar las opciones:

var options = ResultJsonSerializerOptions.Create(o =>
{
    o.WriteIndented = true;
    o.PropertyNameCaseInsensitive = true;
});
Add(...)

Añade los conversores necesarios de Pitasoft sobre unas JsonSerializerOptions existentes.

using System.Text.Json;
using Pitasoft.Result.AspNetCore;

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;

ResultJsonSerializerOptions.Add(options);

ResultHttpStatusCodes

ResultHttpStatusCodes.Get(status) expone el mapeo por defecto usado por el adaptador.

Es útil cuando quieres personalizar uno o dos estados sin reimplementar el resto del comportamiento.

var httpCode = ResultHttpStatusCodes.Get(StatusResult.Conflict);

Uso con Pitasoft.Client

Si tu cliente personaliza JsonSerializerOptions, utiliza ResultJsonSerializerOptions para mantener compatibles los payloads de colección.

Este es el modelo de integración recomendado cuando tu backend ASP.NET Core es consumido desde frontends .NET como:

  • Avalonia UI
  • MAUI
  • WPF
  • Blazor

El contrato esperado es:

  • la API devuelve contratos Pitasoft.Result adaptados mediante Pitasoft.Result.AspNetCore
  • el frontend consume esos contratos mediante Pitasoft.Client
  • la UI interpreta StatusResult en lugar de inventar un segundo modelo de errores

Ejemplo con HttpClient existente

using Microsoft.Extensions.Logging.Abstractions;
using Pitasoft.Client;
using Pitasoft.Result;
using Pitasoft.Result.AspNetCore;

public sealed class ProductsClient : RestServiceBase
{
    public ProductsClient(HttpClient client)
        : base(client, ResultJsonSerializerOptions.Create(), NullLogger.Instance)
    {
    }

    public Task<ResultEntities<ProductDto>> GetProductsAsync(CancellationToken cancellationToken = default) =>
        GetAsync("products", new ResultEntities<ProductDto>(), cancellationToken);

    public Task<ResultPaged<ProductDto>> GetProductsPagedAsync(int page, int pageSize, CancellationToken cancellationToken = default) =>
        GetAsync($"products/paged?page={page}&pageSize={pageSize}", new ResultPaged<ProductDto>(), cancellationToken);

    public Task<ResultEntity<ProductDto>> GetProductAsync(int id, CancellationToken cancellationToken = default) =>
        GetAsync($"products/{id}", new ResultEntity<ProductDto>(), cancellationToken);
}

public sealed record ProductDto(int Id, string Name, decimal Price);

Guía de consumo para frontend

En frontends .NET, conviene mantener las mismas reglas de interpretación sin importar el framework UI:

  • Ok, Added, Updated, Deleted
    • flujo normal de éxito
  • NoExist
    • estado vacío o "sin datos"
  • ValidationError
    • validación de formulario o errores inline
  • Conflict, ConcurrencyError
    • refresco, reintento o mensaje de conflicto
  • Unauthorized
    • flujo de login
  • Forbidden
    • UI de acceso denegado
  • NotFound
    • recurso inexistente o fallback de navegación
  • TooManyRequests, ServiceUnavailable
    • UX de reintento o mensaje de indisponibilidad temporal

Esto permite reutilizar la misma capa de servicios cliente en Avalonia UI, MAUI, WPF y Blazor.

Ejemplo partiendo de opciones propias

using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using Pitasoft.Client;
using Pitasoft.Result.AspNetCore;

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
    WriteIndented = true
};

ResultJsonSerializerOptions.Add(options);

var client = new ProductsClient(httpClient, options);

Ejemplos end-to-end

Ejemplo 1: NoExist como resultado funcional

app.MapGet("/products/{id:int}", (int id, IProductService service) =>
{
    var product = service.Find(id);
    var result = product is null
        ? Result.NotExists<ProductDto>()
        : Result.Ok(product);

    return result.ToHttpResult();
});

Comportamiento esperado:

  • si el producto existe → 200 OK con Status = Ok
  • si el producto no existe → 200 OK con Status = NoExist

Ejemplo 2: 404 HTTP explícito

app.MapGet("/customers/{id:int}", (int id, ICustomerService service) =>
{
    var customer = service.Find(id);
    var result = customer is null
        ? Result.NotFound<CustomerDto>()
        : Result.Ok(customer);

    return result.ToHttpResult();
});

Comportamiento esperado:

  • si el cliente existe → 200 OK
  • si el cliente no existe → 404 Not Found con Status = NotFound

Ejemplo 3: error de validación

app.MapPost("/orders", (CreateOrderRequest request, IOrderService service) =>
{
    var result = service.Create(request);
    return result.ToHttpResult();
});

Comportamiento esperado:

  • request válida → 201 o 200 según el estado devuelto
  • request inválida → 400 Bad Request con Status = ValidationError

Ejemplo 4: endpoint paginado

app.MapGet("/products/paged", (int page, int pageSize, IProductService service) =>
{
    var result = service.GetPaged(page, pageSize);
    return result.ToHttpResult();
});

Comportamiento esperado:

  • el cuerpo sigue siendo un contrato ResultPaged<T>
  • la metadata de paginación se preserva para el cliente
  • Pitasoft.Client puede deserializar el resultado paginado usando ResultJsonSerializerOptions

Ejemplo 5: conflicto con errores estructurados

app.MapPost("/products/conflict", () =>
{
    var errors = ErrorCollection.Create("sku", "A product with the same SKU already exists.");
    return Result.Conflict<ProductDto>(errors).ToHttpResult();
});

Comportamiento esperado:

  • HTTP 409 Conflict
  • Status = Conflict
  • los errores estructurados se mantienen en el payload de respuesta

Guía de testing

El repositorio incluye pruebas para:

  • mapeo de estados en MVC y Minimal API
  • serialización de entidad individual y colecciones
  • serialización de resultados paginados
  • compatibilidad con Pitasoft.Client
  • semántica NoExist vs NotFound
  • payloads estructurados de validación y conflicto en la frontera HTTP
  • comportamiento de conversión de ResultActionFilter y ResultEndpointFilter
  • extensiones asíncronas (ToActionResultAsync, ToHttpResultAsync)
  • almacenamiento del atributo [ProducesResult] y comportamiento de ProducesResultConvention (precedencia action/controlador, deduplicación, múltiples estados)
  • comportamiento de cortocircuito de ResultValidationFilter, construcción de ErrorCollection desde ModelState, normalización de claves vacías y filtrado de mensajes en blanco
  • cadena de mappers de excepción: enrutamiento por CanMap, fallback a DefaultResultExceptionMapper, orden de registro con AddExceptionMapper<T>()

Si amplías el comportamiento de mapeo, actualiza también las pruebas.


Autor

Sebastián Martínez Pérez

Licencia

Copyright © 2026 Pitasoft, S.L.
Licenciado bajo los términos de la LICENSE.txt incluida en este repositorio.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (2)

Showing the top 2 NuGet packages that depend on Pitasoft.Result.AspNetCore:

Package Downloads
Pitasoft.FluentValidation.AspNetCore

ASP.NET Core integration for FluentValidation. Provides Minimal API endpoint filters and MVC action filters to validate request parameters using FluentValidation.IValidator<T> and returns Pitasoft.Result validation responses.

Pitasoft.Validation.AspNetCore

ASP.NET Core integration for Pitasoft.Validation. Provides Minimal API endpoint filters and MVC action filters to validate request parameters using IValidator<T>, IValidatorAsync<T>, IChecker<T>, and ICheckerAsync<T>.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
2.1.2 161 4/3/2026
2.1.1 151 4/2/2026
2.0.2 152 3/31/2026
2.0.1 102 3/30/2026
1.0.8 107 3/20/2026
1.0.7 109 3/10/2026
1.0.6 106 3/9/2026
1.0.5 105 3/6/2026
1.0.4 106 3/2/2026
1.0.3 116 2/25/2026
1.0.2 120 2/23/2026
1.0.1 115 2/23/2026

Initial public package release with MVC and Minimal API adapters for Pitasoft.Result,
JSON serialization helpers for entity, collection, paged, and structured error contracts,
configurable HTTP status mapping, DI-based configuration, and global exception handling support.