From e598819ac47f0f5f58998a1c48ffab86c9d0c737 Mon Sep 17 00:00:00 2001 From: AKaliumhexacyanoferrat Date: Mon, 1 Jul 2024 15:26:54 +0200 Subject: [PATCH] Add structured error mapper --- Modules/ErrorHandling/ErrorHandler.cs | 14 ++- .../GenHTTP.Modules.ErrorHandling.csproj | 1 + Modules/ErrorHandling/Provider/ErrorSentry.cs | 5 +- .../ErrorHandling/StructuredErrorMapper.cs | 85 ++++++++++++++++ .../Acceptance/Engine/DeveloperModeTests.cs | 4 +- .../StructuredErrorMapperTests.cs | 98 +++++++++++++++++++ 6 files changed, 201 insertions(+), 6 deletions(-) create mode 100644 Modules/ErrorHandling/StructuredErrorMapper.cs create mode 100644 Testing/Acceptance/Modules/ErrorHandling/StructuredErrorMapperTests.cs diff --git a/Modules/ErrorHandling/ErrorHandler.cs b/Modules/ErrorHandling/ErrorHandler.cs index 610f856a..e3ca8746 100644 --- a/Modules/ErrorHandling/ErrorHandler.cs +++ b/Modules/ErrorHandling/ErrorHandler.cs @@ -2,6 +2,7 @@ using GenHTTP.Api.Content; +using GenHTTP.Modules.Conversion.Providers; using GenHTTP.Modules.ErrorHandling.Provider; namespace GenHTTP.Modules.ErrorHandling @@ -16,10 +17,19 @@ public static class ErrorHandler /// /// /// By default, server errors will be rendered into - /// a HTML template. + /// structured responses. /// /// The default error handler - public static ErrorSentryBuilder Default() => Html(); + public static ErrorSentryBuilder Default() => Structured(); + + /// + /// Ans error handler which will render exceptions into + /// structured error objects serialized to the format + /// requested by the client (e.g. JSON or XML). + /// + /// The serialization configuration to be used + /// A structured error handler + public static ErrorSentryBuilder Structured(SerializationBuilder? serialization = null) => From(new StructuredErrorMapper(serialization?.Build())); /// /// An error handler which will render exceptions into diff --git a/Modules/ErrorHandling/GenHTTP.Modules.ErrorHandling.csproj b/Modules/ErrorHandling/GenHTTP.Modules.ErrorHandling.csproj index 24a48455..35be4a4c 100644 --- a/Modules/ErrorHandling/GenHTTP.Modules.ErrorHandling.csproj +++ b/Modules/ErrorHandling/GenHTTP.Modules.ErrorHandling.csproj @@ -44,6 +44,7 @@ + diff --git a/Modules/ErrorHandling/Provider/ErrorSentry.cs b/Modules/ErrorHandling/Provider/ErrorSentry.cs index 29da6d11..0117e6fc 100644 --- a/Modules/ErrorHandling/Provider/ErrorSentry.cs +++ b/Modules/ErrorHandling/Provider/ErrorSentry.cs @@ -43,9 +43,8 @@ public ErrorSentry(IHandler parent, Func contentFactory, IEr { try { - var response = await Content.HandleAsync(request) - ; - + var response = await Content.HandleAsync(request); + if (response is null) { return await ErrorHandler.GetNotFound(request, Content); diff --git a/Modules/ErrorHandling/StructuredErrorMapper.cs b/Modules/ErrorHandling/StructuredErrorMapper.cs new file mode 100644 index 00000000..1cd3cdce --- /dev/null +++ b/Modules/ErrorHandling/StructuredErrorMapper.cs @@ -0,0 +1,85 @@ +using System; +using System.Threading.Tasks; + +using GenHTTP.Api.Content; +using GenHTTP.Api.Protocol; + +using GenHTTP.Modules.Conversion; +using GenHTTP.Modules.Conversion.Providers; +using GenHTTP.Modules.Conversion.Providers.Json; + +namespace GenHTTP.Modules.ErrorHandling +{ + + public sealed class StructuredErrorMapper : IErrorMapper + { + + #region Supporting data structures + + public record class ErrorModel(ResponseStatus Status, string Message, string? StackTrace = null); + + #endregion + + #region Get-/Setters + + public SerializationRegistry Registry { get; } + + #endregion + + #region Initialization + + public StructuredErrorMapper(SerializationRegistry? registry) + { + Registry = registry ?? Serialization.Default().Build(); + } + + #endregion + + #region Functionality + + public async ValueTask Map(IRequest request, IHandler handler, Exception error) + { + string? stackTrace = null; + + if (request.Server.Development) + { + stackTrace = error.StackTrace; + } + + if (error is ProviderException e) + { + var model = new ErrorModel(e.Status, error.Message, stackTrace); + + return await RenderAsync(request, model); + } + else + { + var model = new ErrorModel(ResponseStatus.InternalServerError, error.Message, stackTrace); + + return await RenderAsync(request, model); + } + } + + public async ValueTask GetNotFound(IRequest request, IHandler handler) + { + var model = new ErrorModel(ResponseStatus.NotFound, "The requested resource does not exist on this server"); + + return await RenderAsync(request, model); + } + + private async ValueTask RenderAsync(IRequest request, ErrorModel model) + { + var format = Registry.GetSerialization(request) ?? new JsonFormat(); + + var response = await format.SerializeAsync(request, model); + + response.Status(model.Status); + + return response.Build(); + } + + #endregion + + } + +} diff --git a/Testing/Acceptance/Engine/DeveloperModeTests.cs b/Testing/Acceptance/Engine/DeveloperModeTests.cs index 47fe852f..18cc7b57 100644 --- a/Testing/Acceptance/Engine/DeveloperModeTests.cs +++ b/Testing/Acceptance/Engine/DeveloperModeTests.cs @@ -50,7 +50,9 @@ public async Task TestExceptionsWithTrace() using var response = await runner.GetResponseAsync(); - Assert.IsTrue((await response.GetContentAsync()).Contains("Exception")); + var content = await response.GetContentAsync(); + + Assert.IsTrue(content.Contains("HandleAsync")); } /// diff --git a/Testing/Acceptance/Modules/ErrorHandling/StructuredErrorMapperTests.cs b/Testing/Acceptance/Modules/ErrorHandling/StructuredErrorMapperTests.cs new file mode 100644 index 00000000..7227bf43 --- /dev/null +++ b/Testing/Acceptance/Modules/ErrorHandling/StructuredErrorMapperTests.cs @@ -0,0 +1,98 @@ +using System; +using System.Net; +using System.Threading.Tasks; + +using GenHTTP.Api.Content; +using GenHTTP.Api.Protocol; + +using GenHTTP.Modules.ErrorHandling; +using GenHTTP.Modules.Functional; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace GenHTTP.Testing.Acceptance.Modules.ErrorHandling +{ + + [TestClass] + public sealed class StructuredErrorMapperTests + { + + [TestMethod] + public async Task TestNotFound() + { + using var host = TestHost.Run(Inline.Create()); + + using var response = await host.GetResponseAsync(); + + await response.AssertStatusAsync(HttpStatusCode.NotFound); + + var model = await response.GetContentAsync(); + + Assert.AreEqual(ResponseStatus.NotFound, model.Status); + + Assert.IsNull(model.StackTrace); + } + + [TestMethod] + public async Task TestGeneralError() + { + var handler = Inline.Create() + .Get(() => DoThrow(new Exception("Oops"))); + + using var host = TestHost.Run(handler); + + using var response = await host.GetResponseAsync(); + + await response.AssertStatusAsync(HttpStatusCode.InternalServerError); + + var model = await response.GetContentAsync(); + + Assert.AreEqual(ResponseStatus.InternalServerError, model.Status); + + Assert.IsNotNull(model.StackTrace); + } + + [TestMethod] + public async Task TestProviderError() + { + var handler = Inline.Create() + .Get(() => DoThrow(new ProviderException(ResponseStatus.Locked, "Locked up!"))); + + using var host = TestHost.Run(handler); + + using var response = await host.GetResponseAsync(); + + await response.AssertStatusAsync(HttpStatusCode.Locked); + + var model = await response.GetContentAsync(); + + Assert.AreEqual(ResponseStatus.Locked, model.Status); + + Assert.IsNotNull(model.StackTrace); + } + + [TestMethod] + public async Task TestNoTraceInProduction() + { + var handler = Inline.Create() + .Get(() => DoThrow(new Exception("Oops"))); + + using var host = TestHost.Run(handler, development: false); + + using var response = await host.GetResponseAsync(); + + await response.AssertStatusAsync(HttpStatusCode.InternalServerError); + + var model = await response.GetContentAsync(); + + Assert.IsNull(model.StackTrace); + } + + private static void DoThrow(Exception e) + { + throw e; + } + + } + +}