From ac1e52480f3269ba3b2cebbc0d48901e791ed9fb Mon Sep 17 00:00:00 2001 From: Artemiy Izakov Date: Mon, 18 Dec 2023 18:15:52 +0500 Subject: [PATCH 1/3] feat(server): add exceptions logging --- .../Filters/JsonRpcExceptionFilter.cs | 19 +++++++- .../PublicAPI.Shipped.txt | 2 + .../Settings/JsonRpcServerOptions.cs | 8 ++++ .../Filters/JsonRpcExceptionFilterTests.cs | 47 ++++++++++++++++++- 4 files changed, 73 insertions(+), 3 deletions(-) diff --git a/src/Tochka.JsonRpc.Server/Filters/JsonRpcExceptionFilter.cs b/src/Tochka.JsonRpc.Server/Filters/JsonRpcExceptionFilter.cs index 986038f3..3dc6db25 100644 --- a/src/Tochka.JsonRpc.Server/Filters/JsonRpcExceptionFilter.cs +++ b/src/Tochka.JsonRpc.Server/Filters/JsonRpcExceptionFilter.cs @@ -1,19 +1,29 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Tochka.JsonRpc.Server.Extensions; using Tochka.JsonRpc.Server.Services; +using Tochka.JsonRpc.Server.Settings; namespace Tochka.JsonRpc.Server.Filters; /// /// -/// Filter for JSON-RPC actions to convert exceptions to JSON-RPC error +/// Filter for JSON-RPC actions to log exceptions and convert them to JSON-RPC error /// internal class JsonRpcExceptionFilter : IExceptionFilter { private readonly IJsonRpcErrorFactory errorFactory; + private readonly JsonRpcServerOptions options; + private readonly ILogger log; - public JsonRpcExceptionFilter(IJsonRpcErrorFactory errorFactory) => this.errorFactory = errorFactory; + public JsonRpcExceptionFilter(IJsonRpcErrorFactory errorFactory, IOptions options, ILogger log) + { + this.errorFactory = errorFactory; + this.options = options.Value; + this.log = log; + } // wrap exception in json rpc error public void OnException(ExceptionContext context) @@ -23,6 +33,11 @@ public void OnException(ExceptionContext context) return; } + if (options.LogExceptions) + { + log.LogError(context.Exception, "Exception during JSON-RPC call processing"); + } + var error = errorFactory.Exception(context.Exception); context.Result = new ObjectResult(error); } diff --git a/src/Tochka.JsonRpc.Server/PublicAPI.Shipped.txt b/src/Tochka.JsonRpc.Server/PublicAPI.Shipped.txt index 0896c9b5..18aad8ee 100644 --- a/src/Tochka.JsonRpc.Server/PublicAPI.Shipped.txt +++ b/src/Tochka.JsonRpc.Server/PublicAPI.Shipped.txt @@ -181,3 +181,5 @@ Tochka.JsonRpc.Server.Middlewares.JsonRpcRequestLoggingMiddleware Tochka.JsonRpc.Server.Middlewares.JsonRpcRequestLoggingMiddleware.Invoke(Microsoft.AspNetCore.Http.HttpContext! context) -> System.Threading.Tasks.Task! Tochka.JsonRpc.Server.Middlewares.JsonRpcRequestLoggingMiddleware.JsonRpcRequestLoggingMiddleware(Microsoft.AspNetCore.Http.RequestDelegate! next, Microsoft.Extensions.Logging.ILogger! log) -> void static Tochka.JsonRpc.Server.Extensions.DependencyInjectionExtensions.WithJsonRpcRequestLogging(this Microsoft.AspNetCore.Builder.IApplicationBuilder! app) -> Microsoft.AspNetCore.Builder.IApplicationBuilder! +Tochka.JsonRpc.Server.Settings.JsonRpcServerOptions.LogExceptions.get -> bool +Tochka.JsonRpc.Server.Settings.JsonRpcServerOptions.LogExceptions.set -> void diff --git a/src/Tochka.JsonRpc.Server/Settings/JsonRpcServerOptions.cs b/src/Tochka.JsonRpc.Server/Settings/JsonRpcServerOptions.cs index ec98242a..2516398a 100644 --- a/src/Tochka.JsonRpc.Server/Settings/JsonRpcServerOptions.cs +++ b/src/Tochka.JsonRpc.Server/Settings/JsonRpcServerOptions.cs @@ -63,4 +63,12 @@ public sealed class JsonRpcServerOptions /// Batches will break if this option is enabled and one of requests returns non-json data! /// public bool AllowRawResponses { get; set; } + + /// + /// If `true`, all exceptions during JSON-RPC call processing will be logged with Error log level + /// + /// + /// true by default + /// + public bool LogExceptions { get; set; } = true; } diff --git a/src/tests/Tochka.JsonRpc.Server.Tests/Filters/JsonRpcExceptionFilterTests.cs b/src/tests/Tochka.JsonRpc.Server.Tests/Filters/JsonRpcExceptionFilterTests.cs index 45c12786..4c246283 100644 --- a/src/tests/Tochka.JsonRpc.Server.Tests/Filters/JsonRpcExceptionFilterTests.cs +++ b/src/tests/Tochka.JsonRpc.Server.Tests/Filters/JsonRpcExceptionFilterTests.cs @@ -7,6 +7,8 @@ using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Moq; using NUnit.Framework; using Tochka.JsonRpc.Common.Models.Request; @@ -14,6 +16,7 @@ using Tochka.JsonRpc.Server.Features; using Tochka.JsonRpc.Server.Filters; using Tochka.JsonRpc.Server.Services; +using Tochka.JsonRpc.Server.Settings; namespace Tochka.JsonRpc.Server.Tests.Filters; @@ -21,14 +24,18 @@ namespace Tochka.JsonRpc.Server.Tests.Filters; internal class JsonRpcExceptionFilterTests { private Mock errorFactoryMock; + private JsonRpcServerOptions options; + private Mock> logMock; private JsonRpcExceptionFilter exceptionFilter; [SetUp] public void Setup() { errorFactoryMock = new Mock(); + logMock = new Mock>(); + options = new JsonRpcServerOptions(); - exceptionFilter = new JsonRpcExceptionFilter(errorFactoryMock.Object); + exceptionFilter = new JsonRpcExceptionFilter(errorFactoryMock.Object, Options.Create(options), logMock.Object); } [Test] @@ -69,4 +76,42 @@ public void OnException_JsonRpcCall_SetErrorResult() context.Result.Should().BeEquivalentTo(expected); errorFactoryMock.Verify(); } + + [Test] + public void OnException_LogExceptionsTrue_LogError() + { + var exception = new ArgumentException(); + var httpContext = new DefaultHttpContext(); + httpContext.Features.Set(new JsonRpcFeature { Call = Mock.Of() }); + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor(), new ModelStateDictionary()); + var context = new ExceptionContext(actionContext, new List()) + { + Exception = exception + }; + options.LogExceptions = true; + logMock.Setup(l => l.Log(LogLevel.Error, It.IsAny(), It.IsAny(), exception, It.IsAny>())) + .Verifiable(); + + exceptionFilter.OnException(context); + + logMock.Verify(); + } + + [Test] + public void OnException_LogExceptionsFalse_DontLogAnything() + { + var exception = new ArgumentException(); + var httpContext = new DefaultHttpContext(); + httpContext.Features.Set(new JsonRpcFeature { Call = Mock.Of() }); + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor(), new ModelStateDictionary()); + var context = new ExceptionContext(actionContext, new List()) + { + Exception = exception + }; + options.LogExceptions = false; + + exceptionFilter.OnException(context); + + logMock.VerifyNoOtherCalls(); + } } From 61667ae4b920aea16b7267ed04f4ee545d9aa1c6 Mon Sep 17 00:00:00 2001 From: Artemiy Izakov Date: Mon, 18 Dec 2023 18:16:43 +0500 Subject: [PATCH 2/3] test(server): add tests for custom exception filters and FluentValidation integration --- .../CompatibilityTests.cs | 165 ++++++++++++++---- .../IntegrationTests.cs | 74 +++++++- .../BusinessLogicException.cs | 5 + .../BusinessLogicExceptionHandler.cs | 8 + .../BusinessLogicExceptionHandlingFilter.cs | 20 +++ .../BusinessLogicExceptionWrappingFilter.cs | 27 +++ .../ExceptionsJsonRpcController.cs | 10 ++ .../FluentValidationJsonRpcController.cs | 48 +++++ .../IBusinessLogicExceptionHandler.cs | 6 + .../Program.cs | 14 +- ...Tochka.JsonRpc.Tests.WebApplication.csproj | 14 +- 11 files changed, 350 insertions(+), 41 deletions(-) create mode 100644 src/tests/Tochka.JsonRpc.Tests.WebApplication/BusinessLogicException.cs create mode 100644 src/tests/Tochka.JsonRpc.Tests.WebApplication/BusinessLogicExceptionHandler.cs create mode 100644 src/tests/Tochka.JsonRpc.Tests.WebApplication/BusinessLogicExceptionHandlingFilter.cs create mode 100644 src/tests/Tochka.JsonRpc.Tests.WebApplication/BusinessLogicExceptionWrappingFilter.cs create mode 100644 src/tests/Tochka.JsonRpc.Tests.WebApplication/Controllers/ExceptionsJsonRpcController.cs create mode 100644 src/tests/Tochka.JsonRpc.Tests.WebApplication/Controllers/FluentValidationJsonRpcController.cs create mode 100644 src/tests/Tochka.JsonRpc.Tests.WebApplication/IBusinessLogicExceptionHandler.cs diff --git a/src/tests/Tochka.JsonRpc.Server.Tests.Integration/CompatibilityTests.cs b/src/tests/Tochka.JsonRpc.Server.Tests.Integration/CompatibilityTests.cs index c2c650e7..a17d28bc 100644 --- a/src/tests/Tochka.JsonRpc.Server.Tests.Integration/CompatibilityTests.cs +++ b/src/tests/Tochka.JsonRpc.Server.Tests.Integration/CompatibilityTests.cs @@ -6,6 +6,7 @@ using NUnit.Framework; using Tochka.JsonRpc.Common; using Tochka.JsonRpc.Tests.WebApplication.Auth; +using Tochka.JsonRpc.Tests.WebApplication.Controllers; using Tochka.JsonRpc.TestUtils; using Tochka.JsonRpc.TestUtils.Integration; @@ -18,12 +19,12 @@ internal class CompatibilityTests : IntegrationTestsBase public async Task Authorization_NotAuthorizedButRequired_Return401() { var requestContent = """ - { - "id": 123, - "method": "with_auth", - "jsonrpc": "2.0" - } - """; + { + "id": 123, + "method": "with_auth", + "jsonrpc": "2.0" + } + """; using var request = new StringContent(requestContent, Encoding.UTF8, "application/json"); var response = await ApiClient.PostAsync(JsonRpcConstants.DefaultRoutePrefix, request); @@ -35,23 +36,23 @@ public async Task Authorization_NotAuthorizedButRequired_Return401() public async Task Authorization_NotAuthorizedAndNotRequired_ReturnJsonRpcResponse() { var requestContent = """ - { - "id": 123, - "method": "without_auth", - "jsonrpc": "2.0" - } - """; + { + "id": 123, + "method": "without_auth", + "jsonrpc": "2.0" + } + """; using var request = new StringContent(requestContent, Encoding.UTF8, "application/json"); var response = await ApiClient.PostAsync(JsonRpcConstants.DefaultRoutePrefix, request); var expectedResponse = """ - { - "id": 123, - "result": true, - "jsonrpc": "2.0" - } - """.TrimAllLines(); + { + "id": 123, + "result": true, + "jsonrpc": "2.0" + } + """.TrimAllLines(); response.StatusCode.Should().Be(HttpStatusCode.OK); var responseContent = await response.Content.ReadAsStringAsync(); responseContent.TrimAllLines().Should().Be(expectedResponse); @@ -61,24 +62,128 @@ public async Task Authorization_NotAuthorizedAndNotRequired_ReturnJsonRpcRespons public async Task Authorization_AuthorizedAndRequired_ReturnJsonRpcResponse() { var requestContent = """ - { - "id": 123, - "method": "without_auth", - "jsonrpc": "2.0" - } - """; + { + "id": 123, + "method": "without_auth", + "jsonrpc": "2.0" + } + """; using var request = new StringContent(requestContent, Encoding.UTF8, "application/json"); request.Headers.Add(AuthConstants.Header, AuthConstants.Key); var response = await ApiClient.PostAsync(JsonRpcConstants.DefaultRoutePrefix, request); var expectedResponse = """ - { - "id": 123, - "result": true, - "jsonrpc": "2.0" - } - """.TrimAllLines(); + { + "id": 123, + "result": true, + "jsonrpc": "2.0" + } + """.TrimAllLines(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var responseContent = await response.Content.ReadAsStringAsync(); + responseContent.TrimAllLines().Should().Be(expectedResponse); + } + + [Test] + public async Task FluentValidation_InvalidModel_ReturnJsonRpcResponseWithError() + { + var requestContent = """ + { + "id": 123, + "method": "validate", + "jsonrpc": "2.0", + "params": { + "str": null + } + } + """; + + using var request = new StringContent(requestContent, Encoding.UTF8, "application/json"); + var response = await ApiClient.PostAsync(JsonRpcConstants.DefaultRoutePrefix, request); + + var expectedResponse = $$""" + { + "id": 123, + "error": { + "code": -32602, + "message": "Invalid params", + "data": { + "Str": [ + "{{ModelValidator.Error}}" + ] + } + }, + "jsonrpc": "2.0" + } + """.TrimAllLines(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var responseContent = await response.Content.ReadAsStringAsync(); + responseContent.TrimAllLines().Should().Be(expectedResponse); + } + + [Test] + public async Task FluentValidation_ManualValidationError_ReturnJsonRpcResponseWithError() + { + var requestContent = """ + { + "id": 123, + "method": "validate", + "jsonrpc": "2.0", + "params": { + "str": "12" + } + } + """; + + using var request = new StringContent(requestContent, Encoding.UTF8, "application/json"); + var response = await ApiClient.PostAsync(JsonRpcConstants.DefaultRoutePrefix, request); + + var expectedResponse = $$""" + { + "id": 123, + "error": { + "code": -32603, + "message": "Internal error", + "data": { + "": [ + "{{StringValidator.Error}}" + ] + } + }, + "jsonrpc": "2.0" + } + """.TrimAllLines(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var responseContent = await response.Content.ReadAsStringAsync(); + responseContent.TrimAllLines().Should().Be(expectedResponse); + } + + [Test] + public async Task FluentValidation_ValidRequest_ReturnJsonRpcResponse() + { + var str = "123"; + var requestContent = $$""" + { + "id": 123, + "method": "validate", + "jsonrpc": "2.0", + "params": { + "str": "{{str}}" + } + } + """; + + using var request = new StringContent(requestContent, Encoding.UTF8, "application/json"); + var response = await ApiClient.PostAsync(JsonRpcConstants.DefaultRoutePrefix, request); + + var expectedResponse = $$""" + { + "id": 123, + "result": "{{str}}", + "jsonrpc": "2.0" + } + """.TrimAllLines(); response.StatusCode.Should().Be(HttpStatusCode.OK); var responseContent = await response.Content.ReadAsStringAsync(); responseContent.TrimAllLines().Should().Be(expectedResponse); diff --git a/src/tests/Tochka.JsonRpc.Server.Tests.Integration/IntegrationTests.cs b/src/tests/Tochka.JsonRpc.Server.Tests.Integration/IntegrationTests.cs index 7443328b..4b1fa8b8 100644 --- a/src/tests/Tochka.JsonRpc.Server.Tests.Integration/IntegrationTests.cs +++ b/src/tests/Tochka.JsonRpc.Server.Tests.Integration/IntegrationTests.cs @@ -6,7 +6,9 @@ using Microsoft.Extensions.DependencyInjection; using Moq; using NUnit.Framework; +using Tochka.JsonRpc.Common; using Tochka.JsonRpc.Tests.WebApplication; +using Tochka.JsonRpc.TestUtils; using Tochka.JsonRpc.TestUtils.Integration; namespace Tochka.JsonRpc.Server.Tests.Integration; @@ -16,12 +18,14 @@ internal class IntegrationTests : IntegrationTestsBase { private Mock responseProviderMock; private Mock requestValidatorMock; + private Mock businessLogicExceptionHandlerMock; public override void Setup() { base.Setup(); responseProviderMock = new Mock(); requestValidatorMock = new Mock(); + businessLogicExceptionHandlerMock = new Mock(); } protected override void SetupServices(IServiceCollection services) @@ -29,6 +33,7 @@ protected override void SetupServices(IServiceCollection services) base.SetupServices(services); services.AddTransient(_ => responseProviderMock.Object); services.AddTransient(_ => requestValidatorMock.Object); + services.AddTransient(_ => businessLogicExceptionHandlerMock.Object); } [Test] @@ -37,10 +42,75 @@ public async Task NotJson_Return404() const string requestContent = "Hello World!"; using var request = new StringContent(requestContent, Encoding.UTF8, "text/plain"); - var response = await ApiClient.PostAsync(JsonRpcUrl, request); + var response = await ApiClient.PostAsync(JsonRpcConstants.DefaultRoutePrefix, request); response.StatusCode.Should().Be(HttpStatusCode.NotFound); } - private const string JsonRpcUrl = "/api/jsonrpc"; + [Test] + public async Task CustomExceptionFilter_ExpectedException_HandleOnlyWithCustomFilters() + { + var requestContent = """ + { + "id": 123, + "method": "business_logic_exception", + "jsonrpc": "2.0" + } + """; + businessLogicExceptionHandlerMock.Setup(static h => h.Handle(It.IsAny())) + .Verifiable(); + + using var request = new StringContent(requestContent, Encoding.UTF8, "application/json"); + var response = await ApiClient.PostAsync(JsonRpcConstants.DefaultRoutePrefix, request); + + var expectedResponse = $$""" + { + "id": 123, + "error": { + "code": -32603, + "message": "Internal error", + "data": "{{BusinessLogicExceptionWrappingFilter.ErrorData}}" + }, + "jsonrpc": "2.0" + } + """.TrimAllLines(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var responseContent = await response.Content.ReadAsStringAsync(); + responseContent.TrimAllLines().Should().Be(expectedResponse); + businessLogicExceptionHandlerMock.Verify(); + } + + [Test] + public async Task CustomExceptionFilter_UnexpectedException_HandleWithDefaultFilter() + { + var requestContent = """ + { + "id": 123, + "method": "unexpected_exception", + "jsonrpc": "2.0" + } + """; + + using var request = new StringContent(requestContent, Encoding.UTF8, "application/json"); + var response = await ApiClient.PostAsync(JsonRpcConstants.DefaultRoutePrefix, request); + + var expectedResponse = """ + { + "id": 123, + "error": { + "code": -32000, + "message": "Server error", + "data": { + "type": "System.InvalidOperationException", + "message": "Operation is not valid due to the current state of the object.", + "details": null + } + }, + "jsonrpc": "2.0" + } + """.TrimAllLines(); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var responseContent = await response.Content.ReadAsStringAsync(); + responseContent.TrimAllLines().Should().Be(expectedResponse); + } } diff --git a/src/tests/Tochka.JsonRpc.Tests.WebApplication/BusinessLogicException.cs b/src/tests/Tochka.JsonRpc.Tests.WebApplication/BusinessLogicException.cs new file mode 100644 index 00000000..a11e3a99 --- /dev/null +++ b/src/tests/Tochka.JsonRpc.Tests.WebApplication/BusinessLogicException.cs @@ -0,0 +1,5 @@ +namespace Tochka.JsonRpc.Tests.WebApplication; + +public class BusinessLogicException : Exception +{ +} diff --git a/src/tests/Tochka.JsonRpc.Tests.WebApplication/BusinessLogicExceptionHandler.cs b/src/tests/Tochka.JsonRpc.Tests.WebApplication/BusinessLogicExceptionHandler.cs new file mode 100644 index 00000000..0a763476 --- /dev/null +++ b/src/tests/Tochka.JsonRpc.Tests.WebApplication/BusinessLogicExceptionHandler.cs @@ -0,0 +1,8 @@ +namespace Tochka.JsonRpc.Tests.WebApplication; + +internal class BusinessLogicExceptionHandler : IBusinessLogicExceptionHandler +{ + public void Handle(BusinessLogicException exception) + { + } +} diff --git a/src/tests/Tochka.JsonRpc.Tests.WebApplication/BusinessLogicExceptionHandlingFilter.cs b/src/tests/Tochka.JsonRpc.Tests.WebApplication/BusinessLogicExceptionHandlingFilter.cs new file mode 100644 index 00000000..e9f4e426 --- /dev/null +++ b/src/tests/Tochka.JsonRpc.Tests.WebApplication/BusinessLogicExceptionHandlingFilter.cs @@ -0,0 +1,20 @@ +using JetBrains.Annotations; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Tochka.JsonRpc.Tests.WebApplication; + +[UsedImplicitly] +internal class BusinessLogicExceptionHandlingFilter : IExceptionFilter +{ + private readonly IBusinessLogicExceptionHandler handler; + + public BusinessLogicExceptionHandlingFilter(IBusinessLogicExceptionHandler handler) => this.handler = handler; + + public void OnException(ExceptionContext context) + { + if (context.Exception is BusinessLogicException exception) + { + handler.Handle(exception); + } + } +} diff --git a/src/tests/Tochka.JsonRpc.Tests.WebApplication/BusinessLogicExceptionWrappingFilter.cs b/src/tests/Tochka.JsonRpc.Tests.WebApplication/BusinessLogicExceptionWrappingFilter.cs new file mode 100644 index 00000000..9519b6ae --- /dev/null +++ b/src/tests/Tochka.JsonRpc.Tests.WebApplication/BusinessLogicExceptionWrappingFilter.cs @@ -0,0 +1,27 @@ +using JetBrains.Annotations; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Tochka.JsonRpc.Server.Services; + +namespace Tochka.JsonRpc.Tests.WebApplication; + +[UsedImplicitly] +internal class BusinessLogicExceptionWrappingFilter : IExceptionFilter +{ + private readonly IJsonRpcErrorFactory errorFactory; + + public BusinessLogicExceptionWrappingFilter(IJsonRpcErrorFactory errorFactory) => this.errorFactory = errorFactory; + + public void OnException(ExceptionContext context) + { + if (context.Exception is not BusinessLogicException) + { + return; + } + + var error = errorFactory.InternalError(ErrorData); + context.Result = new ObjectResult(error); + } + + public const string ErrorData = "handled with custom filter"; +} diff --git a/src/tests/Tochka.JsonRpc.Tests.WebApplication/Controllers/ExceptionsJsonRpcController.cs b/src/tests/Tochka.JsonRpc.Tests.WebApplication/Controllers/ExceptionsJsonRpcController.cs new file mode 100644 index 00000000..4159b5a5 --- /dev/null +++ b/src/tests/Tochka.JsonRpc.Tests.WebApplication/Controllers/ExceptionsJsonRpcController.cs @@ -0,0 +1,10 @@ +using Tochka.JsonRpc.Server; + +namespace Tochka.JsonRpc.Tests.WebApplication.Controllers; + +public class ExceptionsJsonRpcController : JsonRpcControllerBase +{ + public string BusinessLogicException() => throw new BusinessLogicException(); + + public string UnexpectedException() => throw new InvalidOperationException(); +} diff --git a/src/tests/Tochka.JsonRpc.Tests.WebApplication/Controllers/FluentValidationJsonRpcController.cs b/src/tests/Tochka.JsonRpc.Tests.WebApplication/Controllers/FluentValidationJsonRpcController.cs new file mode 100644 index 00000000..e3bbc1dc --- /dev/null +++ b/src/tests/Tochka.JsonRpc.Tests.WebApplication/Controllers/FluentValidationJsonRpcController.cs @@ -0,0 +1,48 @@ +using FluentValidation; +using JetBrains.Annotations; +using Microsoft.AspNetCore.Mvc; +using Tochka.JsonRpc.Server; +using Tochka.JsonRpc.Server.Attributes; +using Tochka.JsonRpc.Server.Services; +using Tochka.JsonRpc.Server.Settings; + +namespace Tochka.JsonRpc.Tests.WebApplication.Controllers; + +public class FluentValidationJsonRpcController : JsonRpcControllerBase +{ + private readonly IValidator strValidator; + private readonly IJsonRpcErrorFactory errorFactory; + + public FluentValidationJsonRpcController(IValidator strValidator, IJsonRpcErrorFactory errorFactory) + { + this.strValidator = strValidator; + this.errorFactory = errorFactory; + } + + // model validated automatically because of builder.Services.AddFluentValidationAutoValidation() in Program.cs + public async Task Validate([FromParams(BindingStyle.Object)] ValidationModel model, CancellationToken token) + { + var validationResult = await strValidator.ValidateAsync(model.Str, token); + return !validationResult.IsValid + ? Ok(errorFactory.InternalError(validationResult.ToDictionary())) + : Ok(model.Str); + } +} + +public record ValidationModel(string? Str); + +[UsedImplicitly] +public class ModelValidator : AbstractValidator +{ + public ModelValidator() => RuleFor(static m => m.Str).NotEmpty().WithMessage(Error); + + internal const string Error = "Str is empty"; +} + +[UsedImplicitly] +public class StringValidator : AbstractValidator +{ + public StringValidator() => RuleFor(static x => x).MinimumLength(3).WithMessage(Error); + + internal const string Error = "Str is too short"; +} diff --git a/src/tests/Tochka.JsonRpc.Tests.WebApplication/IBusinessLogicExceptionHandler.cs b/src/tests/Tochka.JsonRpc.Tests.WebApplication/IBusinessLogicExceptionHandler.cs new file mode 100644 index 00000000..489d8284 --- /dev/null +++ b/src/tests/Tochka.JsonRpc.Tests.WebApplication/IBusinessLogicExceptionHandler.cs @@ -0,0 +1,6 @@ +namespace Tochka.JsonRpc.Tests.WebApplication; + +internal interface IBusinessLogicExceptionHandler +{ + void Handle(BusinessLogicException exception); +} diff --git a/src/tests/Tochka.JsonRpc.Tests.WebApplication/Program.cs b/src/tests/Tochka.JsonRpc.Tests.WebApplication/Program.cs index e2d96baa..e519dd3d 100644 --- a/src/tests/Tochka.JsonRpc.Tests.WebApplication/Program.cs +++ b/src/tests/Tochka.JsonRpc.Tests.WebApplication/Program.cs @@ -1,5 +1,6 @@ using System.Reflection; -using Asp.Versioning.ApiExplorer; +using FluentValidation; +using FluentValidation.AspNetCore; using Microsoft.OpenApi.Models; using Tochka.JsonRpc.OpenRpc; using Tochka.JsonRpc.OpenRpc.Models; @@ -12,12 +13,17 @@ var builder = WebApplication.CreateBuilder(args); -builder.Services.AddControllers(); +builder.Services.AddControllers(static options => +{ + options.Filters.Add(); + options.Filters.Add(); +}); builder.Services.AddJsonRpcServer(static options => options.DefaultMethodStyle = JsonRpcMethodStyle.ActionOnly); // "business logic" builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); // custom serializers for requests builder.Services.AddSingleton(); @@ -39,7 +45,9 @@ .AddScheme(AuthConstants.SchemeName, null); builder.Services.AddAuthorization(); -builder.Services.AddSingleton(); +// FluentValidation +builder.Services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly()); +builder.Services.AddFluentValidationAutoValidation(); var app = builder.Build(); diff --git a/src/tests/Tochka.JsonRpc.Tests.WebApplication/Tochka.JsonRpc.Tests.WebApplication.csproj b/src/tests/Tochka.JsonRpc.Tests.WebApplication/Tochka.JsonRpc.Tests.WebApplication.csproj index 5b9f530b..94935d4a 100644 --- a/src/tests/Tochka.JsonRpc.Tests.WebApplication/Tochka.JsonRpc.Tests.WebApplication.csproj +++ b/src/tests/Tochka.JsonRpc.Tests.WebApplication/Tochka.JsonRpc.Tests.WebApplication.csproj @@ -14,7 +14,7 @@ - + @@ -39,14 +39,16 @@ - - - - + + + + - + + + From c8a299c532d36fd5e7fe579c2b0b313f0a5855d4 Mon Sep 17 00:00:00 2001 From: Artemiy Izakov Date: Wed, 20 Dec 2023 13:53:18 +0500 Subject: [PATCH 3/3] docs(server): add docs for exceptions logging and handling --- docs/en/server/configuration.md | 17 +++++++ docs/en/server/examples.md | 82 ++++++++++++++++++++++++++++++++- docs/ru/server/configuration.md | 17 +++++++ docs/ru/server/examples.md | 82 ++++++++++++++++++++++++++++++++- 4 files changed, 194 insertions(+), 4 deletions(-) diff --git a/docs/en/server/configuration.md b/docs/en/server/configuration.md index 3391113e..ee1ee2be 100644 --- a/docs/en/server/configuration.md +++ b/docs/en/server/configuration.md @@ -19,6 +19,7 @@ builder.Services.AddJsonRpcServer(static options => options.DefaultDataJsonSerializerOptions = JsonRpcSerializerOptions.SnakeCase; options.HeadersJsonSerializerOptions = JsonRpcSerializerOptions.Headers; options.RoutePrefix = "/api/jsonrpc"; + options.LogExceptions = true; }); ``` @@ -150,6 +151,22 @@ Route can be overridden with framework's `RouteAttribute` like usual, and global * Prefix can be set to `"/"` to get rid of it * Templates are supported (see [Routing#Route templates](routing#Route-templates)) +### LogExceptions + +```cs +builder.Services.AddJsonRpcServer(static options => options.LogExceptions = /* true or false */); +``` + +> Default: `true` + +> If `true`, all exceptions during JSON-RPC call processing will be logged with Error log level + +[Usage examples](examples#Logging). + +Exceptions thrown by this library, middleware, or user code, are intercepted and serialized as JSON-RPC error response in `IExceptionFilter`. Because of that, filter is the last place, where you can access exception object. + +If you want to log only certain type of exceptions, you can set this option to `false` and add your own `IExceptionFilter` with logging logic (see [Examples#Logging > Certain exceptions](examples#Logging)). + ## Attributes ### JsonRpcSerializerOptionsAttribute diff --git a/docs/en/server/examples.md b/docs/en/server/examples.md index 15627668..d19d80e5 100644 --- a/docs/en/server/examples.md +++ b/docs/en/server/examples.md @@ -1389,7 +1389,7 @@ HttpContext.SetJsonRpcResponse(response); Different ways to return error from method. See [Errors](errors) for details.
-IJsonRpcErrorFactory methods +IJsonRpcErrorFactory methods (recommended) ```cs public class FailController : JsonRpcControllerBase @@ -1871,14 +1871,92 @@ Response (does not depend on [`DetailedResponseExceptions`](configuration#Detail
-## Requests logging +## Custom exceptions wrapping + +If you want to add custom logic for wrapping certain exceptions in JSON-RPC response, you can register custom `IExceptionFilter` and use it to set `context.Result`.
Expand +> `Program.cs` +```cs +builder.Services.AddControllers(static options => options.Filters.Add()); +builder.Services.AddJsonRpcServer(); +``` + +> `CustomExceptionWrappingFilter.cs` +```cs +public class CustomExceptionWrappingFilter : IExceptionFilter +{ + private readonly IJsonRpcErrorFactory errorFactory; + + public CustomExceptionWrappingFilter(IJsonRpcErrorFactory errorFactory) => this.errorFactory = errorFactory; + + public void OnException(ExceptionContext context) + { + if (context.Exception is not BusinessLogicException exception) + { + return; + } + + var error = errorFactory.InternalError(exception); + context.Result = new ObjectResult(error); + } +} +``` + +
+ +## Logging + +
+Incoming requests + ```cs app.UseJsonRpc() .WithJsonRpcRequestLogging() ```
+ +
+All exceptions + +Exceptions logging is enabled by default, but you can configure it using options. + +```cs +builder.Services.AddJsonRpcServer(static options => options.LogExceptions = true); // <-- by default +``` + +
+ +
+Certain exceptions + +You can disable exceptions logging and add your own filter. + +> `Program.cs` +```cs +builder.Services.AddControllers(static options => options.Filters.Add()); +builder.Services.AddJsonRpcServer(static options => options.LogExceptions = false); +``` + +> `CustomExceptionsLoggingFilter.cs` +```cs +public class CustomExceptionsLoggingFilter : IExceptionFilter +{ + private readonly ILogger log; + + public CustomExceptionsLoggingFilter(ILogger log) => this.log = log; + + public void OnException(ExceptionContext context) + { + if (context.Exception is not BusinessLogicException) + { + log.LogError(context.Exception, "Unexpected exception"); + } + } +} +``` + +
\ No newline at end of file diff --git a/docs/ru/server/configuration.md b/docs/ru/server/configuration.md index 44d003d2..0d9b355c 100644 --- a/docs/ru/server/configuration.md +++ b/docs/ru/server/configuration.md @@ -19,6 +19,7 @@ builder.Services.AddJsonRpcServer(static options => options.DefaultDataJsonSerializerOptions = JsonRpcSerializerOptions.SnakeCase; options.HeadersJsonSerializerOptions = JsonRpcSerializerOptions.Headers; options.RoutePrefix = "/api/jsonrpc"; + options.LogExceptions = true; }); ``` @@ -148,6 +149,22 @@ Route может быть переопределен с помощью стан * Может быть установлено значение `"/"`, чтобы избавиться от него * Поддерживает шаблонизацию (см. [Маршрутизация#Шаблонизация route](routing#Шаблонизация-route)) +### LogExceptions + +```cs +builder.Services.AddJsonRpcServer(static options => options.LogExceptions = /* true или false */); +``` + +> Значение по умолчанию: `true` + +> Если `true`, все исключения будут логироваться с уровнем Error + +[Примеры использования](examples#Логирование). + +Исключения, вызванные этой библиотекой, мидлварями или пользовательским кодом, перехватываются и сериализуются как JSON-RPC response в `IExceptionFilter`. Из-за этого, фильтр - последнее место, где можно получить доступ к объекту исключения. + +Если требуется логировать только определенные типы исключений, можно установить эту настройку в `false` и реализовать свой `IExceptionFilter` для логирования (см. [Примеры#Логирование > Определенные исключения](examples#Логирование)). + ## Атрибуты ### JsonRpcSerializerOptionsAttribute diff --git a/docs/ru/server/examples.md b/docs/ru/server/examples.md index 41ec4ed8..5e9fa7fe 100644 --- a/docs/ru/server/examples.md +++ b/docs/ru/server/examples.md @@ -1390,7 +1390,7 @@ HttpContext.SetJsonRpcResponse(response); Разные способы вернуть ошибку из метода. Подробности см. в [Ошибки](errors).
-Методы IJsonRpcErrorFactory +Методы IJsonRpcErrorFactory (рекомендуется) ```cs public class FailController : JsonRpcControllerBase @@ -1872,14 +1872,92 @@ public class FailController : JsonRpcControllerBase
-## Логирование запросов +## Кастомная обработка исключений + +Если нужно реализовать особую логику заворачивания исключений в JSON-RPC response, можно зарегистрировать свой `IExceptionFilter` и просаживать в нем `context.Result`.
Развернуть +> `Program.cs` +```cs +builder.Services.AddControllers(static options => options.Filters.Add()); +builder.Services.AddJsonRpcServer(); +``` + +> `CustomExceptionWrappingFilter.cs` +```cs +public class CustomExceptionWrappingFilter : IExceptionFilter +{ + private readonly IJsonRpcErrorFactory errorFactory; + + public CustomExceptionWrappingFilter(IJsonRpcErrorFactory errorFactory) => this.errorFactory = errorFactory; + + public void OnException(ExceptionContext context) + { + if (context.Exception is not BusinessLogicException exception) + { + return; + } + + var error = errorFactory.InternalError(exception); + context.Result = new ObjectResult(error); + } +} +``` + +
+ +## Логирование + +
+Входящие запросы + ```cs app.UseJsonRpc() .WithJsonRpcRequestLogging() ```
+ +
+Все исключения + +Логирование исключений включено по умолчанию, но его можно конфигурировать через настройки. + +```cs +builder.Services.AddJsonRpcServer(static options => options.LogExceptions = true); // <-- по умолчанию +``` + +
+ +
+Определенные исключения + +Можно выключить логирование исключений и добавить собственный фильтр. + +> `Program.cs` +```cs +builder.Services.AddControllers(static options => options.Filters.Add()); +builder.Services.AddJsonRpcServer(static options => options.LogExceptions = false); +``` + +> `CustomExceptionsLoggingFilter.cs` +```cs +public class CustomExceptionsLoggingFilter : IExceptionFilter +{ + private readonly ILogger log; + + public CustomExceptionsLoggingFilter(ILogger log) => this.log = log; + + public void OnException(ExceptionContext context) + { + if (context.Exception is not BusinessLogicException) + { + log.LogError(context.Exception, "Unexpected exception"); + } + } +} +``` + +