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
68 changes: 68 additions & 0 deletions FunctionalUseCases.AspNetCore/ExecutionResultHttpExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;

namespace FunctionalUseCases.AspNetCore;

public static class ExecutionResultHttpExtensions
{
public static IActionResult ToActionResult<T>(
this ExecutionResult<T> result,
ExecutionResultHttpOptions? options = null)
where T : notnull =>
result.ExecutionSucceeded
? new OkObjectResult(result.CheckedValue)
: CreateErrorResult(result.Error, options);

public static IActionResult ToActionResult(
this ExecutionResult result,
ExecutionResultHttpOptions? options = null) =>
result.ExecutionSucceeded
? new NoContentResult()
: CreateErrorResult(result.Error, options);

public static ProblemDetails ToProblemDetails(
this ExecutionError error,
ExecutionResultHttpOptions? options = null)
{
ArgumentNullException.ThrowIfNull(error);

options ??= new ExecutionResultHttpOptions();
var statusCode = options.StatusCodeSelector(error);
var problemDetails = new ProblemDetails
{
Status = statusCode,
Title = "Use case execution failed",
Detail = error.Message
};

if (error.ErrorCode is not null)
{
problemDetails.Extensions["errorCode"] = error.ErrorCode;
}

foreach (var property in error.Properties)
{
problemDetails.Extensions[property.Key] = property.Value;
}

if (options.IncludeExceptionDetails && error.Exception is not null)
{
problemDetails.Extensions["exceptionType"] = error.Exception.GetType().FullName;
problemDetails.Extensions["exception"] = error.Exception.ToString();
}

return problemDetails;
}

private static ObjectResult CreateErrorResult(
ExecutionError? error,
ExecutionResultHttpOptions? options)
{
error ??= new ExecutionError("Unknown Error");
var problemDetails = error.ToProblemDetails(options);
return new ObjectResult(problemDetails)
{
StatusCode = problemDetails.Status ?? StatusCodes.Status500InternalServerError
};
}
}
24 changes: 24 additions & 0 deletions FunctionalUseCases.AspNetCore/ExecutionResultHttpOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using Microsoft.AspNetCore.Http;

namespace FunctionalUseCases.AspNetCore;

public sealed class ExecutionResultHttpOptions
{
public Func<ExecutionError, int> StatusCodeSelector { get; init; } = DefaultStatusCodeSelector;

public bool IncludeExceptionDetails { get; init; }

private static int DefaultStatusCodeSelector(ExecutionError error)
{
if (error.Properties.TryGetValue("statusCode", out var statusCode) &&
statusCode is int propertyStatusCode)
{
return propertyStatusCode;
}

return int.TryParse(error.ErrorCode, out var errorCode) &&
errorCode is >= 400 and <= 599
? errorCode
: StatusCodes.Status500InternalServerError;
}
}
32 changes: 32 additions & 0 deletions FunctionalUseCases.AspNetCore/FunctionalUseCases.AspNetCore.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>

<PackageId>FunctionalUseCases.AspNetCore</PackageId>
<Authors>Thomas Berghold-Wieser</Authors>
<Description>ASP.NET Core result mapping extensions for FunctionalUseCases.</Description>
<PackageProjectUrl>https://github.com/ThomasBergholdWieser/FunctionalUseCases</PackageProjectUrl>
<RepositoryUrl>https://github.com/ThomasBergholdWieser/FunctionalUseCases</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageTags>aspnetcore;functional;use-cases;problem-details</PackageTags>
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>

<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Nerdbank.GitVersioning" Version="3.7.115">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<ProjectReference Include="..\FunctionalUseCases\FunctionalUseCases.csproj" />
</ItemGroup>

<ItemGroup>
<None Include="..\README.md" Pack="true" PackagePath="/" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
using FunctionalUseCases.AspNetCore;
using Microsoft.AspNetCore.Mvc;

namespace FunctionalUseCases.Tests;

