Esox.SharpAndRusty.AspNetCore 1.1.1

dotnet add package Esox.SharpAndRusty.AspNetCore --version 1.1.1
                    
NuGet\Install-Package Esox.SharpAndRusty.AspNetCore -Version 1.1.1
                    
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="Esox.SharpAndRusty.AspNetCore" Version="1.1.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Esox.SharpAndRusty.AspNetCore" Version="1.1.1" />
                    
Directory.Packages.props
<PackageReference Include="Esox.SharpAndRusty.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 Esox.SharpAndRusty.AspNetCore --version 1.1.1
                    
#r "nuget: Esox.SharpAndRusty.AspNetCore, 1.1.1"
                    
#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 Esox.SharpAndRusty.AspNetCore@1.1.1
                    
#: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=Esox.SharpAndRusty.AspNetCore&version=1.1.1
                    
Install as a Cake Addin
#tool nuget:?package=Esox.SharpAndRusty.AspNetCore&version=1.1.1
                    
Install as a Cake Tool

Esox.SharpAndRusty.AspNetCore

Build Status Tests Security .NET

ASP.NET Core integration for Esox.SharpAndRusty functional types (Option, Result, Either, Validation).

Features

  • Action Result Conversions - Convert Result/ExtendedResult/Option/Either/Validation to IActionResult
  • RFC 7807 ProblemDetails - Automatic conversion of Error types to standardized problem details
  • Model Binding - Bind Option<T> from request parameters/body with full type support
  • Global Error Handling - Middleware for catching exceptions and converting to ProblemDetails
  • Automatic Status Codes - ErrorKind automatically maps to appropriate HTTP status codes
  • Validation Integration - Validation<T, E> converts to ValidationProblemDetails
  • JSON Serialization - Clean serialization of Option<T>, Result<T,E>, and ExtendedResult<T,E> in API responses
  • Comprehensive Testing - 570 total test executions in the latest verified run; all 570 passing across .NET 8, 9, and 10

Why Use This Library?

✨ Type-Safe API Responses

// Instead of this:
public User? GetUser(int id) // Nullable types lose context
{
    var user = _db.Users.Find(id);
    return user; // Is null a valid value or an error?
}

// Do this:
public Result<User, Error> GetUser(int id) // Clear success/failure semantics
{
    var user = _db.Users.Find(id);
    return user != null 
        ? Result<User, Error>.Ok(user)
        : Result<User, Error>.Err(Error.New("User not found", ErrorKind.NotFound));
}

// And in your controller:
[HttpGet("{id}")]
public IActionResult Get(int id) => GetUser(id).ToActionResult(); // Automatic HTTP status mapping!

🎯 Optional Parameters Done Right

// Instead of nullable parameters that cause validation errors:
public IActionResult Search(string query, int? page = null, string? sortBy = null)

// Use Option<T> - missing values are None, not validation errors:
public IActionResult Search(string query, Option<int> page, Option<string> sortBy)
{
    var actualPage = page.UnwrapOr(1);
    var actualSort = sortBy.UnwrapOr("name");
    // ...
}

🛡️ Production-Ready Error Handling

  • RFC 7807 ProblemDetails format
  • Automatic status code mapping
  • Stack traces in development, clean responses in production
  • Request correlation and tracing
  • Type-safe error handling throughout your application

Installation

dotnet add package Esox.SharpAndRusty.AspNetCore

Quick Start

1. Configure Services

// Program.cs or Startup.cs
builder.Services.AddSharpAndRusty();

2. Add Middleware

// Development
if (app.Environment.IsDevelopment())
{
    app.UseResultMiddlewareDevelopment(); // Includes stack traces
}
else
{
    app.UseResultMiddlewareProduction(); // Production-safe
}

3. Use in Controllers

[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    [HttpGet("{id}")]
    public IActionResult GetUser(int id)
    {
        var result = _userService.GetUser(id); // Returns Result<User, Error>
        return result.ToActionResult(); // Automatic conversion!
    }
}

JSON Serialization

When returning functional types directly from API endpoints, they serialize cleanly to JSON:

Option<T> Serialization

[HttpGet("user/{id}")]
public Option<User> GetUser(int id)
{
    return FindUser(id); // Option<User>
}

// Response when user exists:
// {
//   "id": 123,
//   "name": "John Doe",
//   "email": "john@example.com"
// }

