Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: improved error handling in remote processing #95

Merged
merged 4 commits into from
Jul 10, 2024
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
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
Loading