public class AspNetCoreExecutionResultExtensionsTests
{
[Fact]
public void ToActionResult_WithSuccess_ShouldReturnOkObjectResult()
{
var result = Execution.Success("value");

var actionResult = result.ToActionResult();

var okResult = actionResult.ShouldBeOfType<OkObjectResult>();
okResult.Value.ShouldBe("value");
}

[Fact]
public void ToActionResult_WithFailure_ShouldReturnProblemDetails()
{
var result = Execution.Failure<string>(
"Customer missing",
"CUSTOMER_NOT_FOUND",
properties: new Dictionary<string, object?>
{
["statusCode"] = 404,
["customerId"] = 42
});

var actionResult = result.ToActionResult();

var objectResult = actionResult.ShouldBeOfType<ObjectResult>();
objectResult.StatusCode.ShouldBe(404);
var problemDetails = objectResult.Value.ShouldBeOfType<ProblemDetails>();
problemDetails.Detail.ShouldBe("Customer missing");
problemDetails.Extensions["errorCode"].ShouldBe("CUSTOMER_NOT_FOUND");
problemDetails.Extensions["customerId"].ShouldBe(42);
}

[Fact]
public void ToProblemDetails_ShouldHideExceptionByDefault()
{
var error = Execution.Failure("Failed", new InvalidOperationException("Sensitive")).CheckedError;

var problemDetails = error.ToProblemDetails();

problemDetails.Extensions.ShouldNotContainKey("exception");
}

[Fact]
public void ToProblemDetails_WithOption_ShouldIncludeExceptionDetails()
{
var error = Execution.Failure("Failed", new InvalidOperationException("Sensitive")).CheckedError;

var problemDetails = error.ToProblemDetails(new ExecutionResultHttpOptions
{
IncludeExceptionDetails = true
});

problemDetails.Extensions["exceptionType"].ShouldBe(typeof(InvalidOperationException).FullName);
problemDetails.Extensions["exception"].ShouldBeOfType<string>().ShouldContain("Sensitive");
}
}
8 changes: 5 additions & 3 deletions FunctionalUseCases.Tests/ExecutionErrorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,14 @@ public void ExecutionError_Properties_ShouldBeSettable()
var error = new ExecutionError("test");

// Act
error.ErrorCode = 404;
error.ErrorCode = "NOT_FOUND";
error.LogLevel = LogLevel.Warning;
error.Properties["resource"] = "customer";

// Assert
error.ErrorCode.ShouldBe(404);
error.ErrorCode.ShouldBe("NOT_FOUND");
error.LogLevel.ShouldBe(LogLevel.Warning);
error.Properties["resource"].ShouldBe("customer");
}
}

Expand Down Expand Up @@ -105,4 +107,4 @@ public void ExecutionError_Generic_WithEnumerable_ShouldWork()
error.Message.ShouldBe("100; 200");
error.Messages.Count.ShouldBe(2);
}
}
}
12 changes: 11 additions & 1 deletion FunctionalUseCases.Tests/ExecutionExceptionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,14 @@ public void ExecutionException_ShouldInheritFromException()
// Act & Assert
exception.ShouldBeAssignableTo<Exception>();
}
}

[Fact]
public void ExecutionException_Constructor_ShouldSetInnerException()
{
var innerException = new InvalidOperationException("Original");

var exception = new ExecutionException("Wrapped", innerException);

exception.InnerException.ShouldBeSameAs(innerException);
}
}
17 changes: 16 additions & 1 deletion FunctionalUseCases.Tests/ExecutionResultExtensionsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,19 @@ public void Log_WithFailedResult_UsingTestLogger_ShouldLogCorrectMessage()
result.Error!.Logged.ShouldBeTrue();
}

[Fact]
public void Log_WithException_ShouldPassOriginalExceptionToLogger()
{
var exception = new InvalidOperationException("Original");
var result = Execution.Failure("Failed", exception);
var testLogger = new TestLogger();

result.Log(testLogger);

testLogger.LoggedMessages.ShouldHaveSingleItem();
testLogger.LoggedMessages[0].Exception.ShouldBeSameAs(exception);
}