// Response when user doesn't exist:
// null

Result<T, E> Serialization

[HttpPost("user")]
public Result<User, Error> CreateUser([FromBody] CreateUserDto dto)
{
    return ValidateAndCreateUser(dto); // Result<User, Error>
}

// Success response:
// {
//   "id": 123,
//   "name": "John Doe",
//   "email": "john@example.com"
// }

// Error response: Throws JsonException (results should typically be converted to IActionResult)

ExtendedResult<T, E> Serialization

[HttpGet("user/{id}/details")]
public ExtendedResult<UserDetails, Error> GetUserDetails(int id)
{
    return GetDetailedUserInfo(id); // ExtendedResult<UserDetails, Error>
}

// Success response:
// {
//   "user": { "id": 123, "name": "John Doe" },
//   "permissions": ["read", "write"],
//   "lastLogin": "2023-12-01T10:30:00Z"
// }

// Error response: Throws JsonException (extended results should typically be converted to IActionResult)

Note: Result<T,E> and ExtendedResult<T,E> in error states cannot be serialized and will throw JsonException. For API responses, convert them to IActionResult using .ToActionResult() instead.


Action Result Conversions

Result<T, E> → IActionResult

// Automatic status code mapping for Result<T, Error>
[HttpGet("{id}")]
public IActionResult Get(int id)
{
    var result = GetUser(id); // Result<User, Error>
    return result.ToActionResult(); // 200 OK or 404/400/500 based on ErrorKind
}

// Custom status code for generic errors
[HttpPost]
public IActionResult Create([FromBody] CreateUserDto dto)
{
    var result = CreateUser(dto); // Result<User, string>
    return result.ToActionResult(statusCode: 422); // 422 Unprocessable Entity on error
}

// Created result (201)
[HttpPost]
public IActionResult Create([FromBody] CreateUserDto dto)
{
    var result = CreateUser(dto); // Result<User, Error>
    return result.ToCreatedResult(user => $"/api/users/{user.Id}");
    // Returns 201 Created with Location header
}

// No content result (204)
[HttpDelete("{id}")]
public IActionResult Delete(int id)
{
    var result = DeleteUser(id); // Result<Unit, Error>
    return result.ToNoContentResult(); // 204 No Content or error
}

// Accepted result (202)
[HttpPost("batch")]
public IActionResult BatchProcess([FromBody] BatchRequest request)
{
    var result = QueueBatchJob(request); // Result<JobId, Error>
    return result.ToAcceptedResult($"/api/jobs/{result.Value.Id}");
    // Returns 202 Accepted with Location
}

Option<T> → IActionResult

// Automatic 404 for None
[HttpGet("{id}")]
public IActionResult Get(int id)
{
    var option = FindUser(id); // Option<User>
    return option.ToActionResult(); // 200 OK (Some) or 404 Not Found (None)
}

// Custom 404 message
[HttpGet("{id}")]
public IActionResult Get(int id)
{
    var option = FindUser(id); // Option<User>
    return option.ToActionResult($"User {id} not found");
}

// Custom result mapping
[HttpGet("{id}")]
public IActionResult Get(int id)
{
    var option = FindUser(id); // Option<User>
    return option.ToActionResult(
        someResult: user => Ok(new { user, message = "Found!" }),
        noneResult: () => NotFound(new { message = "Not found", id })
    );
}

Either<L, R> → IActionResult

// Left = success, Right = error (Rust convention)
[HttpPost]
public IActionResult Process([FromBody] ProcessRequest request)
{
    var either = ProcessData(request); // Either<ProcessResult, Error>
    return either.ToActionResult(); // 200 OK (Left) or error (Right)
}

// Custom status code for Right
[HttpPost]
public IActionResult Process([FromBody] ProcessRequest request)
{
    var either = ProcessData(request); // Either<ProcessResult, ValidationError>
    return either.ToActionResult(rightStatusCode: 422);
}

Validation<T, E> → IActionResult

// Automatic ValidationProblemDetails format
[HttpPost]
public IActionResult Create([FromBody] CreateUserDto dto)
{
    var validation = ValidateUser(dto); // Validation<User, Error>
    return validation.ToActionResult();
    // Returns 200 OK or 400 Bad Request with ALL validation errors
}

