-
-
Notifications
You must be signed in to change notification settings - Fork 158
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Marshall JsonApiException thrown from JsonConverter such that the sou…
…rce pointer can be reconstructed
- Loading branch information
Showing
5 changed files
with
296 additions
and
6 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
91 changes: 91 additions & 0 deletions
91
src/JsonApiDotNetCore/Serialization/Request/NotSupportedExceptionExtensions.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
using System.Text.Json.Serialization; | ||
using JsonApiDotNetCore.Errors; | ||
using JsonApiDotNetCore.Serialization.Objects; | ||
|
||
namespace JsonApiDotNetCore.Serialization.Request; | ||
|
||
/// <summary> | ||
/// A hacky approach to obtain the proper JSON:API source pointer from an exception thrown in a <see cref="JsonConverter" />. | ||
/// </summary> | ||
/// <remarks> | ||
/// <para> | ||
/// This method relies on the behavior at | ||
/// https://github.com/dotnet/runtime/blob/release/8.0/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonConverterOfT.ReadCore.cs#L100, | ||
/// which wraps a thrown <see cref="NotSupportedException" /> and adds the JSON path to the outer exception message, based on internal reader state. | ||
/// </para> | ||
/// <para> | ||
/// To take advantage of this, we expect a custom converter to throw a <see cref="NotSupportedException" /> with a specially-crafted | ||
/// <see cref="Exception.Source" /> and a nested <see cref="JsonApiException" /> containing a relative source pointer and a captured stack trace. Once | ||
/// all of that happens, this class extracts the added JSON path from the outer exception message and converts it to a JSON:API pointer to enrich the | ||
/// nested <see cref="JsonApiException" /> with. | ||
/// </para> | ||
/// </remarks> | ||
internal static class NotSupportedExceptionExtensions | ||
{ | ||
private const string LeadingText = " Path: "; | ||
private const string TrailingText = " | LineNumber: "; | ||
|
||
public static bool HasJsonApiException(this NotSupportedException exception) | ||
{ | ||
return exception.InnerException is NotSupportedException { InnerException: JsonApiException }; | ||
} | ||
|
||
public static JsonApiException EnrichSourcePointer(this NotSupportedException exception) | ||
{ | ||
var jsonApiException = (JsonApiException)exception.InnerException!.InnerException!; | ||
string? sourcePointer = GetSourcePointerFromMessage(exception.Message); | ||
|
||
if (sourcePointer != null) | ||
{ | ||
foreach (ErrorObject error in jsonApiException.Errors) | ||
{ | ||
if (error.Source == null) | ||
{ | ||
error.Source = new ErrorSource | ||
{ | ||
Pointer = sourcePointer | ||
}; | ||
} | ||
else | ||
{ | ||
error.Source.Pointer = sourcePointer + '/' + error.Source.Pointer; | ||
} | ||
} | ||
} | ||
|
||
return jsonApiException; | ||
} | ||
|
||
private static string? GetSourcePointerFromMessage(string message) | ||
{ | ||
string? jsonPath = ExtractJsonPathFromMessage(message); | ||
return JsonPathToSourcePointer(jsonPath); | ||
} | ||
|
||
private static string? ExtractJsonPathFromMessage(string message) | ||
{ | ||
int startIndex = message.IndexOf(LeadingText, StringComparison.Ordinal); | ||
|
||
if (startIndex != -1) | ||
{ | ||
int stopIndex = message.IndexOf(TrailingText, startIndex, StringComparison.Ordinal); | ||
|
||
if (stopIndex != -1) | ||
{ | ||
return message.Substring(startIndex + LeadingText.Length, stopIndex - startIndex - LeadingText.Length); | ||
} | ||
} | ||
|
||
return null; | ||
} | ||
|
||
private static string? JsonPathToSourcePointer(string? jsonPath) | ||
{ | ||
if (jsonPath != null && jsonPath.StartsWith('$')) | ||
{ | ||
return jsonPath[1..].Replace('.', '/'); | ||
} | ||
|
||
return null; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
143 changes: 143 additions & 0 deletions
143
...sonApiDotNetCoreTests/UnitTests/Serialization/Extensions/SourcePointerInExceptionTests.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,143 @@ | ||
using System.Net; | ||
using System.Text.Json; | ||
using FluentAssertions; | ||
using JetBrains.Annotations; | ||
using JsonApiDotNetCore.Configuration; | ||
using JsonApiDotNetCore.Errors; | ||
using JsonApiDotNetCore.Resources; | ||
using JsonApiDotNetCore.Serialization.JsonConverters; | ||
using JsonApiDotNetCore.Serialization.Objects; | ||
using JsonApiDotNetCore.Serialization.Request; | ||
using Microsoft.AspNetCore.Http; | ||
using Microsoft.Extensions.Logging.Abstractions; | ||
using TestBuildingBlocks; | ||
using Xunit; | ||
|
||
namespace JsonApiDotNetCoreTests.UnitTests.Serialization.Extensions; | ||
|
||
public sealed class SourcePointerInExceptionTests | ||
{ | ||
private const string RequestBody = """ | ||
{ | ||
"data": { | ||
"type": "testResources", | ||
"attributes": { | ||
"ext-namespace:ext-name": "ignored" | ||
} | ||
} | ||
} | ||
"""; | ||
|
||
[Fact] | ||
public async Task Adds_source_pointer_to_thrown_JsonApiException_from_JsonConverter() | ||
{ | ||
// Arrange | ||
const string? relativeSourcePointer = null; | ||
|
||
var options = new JsonApiOptions(); | ||
IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add<TestResource, long>().Build(); | ||
var converter = new ThrowingResourceObjectConverter(resourceGraph, relativeSourcePointer); | ||
var reader = new FakeJsonApiReader(RequestBody, options, converter); | ||
var httpContext = new DefaultHttpContext(); | ||
|
||
// Act | ||
Func<Task> action = async () => await reader.ReadAsync(httpContext.Request); | ||
|
||
// Assert | ||
JsonApiException? exception = (await action.Should().ThrowExactlyAsync<JsonApiException>()).Which; | ||
|
||
exception.StackTrace.Should().Contain(nameof(ThrowingResourceObjectConverter)); | ||
exception.Errors.ShouldHaveCount(1); | ||
|
||
ErrorObject error = exception.Errors[0]; | ||
error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); | ||
error.Title.Should().Be("Extension error"); | ||
error.Source.ShouldNotBeNull(); | ||
error.Source.Pointer.Should().Be("/data"); | ||
} | ||
|
||
[Fact] | ||
public async Task Makes_source_pointer_absolute_in_thrown_JsonApiException_from_JsonConverter() | ||
{ | ||
// Arrange | ||
const string relativeSourcePointer = "relative/path"; | ||
|
||
var options = new JsonApiOptions(); | ||
IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add<TestResource, long>().Build(); | ||
var converter = new ThrowingResourceObjectConverter(resourceGraph, relativeSourcePointer); | ||
var reader = new FakeJsonApiReader(RequestBody, options, converter); | ||
var httpContext = new DefaultHttpContext(); | ||
|
||
// Act | ||
Func<Task> action = async () => await reader.ReadAsync(httpContext.Request); | ||
|
||
// Assert | ||
JsonApiException? exception = (await action.Should().ThrowExactlyAsync<JsonApiException>()).Which; | ||
|
||
exception.StackTrace.Should().Contain(nameof(ThrowingResourceObjectConverter)); | ||
exception.Errors.ShouldHaveCount(1); | ||
|
||
ErrorObject error = exception.Errors[0]; | ||
error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity); | ||
error.Title.Should().Be("Extension error"); | ||
error.Source.ShouldNotBeNull(); | ||
error.Source.Pointer.Should().Be("/data/relative/path"); | ||
} | ||
|
||
[UsedImplicitly(ImplicitUseTargetFlags.Members)] | ||
private sealed class TestResource : Identifiable<long>; | ||
|
||
private sealed class ThrowingResourceObjectConverter(IResourceGraph resourceGraph, string? relativeSourcePointer) | ||
: ResourceObjectConverter(resourceGraph) | ||
{ | ||
private readonly string? _relativeSourcePointer = relativeSourcePointer; | ||
|
||
private protected override void ValidateExtensionInAttributes(string extensionNamespace, string extensionName, ResourceType resourceType, | ||
Utf8JsonReader reader) | ||
{ | ||
var exception = new JsonApiException(new ErrorObject(HttpStatusCode.UnprocessableEntity) | ||
{ | ||
Title = "Extension error" | ||
}); | ||
|
||
if (_relativeSourcePointer != null) | ||
{ | ||
exception.Errors[0].Source = new ErrorSource | ||
{ | ||
Pointer = _relativeSourcePointer | ||
}; | ||
} | ||
|
||
CapturedThrow(exception); | ||
} | ||
} | ||
|
||
private sealed class FakeJsonApiReader : IJsonApiReader | ||
{ | ||
private readonly string _requestBody; | ||
|
||
private readonly JsonSerializerOptions _serializerOptions; | ||
|
||
public FakeJsonApiReader(string requestBody, JsonApiOptions options, ResourceObjectConverter converter) | ||
{ | ||
_requestBody = requestBody; | ||
|
||
_serializerOptions = new JsonSerializerOptions(options.SerializerOptions); | ||
_serializerOptions.Converters.Add(converter); | ||
} | ||
|
||
public Task<object?> ReadAsync(HttpRequest httpRequest) | ||
{ | ||
try | ||
{ | ||
JsonSerializer.Deserialize<Document>(_requestBody, _serializerOptions); | ||
} | ||
catch (NotSupportedException exception) when (exception.HasJsonApiException()) | ||
{ | ||
throw exception.EnrichSourcePointer(); | ||
} | ||
|
||
return Task.FromResult<object?>(null); | ||
} | ||
} | ||
} |