diff --git a/Codebelt.Extensions.Newtonsoft.Json.sln b/Codebelt.Extensions.Newtonsoft.Json.sln index 45af02b..0eb3889 100644 --- a/Codebelt.Extensions.Newtonsoft.Json.sln +++ b/Codebelt.Extensions.Newtonsoft.Json.sln @@ -19,6 +19,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Codebelt.Extensions.Newtons EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests", "test\Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests\Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests.csproj", "{1CE82D1C-FEC6-4F25-A0FF-94E137F750AF}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Codebelt.Extensions.AspNetCore.Newtonsoft.Json.Tests", "test\Codebelt.Extensions.AspNetCore.Newtonsoft.Json.Tests\Codebelt.Extensions.AspNetCore.Newtonsoft.Json.Tests.csproj", "{BF6A82F1-93DD-4022-9B20-DF83C3FE3C1B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -49,6 +51,10 @@ Global {1CE82D1C-FEC6-4F25-A0FF-94E137F750AF}.Debug|Any CPU.Build.0 = Debug|Any CPU {1CE82D1C-FEC6-4F25-A0FF-94E137F750AF}.Release|Any CPU.ActiveCfg = Release|Any CPU {1CE82D1C-FEC6-4F25-A0FF-94E137F750AF}.Release|Any CPU.Build.0 = Release|Any CPU + {BF6A82F1-93DD-4022-9B20-DF83C3FE3C1B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BF6A82F1-93DD-4022-9B20-DF83C3FE3C1B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BF6A82F1-93DD-4022-9B20-DF83C3FE3C1B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BF6A82F1-93DD-4022-9B20-DF83C3FE3C1B}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -60,6 +66,7 @@ Global {260BDF91-E7C7-4CB4-A39D-E1A5374C5602} = {0070E83B-2DDD-4537-A83F-1CF8644F2880} {90DB61D1-5538-49A4-9F8F-E2F67C1EEED0} = {A3C56B2E-55EE-44EC-876E-B03B8DDA3317} {1CE82D1C-FEC6-4F25-A0FF-94E137F750AF} = {A3C56B2E-55EE-44EC-876E-B03B8DDA3317} + {BF6A82F1-93DD-4022-9B20-DF83C3FE3C1B} = {A3C56B2E-55EE-44EC-876E-B03B8DDA3317} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {0CBE2805-F0FF-4D0F-902C-8B9277A5D3F2} diff --git a/Directory.Build.props b/Directory.Build.props index c6920ab..a046cf9 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -70,8 +70,8 @@ - - + + all @@ -81,7 +81,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/Directory.Build.targets b/Directory.Build.targets index 62e3013..55cf983 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -14,8 +14,8 @@ - 00000 - $(MinVerMajor).$(MinVerMinor).$(MinVerPatch).$(BUILD_BUILDNUMBER) + 0 + $(MinVerMajor).$(MinVerMinor).$(MinVerPatch).$(GITHUB_RUN_NUMBER) diff --git a/src/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.csproj b/src/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.csproj index fbd6c61..2d7beef 100644 --- a/src/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.csproj +++ b/src/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.csproj @@ -6,7 +6,7 @@ - The Cuemon.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json namespace contains both types and extension methods that complements both the Cuemon.Extensions.Newtonsoft.Json/Cuemon.Extensions.AspNetCore.Newtonsoft.Json namespace while being an addition to the Microsoft.AspNetCore.Mvc namespace. Provides JSON formatters for ASP.NET Core MVC that is powered by Newtonsoft.Json. + The Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json namespace contains both types and extension methods that complements both the Codebelt.Extensions.Newtonsoft.Json/Codebelt.Extensions.AspNetCore.Newtonsoft.Json namespace while being an addition to the Microsoft.AspNetCore.Mvc namespace. Provides JSON formatters for ASP.NET Core MVC that is powered by Newtonsoft.Json. extension-methods extensions json-converters add-json-serialization-formatters add-json-formatter-options @@ -23,8 +23,8 @@ - - + + diff --git a/src/Codebelt.Extensions.AspNetCore.Newtonsoft.Json/Codebelt.Extensions.AspNetCore.Newtonsoft.Json.csproj b/src/Codebelt.Extensions.AspNetCore.Newtonsoft.Json/Codebelt.Extensions.AspNetCore.Newtonsoft.Json.csproj index ccbbf1d..b62637f 100644 --- a/src/Codebelt.Extensions.AspNetCore.Newtonsoft.Json/Codebelt.Extensions.AspNetCore.Newtonsoft.Json.csproj +++ b/src/Codebelt.Extensions.AspNetCore.Newtonsoft.Json/Codebelt.Extensions.AspNetCore.Newtonsoft.Json.csproj @@ -11,7 +11,7 @@ - + diff --git a/src/Codebelt.Extensions.Newtonsoft.Json/Codebelt.Extensions.Newtonsoft.Json.csproj b/src/Codebelt.Extensions.Newtonsoft.Json/Codebelt.Extensions.Newtonsoft.Json.csproj index 3163ebe..e7cd03a 100644 --- a/src/Codebelt.Extensions.Newtonsoft.Json/Codebelt.Extensions.Newtonsoft.Json.csproj +++ b/src/Codebelt.Extensions.Newtonsoft.Json/Codebelt.Extensions.Newtonsoft.Json.csproj @@ -10,8 +10,8 @@ - - + + diff --git a/test/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests/Assets/SampleModel.cs b/test/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests/Assets/SampleModel.cs new file mode 100644 index 0000000..95822ab --- /dev/null +++ b/test/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests/Assets/SampleModel.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Assets +{ + public class SampleModel + { + [Required(ErrorMessage = "This field is required.")] + [StringLength(100)] + public string Name { get; set; } + } +} diff --git a/test/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests/Assets/StatusCodesController.cs b/test/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests/Assets/StatusCodesController.cs new file mode 100644 index 0000000..46ee4b9 --- /dev/null +++ b/test/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests/Assets/StatusCodesController.cs @@ -0,0 +1,116 @@ +using System; +using System.Reflection; +using Cuemon.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Assets +{ + [ApiController] + [Route("[controller]")] + public class StatusCodesController : ControllerBase + { + [HttpGet("400")] + public IActionResult Get_400() + { + throw new BadRequestException(new ArgumentNullException()); + } + + [HttpGet("401")] + public IActionResult Get_401() + { + throw new UnauthorizedException(new AccessViolationException()); + } + + [HttpGet("403")] + public IActionResult Get_403() + { + throw new ForbiddenException(new UnauthorizedAccessException()); + } + + [HttpGet("404")] + public IActionResult Get_404() + { + throw new NotFoundException(new NullReferenceException()); + } + + [HttpGet("405")] + public IActionResult Get_405() + { + throw new MethodNotAllowedException(new ArgumentException()); + } + + [HttpGet("406")] + public IActionResult Get_406() + { + throw new NotAcceptableException(new ArgumentException()); + } + + [HttpGet("409")] + public IActionResult Get_409() + { + throw new ConflictException(new AmbiguousMatchException()); + } + + [HttpGet("410")] + public IActionResult Get_410() + { + throw new GoneException(new NotImplementedException()); + } + + [HttpGet("412")] + public IActionResult Get_412() + { + throw new PreconditionFailedException(new ArgumentOutOfRangeException()); + } + + [HttpGet("413")] + public IActionResult Get_413() + { + throw new PayloadTooLargeException(new ArgumentOutOfRangeException()); + } + + [HttpGet("415")] + public IActionResult Get_415() + { + throw new UnsupportedMediaTypeException(new ArgumentOutOfRangeException()); + } + + [HttpGet("428")] + public IActionResult Get_428() + { + throw new PreconditionRequiredException(new ArgumentException()); + } + + [HttpGet("429")] + public IActionResult Get_429() + { + throw new TooManyRequestsException(new OverflowException()); + } + + [HttpGet("XXX/{app}")] + public IActionResult Get_XXX(string app) + { + try + { + throw new ArgumentException("This is an inner exception message ...", nameof(app)) + { + Data = + { + { nameof(app), app } + }, + HelpLink = "https://www.savvyio.net/" + }; + } + catch (Exception e) + { + throw new NotSupportedException("Main exception - look out for inner!", e); + } + } + + [HttpPost("/")] + public IActionResult Post(SampleModel model) + { + return Ok(model); + } + } +} diff --git a/test/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests.csproj b/test/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests.csproj index 19a944d..4a0a988 100644 --- a/test/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests.csproj +++ b/test/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests.csproj @@ -5,6 +5,10 @@ Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json + + + + diff --git a/test/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests/MvcBuilderExtensionsTests.cs b/test/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests/MvcBuilderExtensionsTests.cs new file mode 100644 index 0000000..bc6653d --- /dev/null +++ b/test/Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests/MvcBuilderExtensionsTests.cs @@ -0,0 +1,554 @@ +using System.Net.Http.Headers; +using System.Threading.Tasks; +using Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Assets; +using Codebelt.Extensions.Xunit; +using Codebelt.Extensions.Xunit.Hosting.AspNetCore; +using Cuemon.AspNetCore.Diagnostics; +using Cuemon.Diagnostics; +using Cuemon.Extensions.AspNetCore.Mvc.Filters; +using Cuemon.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using Xunit; +using Xunit.Abstractions; + +namespace Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json +{ + public class MvcBuilderExtensionsTests : Test + { + public MvcBuilderExtensionsTests(ITestOutputHelper output) : base(output) + { + } + + [Theory] + [InlineData(FaultSensitivityDetails.All)] + [InlineData(FaultSensitivityDetails.Evidence)] + [InlineData(FaultSensitivityDetails.FailureWithStackTraceAndData)] + [InlineData(FaultSensitivityDetails.FailureWithData)] + [InlineData(FaultSensitivityDetails.FailureWithStackTrace)] + [InlineData(FaultSensitivityDetails.Failure)] + [InlineData(FaultSensitivityDetails.None)] + public async Task OnException_ShouldCaptureException_RenderAsProblemDetails_UsingNewtonsoftJson(FaultSensitivityDetails sensitivity) + { + using var response = await WebHostTestFactory.RunAsync( + services => + { + services + .AddControllers(o => o.Filters.AddFaultDescriptor()) + .AddApplicationPart(typeof(StatusCodesController).Assembly) + .AddNewtonsoftJsonFormatters() + .AddFaultDescriptorOptions(o => o.FaultDescriptor = PreferredFaultDescriptor.ProblemDetails); + services.PostConfigureAllOf(o => o.SensitivityDetails = sensitivity); + }, + app => + { + app.UseRouting(); + app.UseEndpoints(routes => { routes.MapControllers(); }); + }, + responseFactory: client => + { + client.DefaultRequestHeaders.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json")); + return client.GetAsync("/statuscodes/XXX/serverError"); + }); + + var body = await response.Content.ReadAsStringAsync(); + TestOutput.WriteLine(body); + + switch (sensitivity) + { + case FaultSensitivityDetails.All: + Assert.True(Match(""" + { + "type": "about:blank", + "title": "InternalServerError", + "status": 500, + "detail": "An unhandled exception was raised by *", + "instance": "http://localhost/statuscodes/XXX/serverError", + "traceId": "*", + "failure": { + "type": "System.NotSupportedException", + "source": "Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests", + "message": "Main exception - look out for inner!", + "stack": [ + "at Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Assets.StatusCodesController.Get_XXX(String app) *", + "at *", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ActionMethodExecutor.SyncActionResultExecutor.Execute*", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeActionMethodAsync()", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeNextActionFilterAsync()", + "--- End of stack trace from previous location ---", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Rethrow(ActionExecutedContextSealed context)", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeInnerFilterAsync()", + "--- End of stack trace from previous location ---", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.*" + ], + "inner": { + "type": "System.ArgumentException", + "source": "Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests", + "message": "This is an inner exception message ... (Parameter 'app')", + "stack": [ + "at Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Assets.StatusCodesController.Get_XXX(String app)*" + ], + "data": { + "key": "serverError" + }, + "paramName": "app" + } + }, + "request": { + "location": "http://localhost/statuscodes/XXX/serverError", + "method": "GET", + "headers": { + "accept": "application/json", + "host": "localhost" + }, + "query": [], + "cookies": [], + "body": "" + } + } + """.ReplaceLineEndings(), body.ReplaceLineEndings(), o => o.ThrowOnNoMatch = true)); + break; + case FaultSensitivityDetails.Evidence: + Assert.True(Match(""" + { + "type": "about:blank", + "title": "InternalServerError", + "status": 500, + "detail": "An unhandled exception was raised by *", + "instance": "http://localhost/statuscodes/XXX/serverError", + "traceId": "*", + "request": { + "location": "http://localhost/statuscodes/XXX/serverError", + "method": "GET", + "headers": { + "accept": "application/json", + "host": "localhost" + }, + "query": [], + "cookies": [], + "body": "" + } + } + """.ReplaceLineEndings(), body.ReplaceLineEndings(), o => o.ThrowOnNoMatch = true)); + break; + case FaultSensitivityDetails.FailureWithStackTraceAndData: + Assert.True(Match(""" + { + "type": "about:blank", + "title": "InternalServerError", + "status": 500, + "detail": "An unhandled exception was raised by *", + "instance": "http://localhost/statuscodes/XXX/serverError", + "traceId": "*", + "failure": { + "type": "System.NotSupportedException", + "source": "Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests", + "message": "Main exception - look out for inner!", + "stack": [ + "at Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Assets.StatusCodesController.Get_XXX(String app) *", + "at *", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ActionMethodExecutor.SyncActionResultExecutor.Execute*", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeActionMethodAsync()", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeNextActionFilterAsync()", + "--- End of stack trace from previous location ---", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Rethrow(ActionExecutedContextSealed context)", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeInnerFilterAsync()", + "--- End of stack trace from previous location ---", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.*" + ], + "inner": { + "type": "System.ArgumentException", + "source": "Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests", + "message": "This is an inner exception message ... (Parameter 'app')", + "stack": [ + "at Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Assets.StatusCodesController.Get_XXX(String app)*" + ], + "data": { + "key": "serverError" + }, + "paramName": "app" + } + } + } + """.ReplaceLineEndings(), body.ReplaceLineEndings(), o => o.ThrowOnNoMatch = true)); + break; + case FaultSensitivityDetails.FailureWithData: + Assert.True(Match(""" + { + "type": "about:blank", + "title": "InternalServerError", + "status": 500, + "detail": "An unhandled exception was raised by *", + "instance": "http://localhost/statuscodes/XXX/serverError", + "traceId": "*", + "failure": { + "type": "System.NotSupportedException", + "source": "Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests", + "message": "Main exception - look out for inner!", + "inner": { + "type": "System.ArgumentException", + "source": "Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests", + "message": "This is an inner exception message ... (Parameter 'app')", + "data": { + "key": "serverError" + }, + "paramName": "app" + } + } + } + """.ReplaceLineEndings(), body.ReplaceLineEndings(), o => o.ThrowOnNoMatch = true)); + break; + case FaultSensitivityDetails.FailureWithStackTrace: + Assert.True(Match(""" + { + "type": "about:blank", + "title": "InternalServerError", + "status": 500, + "detail": "An unhandled exception was raised by *", + "instance": "http://localhost/statuscodes/XXX/serverError", + "traceId": "*", + "failure": { + "type": "System.NotSupportedException", + "source": "Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests", + "message": "Main exception - look out for inner!", + "stack": [ + "at Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Assets.StatusCodesController.Get_XXX(String app) *", + "at *", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ActionMethodExecutor.SyncActionResultExecutor.Execute*", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeActionMethodAsync()", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeNextActionFilterAsync()", + "--- End of stack trace from previous location ---", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Rethrow(ActionExecutedContextSealed context)", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeInnerFilterAsync()", + "--- End of stack trace from previous location ---", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.*" + ], + "inner": { + "type": "System.ArgumentException", + "source": "Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests", + "message": "This is an inner exception message ... (Parameter 'app')", + "stack": [ + "at Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Assets.StatusCodesController.Get_XXX(String app)*" + ], + "paramName": "app" + } + } + } + """.ReplaceLineEndings(), body.ReplaceLineEndings(), o => o.ThrowOnNoMatch = true)); + break; + case FaultSensitivityDetails.Failure: + Assert.True(Match(""" + { + "type": "about:blank", + "title": "InternalServerError", + "status": 500, + "detail": "An unhandled exception was raised by *", + "instance": "http://localhost/statuscodes/XXX/serverError", + "traceId": "*", + "failure": { + "type": "System.NotSupportedException", + "source": "Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests", + "message": "Main exception - look out for inner!", + "inner": { + "type": "System.ArgumentException", + "source": "Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests", + "message": "This is an inner exception message ... (Parameter 'app')", + "paramName": "app" + } + } + } + """.ReplaceLineEndings(), body.ReplaceLineEndings(), o => o.ThrowOnNoMatch = true)); + break; + case FaultSensitivityDetails.None: + Assert.True(Match(""" + { + "type": "about:blank", + "title": "InternalServerError", + "status": 500, + "detail": "An unhandled exception was raised by *", + "instance": "http://localhost/statuscodes/XXX/serverError", + "traceId": "*" + } + """.ReplaceLineEndings(), body.ReplaceLineEndings(), o => o.ThrowOnNoMatch = true)); + break; + } + } + + [Theory] + [InlineData(FaultSensitivityDetails.All)] + [InlineData(FaultSensitivityDetails.Evidence)] + [InlineData(FaultSensitivityDetails.FailureWithStackTraceAndData)] + [InlineData(FaultSensitivityDetails.FailureWithData)] + [InlineData(FaultSensitivityDetails.FailureWithStackTrace)] + [InlineData(FaultSensitivityDetails.Failure)] + [InlineData(FaultSensitivityDetails.None)] + public async Task OnException_ShouldCaptureException_RenderAsDefault_UsingNewtonsoftJson(FaultSensitivityDetails sensitivity) + { + using var response = await WebHostTestFactory.RunAsync( + services => + { + services + .AddControllers(o => o.Filters.AddFaultDescriptor()) + .AddApplicationPart(typeof(StatusCodesController).Assembly) + .AddNewtonsoftJsonFormatters() + .AddFaultDescriptorOptions(o => o.FaultDescriptor = PreferredFaultDescriptor.FaultDetails); + services.PostConfigureAllOf(o => o.SensitivityDetails = sensitivity); + }, + app => + { + app.UseRouting(); + app.UseEndpoints(routes => { routes.MapControllers(); }); + }, + responseFactory: client => + { + client.DefaultRequestHeaders.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json")); + return client.GetAsync("/statuscodes/XXX/serverError"); + }); + + var body = await response.Content.ReadAsStringAsync(); + TestOutput.WriteLine(body); + + switch (sensitivity) + { + case FaultSensitivityDetails.All: + Assert.True(Match(""" + { + "error": { + "instance": "http://localhost/statuscodes/XXX/serverError", + "status": 500, + "code": "InternalServerError", + "message": "An unhandled exception was raised by *.", + "failure": { + "type": "System.NotSupportedException", + "source": "Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests", + "message": "Main exception - look out for inner!", + "stack": [ + "at Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Assets.StatusCodesController.Get_XXX(String app) *", + "at *", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ActionMethodExecutor.SyncActionResultExecutor.Execute*", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeActionMethodAsync()", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeNextActionFilterAsync()", + "--- End of stack trace from previous location ---", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Rethrow(ActionExecutedContextSealed context)", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeInnerFilterAsync()", + "--- End of stack trace from previous location ---", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.*" + ], + "inner": { + "type": "System.ArgumentException", + "source": "Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests", + "message": "This is an inner exception message ... (Parameter 'app')", + "stack": [ + "at Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Assets.StatusCodesController.Get_XXX(String app)*" + ], + "data": { + "app": "serverError" + }, + "paramName": "app" + } + } + }, + "evidence": { + "request": { + "location": "http://localhost/statuscodes/XXX/serverError", + "method": "GET", + "headers": { + "accept": "application/json", + "host": "localhost" + }, + "query": [], + "cookies": [], + "body": "" + } + }, + "traceId": "*" + } + """.ReplaceLineEndings(), body.ReplaceLineEndings(), o => o.ThrowOnNoMatch = true)); + break; + case FaultSensitivityDetails.Evidence: + Assert.True(Match(""" + { + "error": { + "instance": "http://localhost/statuscodes/XXX/serverError", + "status": 500, + "code": "InternalServerError", + "message": "An unhandled exception was raised by *." + }, + "evidence": { + "request": { + "location": "http://localhost/statuscodes/XXX/serverError", + "method": "GET", + "headers": { + "accept": "application/json", + "host": "localhost" + }, + "query": [], + "cookies": [], + "body": "" + } + }, + "traceId": "*" + } + """.ReplaceLineEndings(), body.ReplaceLineEndings(), o => o.ThrowOnNoMatch = true)); + break; + case FaultSensitivityDetails.FailureWithStackTraceAndData: + Assert.True(Match(""" + { + "error": { + "instance": "http://localhost/statuscodes/XXX/serverError", + "status": 500, + "code": "InternalServerError", + "message": "An unhandled exception was raised by *.", + "failure": { + "type": "System.NotSupportedException", + "source": "Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests", + "message": "Main exception - look out for inner!", + "stack": [ + "at Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Assets.StatusCodesController.Get_XXX(String app) *", + "at *", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ActionMethodExecutor.SyncActionResultExecutor.Execute*", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeActionMethodAsync()", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeNextActionFilterAsync()", + "--- End of stack trace from previous location ---", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Rethrow(ActionExecutedContextSealed context)", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeInnerFilterAsync()", + "--- End of stack trace from previous location ---", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.*" + ], + "inner": { + "type": "System.ArgumentException", + "source": "Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests", + "message": "This is an inner exception message ... (Parameter 'app')", + "stack": [ + "at Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Assets.StatusCodesController.Get_XXX(String app)*" + ], + "data": { + "app": "serverError" + }, + "paramName": "app" + } + } + }, + "traceId": "*" + } + """.ReplaceLineEndings(), body.ReplaceLineEndings(), o => o.ThrowOnNoMatch = true)); + break; + case FaultSensitivityDetails.FailureWithData: + Assert.True(Match(""" + { + "error": { + "instance": "http://localhost/statuscodes/XXX/serverError", + "status": 500, + "code": "InternalServerError", + "message": "An unhandled exception was raised by *.", + "failure": { + "type": "System.NotSupportedException", + "source": "Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests", + "message": "Main exception - look out for inner!", + "inner": { + "type": "System.ArgumentException", + "source": "Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests", + "message": "This is an inner exception message ... (Parameter 'app')", + "data": { + "app": "serverError" + }, + "paramName": "app" + } + } + }, + "traceId": "*" + } + """.ReplaceLineEndings(), body.ReplaceLineEndings(), o => o.ThrowOnNoMatch = true)); + break; + case FaultSensitivityDetails.FailureWithStackTrace: + Assert.True(Match(""" + { + "error": { + "instance": "http://localhost/statuscodes/XXX/serverError", + "status": 500, + "code": "InternalServerError", + "message": "An unhandled exception was raised by *.", + "failure": { + "type": "System.NotSupportedException", + "source": "Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests", + "message": "Main exception - look out for inner!", + "stack": [ + "at Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Assets.StatusCodesController.Get_XXX(String app) *", + "at *", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ActionMethodExecutor.SyncActionResultExecutor.Execute*", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeActionMethodAsync()", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeNextActionFilterAsync()", + "--- End of stack trace from previous location ---", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Rethrow(ActionExecutedContextSealed context)", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeInnerFilterAsync()", + "--- End of stack trace from previous location ---", + "at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.*" + ], + "inner": { + "type": "System.ArgumentException", + "source": "Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests", + "message": "This is an inner exception message ... (Parameter 'app')", + "stack": [ + "at Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Assets.StatusCodesController.Get_XXX(String app)*" + ], + "paramName": "app" + } + } + }, + "traceId": "*" + } + """.ReplaceLineEndings(), body.ReplaceLineEndings(), o => o.ThrowOnNoMatch = true)); + break; + case FaultSensitivityDetails.Failure: + Assert.True(Match(""" + { + "error": { + "instance": "http://localhost/statuscodes/XXX/serverError", + "status": 500, + "code": "InternalServerError", + "message": "An unhandled exception was raised by *.", + "failure": { + "type": "System.NotSupportedException", + "source": "Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests", + "message": "Main exception - look out for inner!", + "inner": { + "type": "System.ArgumentException", + "source": "Codebelt.Extensions.AspNetCore.Mvc.Formatters.Newtonsoft.Json.Tests", + "message": "This is an inner exception message ... (Parameter 'app')", + "paramName": "app" + } + } + }, + "traceId": "*" + } + """.ReplaceLineEndings(), body.ReplaceLineEndings(), o => o.ThrowOnNoMatch = true)); + break; + case FaultSensitivityDetails.None: + Assert.True(Match(""" + { + "error": { + "instance": "http://localhost/statuscodes/XXX/serverError", + "status": 500, + "code": "InternalServerError", + "message": "An unhandled exception was raised by *." + }, + "traceId": "*" + } + """.ReplaceLineEndings(), body.ReplaceLineEndings(), o => o.ThrowOnNoMatch = true)); + break; + } + } + } +} diff --git a/test/Codebelt.Extensions.AspNetCore.Newtonsoft.Json.Tests/Codebelt.Extensions.AspNetCore.Newtonsoft.Json.Tests.csproj b/test/Codebelt.Extensions.AspNetCore.Newtonsoft.Json.Tests/Codebelt.Extensions.AspNetCore.Newtonsoft.Json.Tests.csproj new file mode 100644 index 0000000..9f8c332 --- /dev/null +++ b/test/Codebelt.Extensions.AspNetCore.Newtonsoft.Json.Tests/Codebelt.Extensions.AspNetCore.Newtonsoft.Json.Tests.csproj @@ -0,0 +1,16 @@ + + + + net9.0;net8.0 + Codebelt.Extensions.AspNetCore.Newtonsoft.Json + + + + + + + + + + + diff --git a/test/Codebelt.Extensions.AspNetCore.Newtonsoft.Json.Tests/Formatters/ServiceCollectionExtensionsTest.cs b/test/Codebelt.Extensions.AspNetCore.Newtonsoft.Json.Tests/Formatters/ServiceCollectionExtensionsTest.cs new file mode 100644 index 0000000..5534233 --- /dev/null +++ b/test/Codebelt.Extensions.AspNetCore.Newtonsoft.Json.Tests/Formatters/ServiceCollectionExtensionsTest.cs @@ -0,0 +1,308 @@ +using System; +using System.Net; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using Codebelt.Extensions.Xunit; +using Codebelt.Extensions.Xunit.Hosting.AspNetCore; +using Cuemon.AspNetCore.Authentication.Basic; +using Cuemon.AspNetCore.Diagnostics; +using Cuemon.AspNetCore.Http; +using Cuemon.Diagnostics; +using Cuemon.Extensions.AspNetCore.Authentication; +using Cuemon.Extensions.AspNetCore.Diagnostics; +using Cuemon.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Net.Http.Headers; +using Xunit; +using Xunit.Abstractions; + +namespace Codebelt.Extensions.AspNetCore.Newtonsoft.Json.Formatters +{ + public class ServiceCollectionExtensionsTest : Test + { + public ServiceCollectionExtensionsTest(ITestOutputHelper output) : base(output) + { + } + + [Fact] + public async Task AddNewtonsoftJsonExceptionResponseFormatter_ShouldCaptureException_RenderAsExceptionDescriptor_UsingNewtonsoftJson_WithSensitivityAll() + { + using var response = await WebHostTestFactory.RunAsync( + services => + { + services.AddFaultDescriptorOptions(o => o.FaultDescriptor = PreferredFaultDescriptor.FaultDetails); + services.AddNewtonsoftJsonExceptionResponseFormatter(); + services.PostConfigureAllOf(o => o.SensitivityDetails = FaultSensitivityDetails.All); + }, + app => + { + app.UseFaultDescriptorExceptionHandler(); + app.Use(async (context, next) => + { + try + { + throw new ArgumentException("This is an inner exception message ...", nameof(app)) + { + Data = + { + { "1st", "data value" } + }, + HelpLink = "https://www.savvyio.net/" + }; + } + catch (Exception e) + { + throw new NotFoundException("Main exception - look out for inner!", e); + } + + await next(context); + }); + }, + responseFactory: client => + { + client.DefaultRequestHeaders.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json")); + return client.GetAsync("/"); + }); + + var body = await response.Content.ReadAsStringAsync(); + + TestOutput.WriteLine(body); + + Assert.True(Match(""" + { + "error": { + "instance": "http://localhost/", + "status": 404, + "code": "NotFound", + "message": "Main exception - look out for inner!", + "failure": { + "type": "Cuemon.AspNetCore.Http.NotFoundException", + "source": "Codebelt.Extensions.AspNetCore.Newtonsoft.Json.Tests", + "message": "Main exception - look out for inner!", + "stack": [ + "at Codebelt.Extensions.AspNetCore.Newtonsoft.Json.Formatters.ServiceCollectionExtensionsTest.<>c.<*", + "--- End of stack trace from previous location ---", + "at Microsoft.AspNetCore.Diagnostics.ExceptionHandler*" + ], + "headers": {}, + "statusCode": 404, + "reasonPhrase": "Not Found", + "inner": { + "type": "System.ArgumentException", + "source": "Codebelt.Extensions.AspNetCore.Newtonsoft.Json.Tests", + "message": "This is an inner exception message ... (Parameter 'app')", + "stack": [ + "at Codebelt.Extensions.AspNetCore.Newtonsoft.Json.Formatters.ServiceCollectionExtensionsTest.<>c.<*" + ], + "data": { + "1st": "data value" + }, + "paramName": "app" + } + } + }, + "evidence": { + "request": { + "location": "http://localhost/", + "method": "GET", + "headers": { + "accept": "application/json", + "host": "localhost" + }, + "query": [], + "cookies": [], + "body": "" + } + }, + "traceId": "*" + } + """.ReplaceLineEndings(), body.ReplaceLineEndings(), o => o.ThrowOnNoMatch = true)); + } + + [Fact] + public async Task AddNewtonsoftJsonExceptionResponseFormatter_ShouldCaptureException_RenderAsProblemDetails_UsingNewtonsoftJson_WithSensitivityAll() + { + using var response = await WebHostTestFactory.RunAsync( + services => + { + services.AddFaultDescriptorOptions(o => o.FaultDescriptor = PreferredFaultDescriptor.ProblemDetails); + services.AddNewtonsoftJsonExceptionResponseFormatter(); + services.PostConfigureAllOf(o => o.SensitivityDetails = FaultSensitivityDetails.All); + }, + app => + { + app.UseFaultDescriptorExceptionHandler(); + app.Use(async (context, next) => + { + try + { + throw new ArgumentException("This is an inner exception message ...", nameof(app)) + { + Data = + { + { "1st", "data value" } + }, + HelpLink = "https://www.savvyio.net/" + }; + } + catch (Exception e) + { + throw new NotFoundException("Main exception - look out for inner!", e); + } + + await next(context); + }); + }, + responseFactory: client => + { + client.DefaultRequestHeaders.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json")); + return client.GetAsync("/"); + }); + + var body = await response.Content.ReadAsStringAsync(); + + TestOutput.WriteLine(body); + + Assert.True(Match(""" + { + "type": "about:blank", + "title": "NotFound", + "status": 404, + "detail": "Main exception - look out for inner!", + "instance": "http://localhost/", + "traceId": "*", + "failure": { + "type": "Cuemon.AspNetCore.Http.NotFoundException", + "source": "Codebelt.Extensions.AspNetCore.Newtonsoft.Json.Tests", + "message": "Main exception - look out for inner!", + "stack": [ + "at Codebelt.Extensions.AspNetCore.Newtonsoft.Json.Formatters.ServiceCollectionExtensionsTest.<>c.<*", + "--- End of stack trace from previous location ---", + "at Microsoft.AspNetCore.Diagnostics.ExceptionHandler*" + ], + "headers": {}, + "statusCode": 404, + "reasonPhrase": "Not Found", + "inner": { + "type": "System.ArgumentException", + "source": "Codebelt.Extensions.AspNetCore.Newtonsoft.Json.Tests", + "message": "This is an inner exception message ... (Parameter 'app')", + "stack": [ + "at Codebelt.Extensions.AspNetCore.Newtonsoft.Json.Formatters.ServiceCollectionExtensionsTest.<>c.<*" + ], + "data": { + "key": "data value" + }, + "paramName": "app" + } + }, + "request": { + "location": "http://localhost/", + "method": "GET", + "headers": { + "accept": "application/json", + "host": "localhost" + }, + "query": [], + "cookies": [], + "body": "" + } + } + """.ReplaceLineEndings(), body.ReplaceLineEndings(), o => o.ThrowOnNoMatch = true)); + } + + [Theory] + [InlineData(FaultSensitivityDetails.All)] + [InlineData(FaultSensitivityDetails.None)] + public async void AddNewtonsoftJsonExceptionResponseFormatter_AuthorizationResponseHandler_BasicScheme_ShouldRenderResponseInJsonByNewtonsoft_UsingAspNetBootstrapping(FaultSensitivityDetails sensitivityDetails) + { + using (var startup = WebHostTestFactory.Create(services => + { + services.AddNewtonsoftJsonExceptionResponseFormatter(); + services.AddAuthorizationResponseHandler(); + services.AddAuthentication(BasicAuthorizationHeader.Scheme) + .AddBasic(o => + { + o.RequireSecureConnection = false; + o.Authenticator = (username, password) => null; + }); + services.AddAuthorization(o => + { + o.FallbackPolicy = new AuthorizationPolicyBuilder() + .AddAuthenticationSchemes(BasicAuthorizationHeader.Scheme) + .RequireAuthenticatedUser() + .Build(); + + }); + services.AddRouting(); + services.PostConfigureAllExceptionDescriptorOptions(o => o.SensitivityDetails = sensitivityDetails); + }, app => + { + app.UseRouting(); + app.UseAuthentication(); + app.UseAuthorization(); + app.UseEndpoints(endpoints => + { + endpoints.MapGet("/", context => context.Response.WriteAsync($"Hello {context.User.Identity!.Name}")); + }); + })) + { + var client = startup.Host.GetTestClient(); + var bb = new BasicAuthorizationHeaderBuilder() + .AddUserName("Agent") + .AddPassword("Test"); + + client.DefaultRequestHeaders.Add(HeaderNames.Authorization, bb.Build().ToString()); + client.DefaultRequestHeaders.Add(HeaderNames.Accept, "application/json"); + + var result = await client.GetAsync("/"); + var content = await result.Content.ReadAsStringAsync(); + + TestOutput.WriteLine(content); + + Assert.Equal(HttpStatusCode.Unauthorized, result.StatusCode); + Assert.Equal("Basic realm=\"AuthenticationServer\"", result.Headers.WwwAuthenticate.ToString()); + if (sensitivityDetails == FaultSensitivityDetails.All) + { + Assert.Equal(""" + { + "error": { + "status": 401, + "code": "Unauthorized", + "message": "The request has not been applied because it lacks valid authentication credentials for the target resource.", + "failure": { + "type": "Cuemon.AspNetCore.Http.UnauthorizedException", + "message": "The request has not been applied because it lacks valid authentication credentials for the target resource.", + "headers": {}, + "statusCode": 401, + "reasonPhrase": "Unauthorized", + "inner": { + "type": "System.Security.SecurityException", + "message": "Unable to authenticate Agent." + } + } + } + } + """.ReplaceLineEndings(), content.ReplaceLineEndings()); + } + else + { + Assert.Equal(""" + { + "error": { + "status": 401, + "code": "Unauthorized", + "message": "The request has not been applied because it lacks valid authentication credentials for the target resource." + } + } + """.ReplaceLineEndings(), content.ReplaceLineEndings()); + } + } + } + + } +} diff --git a/test/Codebelt.Extensions.Newtonsoft.Json.Tests/Codebelt.Extensions.Newtonsoft.Json.Tests.csproj b/test/Codebelt.Extensions.Newtonsoft.Json.Tests/Codebelt.Extensions.Newtonsoft.Json.Tests.csproj index 82e7553..aff0c3a 100644 --- a/test/Codebelt.Extensions.Newtonsoft.Json.Tests/Codebelt.Extensions.Newtonsoft.Json.Tests.csproj +++ b/test/Codebelt.Extensions.Newtonsoft.Json.Tests/Codebelt.Extensions.Newtonsoft.Json.Tests.csproj @@ -9,9 +9,9 @@ - - - + + +