// Custom field mapping
[HttpPost]
public IActionResult Create([FromBody] CreateUserDto dto)
{
    var validation = ValidateUser(dto); // Validation<User, FieldError>
    return validation.ToValidationResult(
        keySelector: error => error.FieldName,
        messageSelector: error => error.Message
    );
}

ExtendedResult<T, E> → IActionResult

ExtendedResult provides the same functionality as Result with additional context capabilities:

// Basic conversion with automatic status mapping
[HttpGet("{id}")]
public IActionResult Get(int id)
{
    var result = GetUserExtended(id); // ExtendedResult<User, Error>
    return result.ToActionResult(); // 200 OK or appropriate error status
}

// Custom status code for generic errors
[HttpPost]
public IActionResult Create([FromBody] CreateUserDto dto)
{
    var result = CreateUserExtended(dto); // ExtendedResult<User, string>
    return result.ToActionResult(statusCode: 422); // 422 on error
}

// Created result (201)
[HttpPost]
public IActionResult Create([FromBody] CreateUserDto dto)
{
    var result = CreateUserExtended(dto); // ExtendedResult<User, Error>
    return result.ToCreatedResult(user => $"/api/users/{user.Id}");
    // Returns 201 Created with Location header
}

// No content result (204)
[HttpDelete("{id}")]
public IActionResult Delete(int id)
{
    var result = DeleteUserExtended(id); // ExtendedResult<Unit, Error>
    return result.ToNoContentResult(); // 204 No Content or error
}

// Accepted result (202)
[HttpPost("batch")]
public IActionResult BatchProcess([FromBody] BatchRequest request)
{
    var result = QueueBatchJobExtended(request); // ExtendedResult<JobId, Error>
    return result.ToAcceptedResult($"/api/jobs/{result.Value.Id}");
    // Returns 202 Accepted with Location
}

// Custom handlers for complete control
[HttpPost("process")]
public IActionResult Process([FromBody] ProcessRequest request)
{
    var result = ProcessDataExtended(request); // ExtendedResult<ProcessResult, Error>
    return result.ToActionResult(
        onSuccess: data => Ok(new { data, processed = DateTime.UtcNow }),
        onFailure: error => error.ToProblemDetails()
    );
}

RFC 7807 ProblemDetails Integration

The library automatically converts Error types to RFC 7807 ProblemDetails with appropriate status codes:

Automatic Status Code Mapping

ErrorKind HTTP Status Title
NotFound 404 Not Found
PermissionDenied 403 Forbidden
Unauthorized 401 Unauthorized
InvalidInput 400 Bad Request
ParseError 400 Bad Request
InvalidOperation 400 Bad Request
AlreadyExists 409 Conflict
Timeout 408 Request Timeout
ResourceExhausted 429 Too Many Requests
NotSupported 501 Not Implemented
ConnectionRefused 503 Service Unavailable
ConnectionReset 503 Service Unavailable
Io 500 Internal Server Error
Other 500 Internal Server Error

Example ProblemDetails Response

// Code
var error = Error.New("User not found", ErrorKind.NotFound)
    .WithContext("Database query returned no results")
    .WithMetadata("userId", 123);

return error.ToProblemDetails();
// HTTP Response: 404 Not Found
{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.4",
  "title": "Not Found",
  "status": 404,
  "detail": "User not found: Database query returned no results",
  "instance": "/api/users/123",
  "errors": [
    {
      "message": "User not found",
      "kind": "NotFound"
    },
    {
      "message": "Database query returned no results",
      "kind": "NotFound"
    }
  ],
  "traceId": "0HM7...",
  "requestId": "0HM7..."
}

Global Error Handling Middleware

Basic Setup

// Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSharpAndRusty();

var app = builder.Build();

// Add middleware based on environment
if (app.Environment.IsDevelopment())
{
    app.UseResultMiddlewareDevelopment(); // Includes stack traces
}
else
{
    app.UseResultMiddlewareProduction(); // Production-safe
}

app.MapControllers();
app.Run();

Custom Configuration

app.UseResultMiddleware(new ResultMiddlewareOptions
{
    IncludeStackTrace = app.Environment.IsDevelopment(),
    IncludeFileInfo = app.Environment.IsDevelopment(),
    WriteIndented = app.Environment.IsDevelopment(),
    
    // Only handle specific exceptions
    HandleException = ex => ex is not OperationCanceledException,
    
    // Add custom metadata
    CustomMetadataProvider = (context, error) => error
        .WithMetadata("user_id", context.User?.FindFirst("sub")?.Value)
        .WithMetadata("tenant_id", context.Request.Headers["X-Tenant-Id"].FirstOrDefault())
});

