Skip to content

Commit

Permalink
Merge pull request #95 from wemogy/fix/error-handling
Browse files Browse the repository at this point in the history
fix: improved error handling in remote processing
  • Loading branch information
SebastianKuesters committed Jul 10, 2024
2 parents 21d276e + d4b5497 commit d0fc470
Show file tree
Hide file tree
Showing 20 changed files with 418 additions and 200 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ jobs:
tests:
name: Tests
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v3

Expand Down
322 changes: 161 additions & 161 deletions src/Wemogy.Cqrs.sln

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/core/Wemogy.CQRS/Wemogy.CQRS.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,6 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Wemogy.Core" Version="3.2.1" />
<PackageReference Include="Wemogy.Core" Version="3.2.2" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -1,25 +1,36 @@
using Wemogy.Core.Errors;
using Wemogy.Core.Errors.Enums;
using Wemogy.Core.Errors.Exceptions;
using Wemogy.CQRS.Commands.Abstractions;

namespace Wemogy.CQRS.Extensions.FastEndpoints.TestWebApp.Commands.ThrowError;

public class ThrowErrorCommandHandler : ICommandHandler<ThrowErrorCommand>
{
public Task HandleAsync(ThrowErrorCommand command)
{
throw new CustomErrorException(
command.ErrorType,
"CustomError",
"This is a custom error",
null);
}
private const string DummyErrorCode = "DummyErrorCode";
private const string DummyErrorDescription = "DummyErrorDescription";

class CustomErrorException : ErrorException
public Task HandleAsync(ThrowErrorCommand command)
{
public CustomErrorException(ErrorType errorType, string code, string description, Exception? innerException)
: base(errorType, code, description, innerException)
switch (command.ErrorType)
{
case ErrorType.Failure:
throw Error.Failure(DummyErrorCode, DummyErrorDescription);
case ErrorType.Unexpected:
throw Error.Unexpected(DummyErrorCode, DummyErrorDescription);
case ErrorType.Validation:
throw Error.Validation(DummyErrorCode, DummyErrorDescription);
case ErrorType.Conflict:
throw Error.Conflict(DummyErrorCode, DummyErrorDescription);
case ErrorType.NotFound:
throw Error.NotFound(DummyErrorCode, DummyErrorDescription);
case ErrorType.Authorization:
throw Error.Authorization(DummyErrorCode, DummyErrorDescription);
case ErrorType.PreconditionFailed:
throw Error.PreconditionFailed(DummyErrorCode, DummyErrorDescription);
}

throw Error.Unexpected(
"ErrorTypeNotSupported",
$"The ErrorType {command.ErrorType} is not supported!");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using Wemogy.Core.Errors.Enums;
using Wemogy.Core.Errors.Exceptions;
using Wemogy.CQRS.Commands.Abstractions;
using Wemogy.CQRS.Extensions.FastEndpoints.TestWebApp.Commands.ThrowError;

namespace Wemogy.CQRS.Extensions.FastEndpoints.UnitTests.Endpoints;

public class CommandEndpointBaseExceptionTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;

public CommandEndpointBaseExceptionTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}

[Theory]
[InlineData(ErrorType.Failure)]
[InlineData(ErrorType.Unexpected)]
[InlineData(ErrorType.Validation)]
[InlineData(ErrorType.Conflict)]
[InlineData(ErrorType.NotFound)]
[InlineData(ErrorType.Authorization)]
[InlineData(ErrorType.PreconditionFailed)]
public async Task PrintHelloWorldCommand_ShouldReturnCorrectErrorException(ErrorType errorType)
{
// Arrange
var client = _factory.CreateClient();
var serviceCollection = new ServiceCollection();
serviceCollection.AddCQRS(typeof(ThrowErrorCommand).Assembly)
.AddRemoteHttpServer(client)
.ConfigureRemoteCommandProcessing<ThrowErrorCommand>("api/commands/throw-error");
var commands = serviceCollection.BuildServiceProvider().GetRequiredService<ICommands>();
var throwErrorCommand = new ThrowErrorCommand(errorType);

// Act
var exception = await Record.ExceptionAsync(() => commands.RunAsync(throwErrorCommand));

// Assert
exception.Should().NotBeNull().And.BeAssignableTo<ErrorException>()
.Which.ErrorType.Should().Be(errorType);
exception?.GetType().Name.Should().Be($"{errorType}ErrorException");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
using FastEndpoints;
using FluentAssertions;
using Moq;
using Wemogy.CQRS.Extensions.FastEndpoints.Extensions;

namespace Wemogy.CQRS.Extensions.FastEndpoints.UnitTests.Extensions;

public class PostProcessorContextExtensionsTests
{
/// <summary>
/// This method checks if the MarkExceptionAsHandled implementation from FastEndpoints is working as expected
/// This is required, because the implementation is only internal accessible, so we need to hardcode the key
/// </summary>
[Fact]
public void MarkExceptionAsHandled_ShouldHaveTheExpectedImplementation()
{
// Arrange
var defaultHttpContext = new DefaultHttpContext();
var mockContext = new Mock<IPostProcessorContext>
{
// enable CallBase to tell Moq to use the default interface implementations (like MarkExceptionAsHandled)
CallBase = true
};
mockContext.SetupGet(m => m.HttpContext).Returns(defaultHttpContext);

// Act
mockContext.Object.MarkExceptionAsHandled();

// Assert
defaultHttpContext.Items.Should().ContainSingle(x => ReferenceEquals(x.Key, "3") && x.Value == null);
}

[Fact]
public void IsExceptionHandled_ShouldBeTrueIfMarkExceptionAsHandledWasCalled()
{
// Arrange
var defaultHttpContext = new DefaultHttpContext();
var mockContext = new Mock<IPostProcessorContext>
{
// enable CallBase to tell Moq to use the default interface implementations (like MarkExceptionAsHandled)
CallBase = true
};
mockContext.SetupGet(m => m.HttpContext).Returns(defaultHttpContext);
mockContext.Object.MarkExceptionAsHandled();

// Act
var result = mockContext.Object.IsExceptionHandled();

// Assert
result.Should().BeTrue();
}

[Fact]
public void IsExceptionHandled_ShouldBeFalseIfMarkExceptionAsHandledWasNotCalled()
{
// Arrange
var defaultHttpContext = new DefaultHttpContext();
var mockContext = new Mock<IPostProcessorContext>
{
// enable CallBase to tell Moq to use the default interface implementations (like MarkExceptionAsHandled)
CallBase = true
};
mockContext.SetupGet(m => m.HttpContext).Returns(defaultHttpContext);

// Act
var result = mockContext.Object.IsExceptionHandled();

// Assert
result.Should().BeFalse();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.6" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.0"/>
<PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="xunit" Version="2.4.2"/>
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Wemogy.CQRS.Abstractions;
using Wemogy.CQRS.Commands.Abstractions;
using Wemogy.CQRS.Common.ValueObjects;
using Wemogy.CQRS.Extensions.FastEndpoints.PostProcessors;
using Wemogy.CQRS.Setup;
using ICommand = Wemogy.CQRS.Commands.Abstractions.ICommand;

Expand All @@ -30,6 +31,7 @@ public override void Configure()

// ToDo: remove this
AllowAnonymous();
PostProcessor<CqrsEndpointExceptionPostProcessor<CommandRequest<TCommand>>>();
}

public override async Task HandleAsync(CommandRequest<TCommand> req, CancellationToken ct)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Wemogy.CQRS.Abstractions;
using Wemogy.CQRS.Commands.Abstractions;
using Wemogy.CQRS.Common.ValueObjects;
using Wemogy.CQRS.Extensions.FastEndpoints.PostProcessors;
using Wemogy.CQRS.Setup;

namespace Wemogy.CQRS.Extensions.FastEndpoints.Endpoints;
Expand All @@ -29,6 +30,7 @@ public override void Configure()

// ToDo: remove this
AllowAnonymous();
PostProcessor<CqrsEndpointExceptionPostProcessor<CommandRequest<TCommand>, TResult>>();
}

public override async Task HandleAsync(CommandRequest<TCommand> req, CancellationToken ct)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Wemogy.Core.Extensions;
using Wemogy.CQRS.Abstractions;
using Wemogy.CQRS.Common.ValueObjects;
using Wemogy.CQRS.Extensions.FastEndpoints.PostProcessors;
using Wemogy.CQRS.Queries.Abstractions;
using Wemogy.CQRS.Setup;

Expand All @@ -30,6 +31,7 @@ public override void Configure()

// ToDo: remove this
AllowAnonymous();
PostProcessor<CqrsEndpointExceptionPostProcessor<QueryRequest<TQuery>, TResult>>();
}

public override async Task HandleAsync(QueryRequest<TQuery> req, CancellationToken ct)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ public static class EndpointDefinitionExtensions
{
public static void AddErrorHandlerPostProcessor(this EndpointDefinition endpointDefinition)
{
endpointDefinition.PostProcessor<ErrorHandlerPostProcessor>(Order.Before);
// Add the global ErrorHandlerPostProcessor after the endpoint post processors are executed
endpointDefinition.PostProcessor<ErrorHandlerPostProcessor>(Order.After);
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using FastEndpoints;

namespace Wemogy.CQRS.Extensions.FastEndpoints.Extensions;

public static class PostProcessorContextExtensions
{
/// <summary>
/// The MarkExceptionAsHandled method add the CtxKey.EdiIsHandled key to the HttpContext.Items dictionary
/// This method checks if the CtxKey.EdiIsHandled key is present in the HttpContext.Items dictionary
/// </summary>
public static bool IsExceptionHandled(this IPostProcessorContext context)
{
// The CtxKey class is an internal class of FastEndpoints which means that we can't access it
// for that reason we hardcoded the value of the key
// The PostProcessorContextExtensionsTests class tests this implementation, so if the key changes the tests will fail
return context.HttpContext.Items.ContainsKey("3");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace Wemogy.CQRS.Extensions.FastEndpoints.PostProcessors;

public class CqrsEndpointExceptionPostProcessor<TRequest> : CqrsEndpointExceptionPostProcessor<TRequest, object?>
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using System.Net;
using FastEndpoints;
using Wemogy.Core.Errors.Exceptions;
using Wemogy.Core.Errors.Extensions;
using Wemogy.Core.Json.ExceptionInformation;
using Wemogy.CQRS.Extensions.FastEndpoints.Extensions;

namespace Wemogy.CQRS.Extensions.FastEndpoints.PostProcessors;

public class CqrsEndpointExceptionPostProcessor<TRequest, TResponse> : IPostProcessor<TRequest, TResponse>
{
public Task PostProcessAsync(IPostProcessorContext<TRequest, TResponse> context, CancellationToken ct)
{
if (!context.HasExceptionOccurred)
{
return Task.CompletedTask;
}

var exception = context.ExceptionDispatchInfo.SourceException;

var statusCode = (exception as ErrorException)?.ErrorType.ToHttpStatusCode() ?? HttpStatusCode.InternalServerError;

context.MarkExceptionAsHandled();

context.HttpContext.Response.Headers.AppendJsonTypeHeader<ExceptionInformation>();
return context.HttpContext.Response.SendStringAsync(exception.ToJson(), (int)statusCode, cancellation: ct);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ public class ErrorHandlerPostProcessor : IGlobalPostProcessor
{
public async Task PostProcessAsync(IPostProcessorContext context, CancellationToken ct)
{
if (!context.HasExceptionOccurred)
if (!context.HasExceptionOccurred || context.IsExceptionHandled())
{
return;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using RestSharp;
using Wemogy.Core.Errors;
using Wemogy.Core.Extensions;
using Wemogy.Core.Json.ExceptionInformation;
using Wemogy.CQRS.Abstractions;
Expand Down Expand Up @@ -41,8 +42,17 @@ public async Task RunAsync(CommandRequest<TCommand> command)
throw response.ErrorException ?? new Exception(response.Content);
}

// ToDo: Handle the exception information
var exceptionInformation = response.Content?.FromJson<ExceptionInformation>();

if (exceptionInformation == null)
{
throw Error.Unexpected(
"ExceptionInformationMissing",
"The response from the remote service did not contain any exception information.");
}

var exception = exceptionInformation.ToException();
throw exception;
}
}
catch (HttpRequestException e)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
using System.Text.Json;
using RestSharp;
using Wemogy.Core.Errors;
using Wemogy.Core.Extensions;
using Wemogy.Core.Json.ExceptionInformation;
using Wemogy.CQRS.Abstractions;
using Wemogy.CQRS.Commands.Abstractions;
using Wemogy.CQRS.Common.ValueObjects;
using Wemogy.CQRS.Extensions.FastEndpoints.Common;
using Wemogy.CQRS.Extensions.FastEndpoints.Extensions;

namespace Wemogy.CQRS.Extensions.FastEndpoints.RemoteCommandRunners;

Expand Down Expand Up @@ -33,7 +37,22 @@ public async Task<TResult> RunAsync(CommandRequest<TCommand> command)

if (!response.IsSuccessful)
{
throw new Exception($"Failed to run command {command.Command.GetType().Name}");
if (response.Headers == null || !response.Headers.HasJsonTypeHeader<ExceptionInformation>())
{
throw response.ErrorException ?? new Exception(response.Content);
}

var exceptionInformation = response.Content?.FromJson<ExceptionInformation>();

if (exceptionInformation == null)
{
throw Error.Unexpected(
"ExceptionInformationMissing",
"The response from the remote service did not contain any exception information.");
}

var exception = exceptionInformation.ToException();
throw exception;
}

if (response.Content == null)
Expand Down
Loading

0 comments on commit d0fc470

Please sign in to comment.