private class TestLogger : ILogger
{
public List<LogEntry> LoggedMessages { get; } = new();
Expand All @@ -146,14 +159,16 @@ public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Except
LoggedMessages.Add(new LogEntry
{
LogLevel = logLevel,
Message = formatter(state, exception)
Message = formatter(state, exception),
Exception = exception
});
}

public class LogEntry
{
public LogLevel LogLevel { get; set; }
public string Message { get; set; } = string.Empty;
public Exception? Exception { get; set; }
}
}
}
54 changes: 51 additions & 3 deletions FunctionalUseCases.Tests/ExecutionResultGenericTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,58 @@ public void ExecutionResult_Failure_ShouldReturnFailedResult()
public void ExecutionResult_CheckedValue_ShouldThrowWhenFailed()
{
// Arrange
var result = Execution.Failure<string>("Test error");
var originalException = new InvalidOperationException("Original error");
var result = Execution.Failure<string>("Test error", originalException);

// Act & Assert
Should.Throw<NullReferenceException>(() => result.CheckedValue);
var exception = Should.Throw<ExecutionException>(() => result.CheckedValue);
exception.Message.ShouldContain("Test error");
exception.InnerException.ShouldBeSameAs(originalException);
}

[Fact]
public void ExecutionResult_Match_ShouldHandleSuccessAndFailure()
{
var success = Execution.Success(21);
var failure = Execution.Failure<int>("No value");

success.Match(value => value * 2, _ => -1).ShouldBe(42);
failure.Match(value => value * 2, error => error.Message.Length).ShouldBe(8);
}

[Fact]
public void ExecutionResult_Map_ShouldTransformSuccessAndPreserveFailure()
{
var originalException = new InvalidOperationException("Original");
var success = Execution.Success(21);
var failure = Execution.Failure<int>("No value", originalException);

success.Map(value => value * 2).CheckedValue.ShouldBe(42);

var mappedFailure = failure.Map(value => value * 2);
mappedFailure.ExecutionFailed.ShouldBeTrue();
mappedFailure.Error!.Exception.ShouldBeSameAs(originalException);
}

[Fact]
public void ExecutionResult_Bind_ShouldComposeResults()
{
var success = Execution.Success(21);

var result = success.Bind(value => Execution.Success(value * 2));

result.CheckedValue.ShouldBe(42);
}

[Fact]
public void ExecutionResult_GetValueOrThrow_ShouldIncludeCustomMessage()
{
var result = Execution.Failure<int>("No value");

var exception = Should.Throw<ExecutionException>(() =>
result.GetValueOrThrow("Cannot continue"));

exception.Message.ShouldBe("Cannot continue: No value");
}

[Fact]
Expand Down Expand Up @@ -98,4 +146,4 @@ public void ExecutionResult_Combine_ShouldReturnFailureWhenAnyFails()
// Assert
combined.ExecutionFailed.ShouldBeTrue();
}
}
}
6 changes: 4 additions & 2 deletions FunctionalUseCases.Tests/ExecutionResultTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,13 @@ public void ExecutionResult_Failure_ShouldReturnFailedResult()
public void ExecutionResult_ThrowIfFailed_ShouldThrowWhenFailed()
{
// Arrange
var result = Execution.Failure("Test error");
var originalException = new InvalidOperationException("Original");
var result = Execution.Failure("Test error", originalException);

// Act & Assert
var exception = Should.Throw<ExecutionException>(() => result.ThrowIfFailed());
exception.Message.ShouldContain("Test error");
exception.InnerException.ShouldBeSameAs(originalException);
}

[Fact]
Expand All @@ -50,4 +52,4 @@ public void ExecutionResult_ThrowIfFailed_ShouldNotThrowWhenSuccessful()
// Act & Assert (no exception should be thrown)
result.ThrowIfFailed();
}
}
}
Loading
Loading