What It Does

  1. Catches unhandled exceptions globally
  2. Converts to Error types
  3. Maps to ProblemDetails with appropriate status code
  4. Adds request context (trace ID, path)
  5. Logs errors using ILogger
  6. Returns JSON in RFC 7807 format

Model Binding

Option<T> Binding

The library provides automatic model binding for Option<T>, treating missing/null values as None instead of validation errors:

[HttpGet]
public IActionResult Search(
    [FromQuery] string query,
    [FromQuery] Option<int> pageSize,      // Optional parameter
    [FromQuery] Option<string> sortBy)     // Optional parameter
{
    var size = pageSize.UnwrapOr(10);     // Default to 10
    var sort = sortBy.UnwrapOr("name");    // Default to "name"
    
    var results = _searchService.Search(query, size, sort);
    return Ok(results);
}

// Usage:
// GET /api/search?query=test                     -> pageSize: None, sortBy: None
// GET /api/search?query=test&pageSize=20         -> pageSize: Some(20), sortBy: None
// GET /api/search?query=test&sortBy=date         -> pageSize: None, sortBy: Some("date")

Complex Types

public class UpdateUserDto
{
    public required string Name { get; set; }
    public Option<string> Email { get; set; }       // Optional update
    public Option<DateTime> BirthDate { get; set; } // Optional update
    public Option<string?> Bio { get; set; }        // Optional, can be set to null
}

[HttpPatch("{id}")]
public IActionResult Update(int id, [FromBody] UpdateUserDto dto)
{
    var user = GetUser(id);
    
    // Only update fields that are Some
    dto.Email.Iter(email => user.Email = email);
    dto.BirthDate.Iter(date => user.BirthDate = date);
    dto.Bio.Iter(bio => user.Bio = bio);
    
    SaveUser(user);
    return NoContent();
}

Supported Types

The OptionModelBinder supports all types that ASP.NET Core can bind:

  • Primitive types: int, string, bool, decimal, double, float, long, etc.
  • Date/Time types: DateTime, DateTimeOffset, TimeSpan
  • Guid and other value types
  • Enums: Both numeric and string-based
  • Nullable types: Option<int?>, Option<DateTime?>, etc.
  • Complex types: Classes, records, structs
  • Collections: List<T>, IEnumerable<T>, arrays
  • Nested Options: Option<Option<T>> (though rarely needed)

How It Works

  1. Registration: AddSharpAndRusty() registers OptionModelBinderProvider
  2. Detection: Provider detects Option<T> parameters/properties
  3. Delegation: Creates OptionModelBinder with inner binder for type T
  4. Binding: Attempts to bind the inner value
    • Success: Wraps value in Some(value)
    • Failure/Missing: Returns None
  5. No Validation Errors: Unlike nullable types, missing Option<T> values don't cause validation errors

Example with All Supported Types

public class SearchFiltersDto
{
    // Primitives
    public Option<int> Page { get; set; }
    public Option<string> SortField { get; set; }
    public Option<bool> IncludeArchived { get; set; }
    public Option<decimal> MinPrice { get; set; }
    
    // Date/Time
    public Option<DateTime> CreatedAfter { get; set; }
    public Option<DateTimeOffset> UpdatedBefore { get; set; }
    
    // Guid
    public Option<Guid> CategoryId { get; set; }
    
    // Enums
    public Option<ProductStatus> Status { get; set; }
    
    // Nullable types
    public Option<int?> Rating { get; set; } // Some(null) vs None
    
    // Collections
    public Option<List<string>> Tags { get; set; }
    
    // Complex types
    public Option<PriceRange> PriceRange { get; set; }
}

[HttpGet("products")]
public IActionResult SearchProducts([FromQuery] SearchFiltersDto filters)
{
    var query = _db.Products.AsQueryable();
    
    // Apply filters only if provided (Some)
    filters.CategoryId.Iter(id => query = query.Where(p => p.CategoryId == id));
    filters.Status.Iter(status => query = query.Where(p => p.Status == status));
    filters.MinPrice.Iter(min => query = query.Where(p => p.Price >= min));
    filters.CreatedAfter.Iter(date => query = query.Where(p => p.CreatedAt >= date));
    filters.IncludeArchived.Iter(include => {
        if (!include) query = query.Where(p => !p.IsArchived);
    });
    
    var results = query.ToList();
    return Ok(results);
}

Real-World Examples

Example 1: CRUD Operations

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IProductService _productService;

    [HttpGet]
    public IActionResult GetAll()
    {
        var products = _productService.GetAll();
        return Ok(products);
    }

    [HttpGet("{id}")]
    public IActionResult Get(int id)
    {
        // Option<Product> - 200 OK or 404 Not Found
        return _productService.FindById(id).ToActionResult();
    }

    [HttpPost]
    public IActionResult Create([FromBody] CreateProductDto dto)
    {
        // Validation<Product, Error> - accumulates all validation errors
        var validation = _productService.Validate(dto);
        
        return validation
            .Map(product => _productService.Create(product))
            .ToActionResult(); // 200 OK with product or 400 with all errors
    }

    [HttpPut("{id}")]
    public IActionResult Update(int id, [FromBody] UpdateProductDto dto)
    {
        // Result<Product, Error> - short-circuits on first error
        var result = _productService.Update(id, dto);
        
        return result.ToActionResult(); // 200 OK or appropriate error status
    }

    [HttpDelete("{id}")]
    public IActionResult Delete(int id)
    {
        // Result<Unit, Error>
        return _productService.Delete(id).ToNoContentResult(); // 204 No Content or error
    }
}

Example 2: Search with Optional Parameters

[ApiController]
[Route("api/[controller]")]
public class SearchController : ControllerBase
{
    [HttpGet]
    public IActionResult Search(
        [FromQuery] string query,
        [FromQuery] Option<int> page,
        [FromQuery] Option<int> pageSize,
        [FromQuery] Option<string> category,
        [FromQuery] Option<decimal> minPrice,
        [FromQuery] Option<decimal> maxPrice)
    {
        var searchParams = new SearchParameters
        {
            Query = query,
            Page = page.UnwrapOr(1),
            PageSize = pageSize.UnwrapOr(20),
            Category = category,
            PriceRange = minPrice.FlatMap(min =>
                maxPrice.Map(max => new PriceRange(min, max)))
        };

        var results = _searchService.Search(searchParams);
        return Ok(results);
    }
}

Example 3: Complex Validation

[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    [HttpPost("register")]
    public IActionResult Register([FromBody] RegisterDto dto)
    {
        // Validate all fields and accumulate errors
        var validations = new[]
        {
            ValidateEmail(dto.Email),
            ValidatePassword(dto.Password),
            ValidateAge(dto.Age),
            ValidateUsername(dto.Username)
        };

        // Sequence validations - returns Valid only if ALL are valid
        var result = validations.Sequence() // From ValidationExtensions
            .Map(values => new User(dto.Email, dto.Password, dto.Age, dto.Username))
            .Bind(user => _userService.Create(user));

        return result.ToActionResult();
        // Returns all validation errors if any fail, or 200 OK with created user
    }

    private Validation<string, Error> ValidateEmail(string email)
    {
        if (string.IsNullOrWhiteSpace(email))
            return Validation<string, Error>.Invalid("Email is required");
        if (!email.Contains("@"))
            return Validation<string, Error>.Invalid("Email must be valid");
        
        return Validation<string, Error>.Valid(email);
    }

    private Validation<string, Error> ValidatePassword(string password)
    {
        var errors = new List<Error>();
        
        if (password.Length < 8)
            errors.Add(Error.New("Password must be at least 8 characters"));
        if (!password.Any(char.IsUpper))
            errors.Add(Error.New("Password must contain uppercase letter"));
        if (!password.Any(char.IsDigit))
            errors.Add(Error.New("Password must contain number"));
        
        return errors.Any()
            ? Validation<string, Error>.Invalid(errors)
            : Validation<string, Error>.Valid(password);
    }
}

Example 4: Async Operations with Result

[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    [HttpPost]
    public async Task<IActionResult> CreateOrder([FromBody] CreateOrderDto dto)
    {
        // Chain async operations with Result
        var result = await _orderService
            .ValidateInventory(dto.Items)
            .BindAsync(async _ => await _paymentService.ProcessPayment(dto.Payment))
            .BindAsync(async payment => await _orderService.CreateOrder(dto, payment))
            .MapAsync(async order => await _notificationService.SendConfirmation(order));

        return result.ToCreatedResult(order => $"/api/orders/{order.Id}");
    }
}

Integration with Existing Code

Wrapping Existing Services

public class UserService
{
    // Existing method that might return null
    public User? GetUserById(int id)
    {
        return _db.Users.Find(id);
    }

    // Wrap with Option
    public Option<User> FindUserById(int id)
    {
        return Option<User>.From(GetUserById(id));
    }

    // Existing method that throws exceptions
    public void DeleteUser(int id)
    {
        var user = GetUserById(id);
        if (user == null)
            throw new NotFoundException($"User {id} not found");
        
        _db.Users.Remove(user);
        _db.SaveChanges();
    }

    // Wrap with Result
    public Result<Unit, Error> TryDeleteUser(int id)
    {
        try
        {
            DeleteUser(id);
            return Result<Unit, Error>.Ok(Unit.Default);
        }
        catch (Exception ex)
        {
            return Result<Unit, Error>.Err(Error.FromException(ex));
        }
    }
}

Best Practices

✅ DO

// Use Result<T, Error> for operations that can fail
public Result<User, Error> GetUser(int id);

// Use Option<T> for values that may not exist
public Option<User> FindUser(int id);

// Use Validation<T, E> when you need ALL errors
public Validation<User, Error> ValidateUser(CreateUserDto dto);

// Let the middleware handle unhandled exceptions
app.UseResultMiddleware();

// Use appropriate status codes
return result.ToCreatedResult(user => $"/api/users/{user.Id}");

❌ DON'T

// Don't return nullable types when Option makes sense
public User? GetUser(int id); // ❌
public Option<User> GetUser(int id); // ✅

// Don't throw exceptions for expected failures
public User GetUser(int id)
{
    var user = _db.Find(id);
    if (user == null)
        throw new NotFoundException(); // ❌
}
// Use Result instead ✅
public Result<User, Error> GetUser(int id);

// Don't catch and return 500 for everything
catch (Exception ex)
{
    return StatusCode(500, "Error"); // ❌
}
// Let middleware handle it ✅

Configuration Options

Service Configuration

builder.Services.AddSharpAndRusty(options =>
{
    options.EnableOptionModelBinding = true;  // Enable Option<T> binding
    options.EnableResultModelBinding = false; // Usually not needed
});

Middleware Configuration

app.UseResultMiddleware(new ResultMiddlewareOptions
{
    // Include stack traces (dev only)
    IncludeStackTrace = true,
    
    // Include file paths in stack traces
    IncludeFileInfo = true,
    
    // Pretty-print JSON
    WriteIndented = true,
    
    // Filter exceptions
    HandleException = ex => ex is not OperationCanceledException,
    
    // Add custom metadata
    CustomMetadataProvider = (context, error) =>
    {
        return error
            .WithMetadata("correlation_id", context.TraceIdentifier)
            .WithMetadata("user_agent", context.Request.Headers["User-Agent"].ToString());
    }
});

Testing

The AspNetCore library currently reports 570 total test executions across .NET 8, 9, and 10 in the latest verified run:

Test Coverage

  • Action Result Conversions (38 tests) - All conversion methods and edge cases
  • OptionModelBinder (69 tests) - Comprehensive model binding scenarios
  • OptionModelBinderProvider (66 tests) - Provider registration and type detection

Example Tests

[Fact]
public void GetUser_WithValidId_ReturnsOk()
{
    // Arrange
    var user = new User { Id = 1, Name = "Test" };
    _mockService.Setup(s => s.GetUser(1))
        .Returns(Result<User, Error>.Ok(user));

    // Act
    var result = _controller.GetUser(1);

    // Assert
    var okResult = Assert.IsType<OkObjectResult>(result);
    Assert.Equal(user, okResult.Value);
}

[Fact]
public void GetUser_WithInvalidId_ReturnsNotFound()
{
    // Arrange
    var error = Error.New("Not found", ErrorKind.NotFound);
    _mockService.Setup(s => s.GetUser(999))
        .Returns(Result<User, Error>.Err(error));

    // Act
    var result = _controller.GetUser(999);

    // Assert
    var objectResult = Assert.IsType<ObjectResult>(result);
    Assert.Equal(404, objectResult.StatusCode);
}

[Fact]
public void OptionModelBinder_WithProvidedValue_BindsToSome()
{
    // Arrange
    var binder = CreateOptionModelBinder<int>();
    var context = CreateBindingContext<int>(42);

    // Act
    await binder.BindModelAsync(context);

    // Assert
    Assert.True(context.Result.IsModelSet);
    var option = Assert.IsType<Option<int>.Some>(context.Result.Model);
    Assert.Equal(42, option.Value);
}

[Fact]
public void OptionModelBinder_WithMissingValue_BindsToNone()
{
    // Arrange
    var binder = CreateOptionModelBinder<int>();
    var context = CreateBindingContextWithoutValue<int>();

    // Act
    await binder.BindModelAsync(context);

    // Assert
    Assert.True(context.Result.IsModelSet);
    Assert.IsType<Option<int>.None>(context.Result.Model);
}

For complete test coverage details, see TEST_DOCUMENTATION.md.


Recent Updates

✅ Security Update (Latest)

Fixed: Vulnerable Microsoft.AspNetCore package dependencies (CVE-2018-8269 and others)

Action Taken:

  • Removed deprecated Microsoft.AspNetCore.* packages (version 2.3.9 from 2018)
  • Migrated to FrameworkReference for Microsoft.AspNetCore.App
  • All packages now use secure, framework-provided versions

Verification:

dotnet list package --vulnerable --include-transitive
# Result: No vulnerable packages found ✅

This change provides:

  • ✅ Security: No known vulnerabilities
  • ✅ Compatibility: Works with .NET 8, 9, and 10
  • ✅ Maintenance: Framework-managed versions
  • ✅ Performance: Latest optimizations

Project Status

Metric Status
Build ✅ Passing
Tests ✅ 570/570 passing
Vulnerabilities ✅ 0 found
Target Frameworks .NET 8.0, 9.0, 10.0
Code Coverage 100%
Documentation Complete

Contributing

Contributions are welcome! Please follow these guidelines:

Getting Started

# Clone the repository
git clone https://github.com/snoekiede/Esox.SharpAndRusty.AspNetCore.git
cd Esox.SharpAndRusty.AspNetCore

# Restore dependencies
dotnet restore

# Build
dotnet build

# Run tests
dotnet test

Development Guidelines

  1. Write tests first - Follow TDD principles
  2. Maintain 100% coverage - All public APIs must be tested
  3. Follow existing patterns - Use the AAA pattern (Arrange-Act-Assert)
  4. Test all frameworks - Ensure compatibility with .NET 8, 9, and 10
  5. Update documentation - Keep README and docs in sync with code changes

Pull Request Process

  1. Create a feature branch (feature/your-feature-name)
  2. Write tests for your changes
  3. Ensure all tests pass (dotnet test)
  4. Update documentation as needed
  5. Submit a pull request with a clear description

Code Style

  • Use C# 12 features where appropriate
  • Enable nullable reference types
  • Follow standard C# conventions
  • Keep methods focused and single-purpose
  • Add XML documentation comments for public APIs

Documentation


Dependencies

Production

  • Esox.SharpAndRusty (1.5.1) - Core functional types library
  • Microsoft.AspNetCore.App (Framework) - ASP.NET Core shared framework
  • Microsoft.Extensions.Logging (10.0.5) - Logging abstractions

Development/Testing

  • xUnit (2.9.3) - Testing framework
  • xunit.runner.visualstudio (3.1.4) - Visual Studio test adapter
  • Microsoft.NET.Test.Sdk (17.14.1) - .NET test SDK
  • Moq (4.20.72) - Mocking framework
  • coverlet.collector (6.0.4) - Code coverage collection

Versioning

This project uses Semantic Versioning:

  • MAJOR version for incompatible API changes
  • MINOR version for new functionality in a backward compatible manner
  • PATCH version for backward compatible bug fixes

License

This project is licensed under the MIT License - see the LICENSE file for details.


Support


Acknowledgments

Built with ❤️ using:


Made with functional programming principles and type safety in mind.

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

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
1.1.1 90 5/23/2026
1.1.0 90 5/15/2026
1.0.1 106 4/14/2026
1.0.0 111 3/15/2026