Skip to content

Commit fa253cc

Browse files
committed
Marshall JsonApiException thrown from JsonConverter such that the source pointer can be reconstructed
1 parent caba090 commit fa253cc

File tree

5 files changed

+300
-6
lines changed

5 files changed

+300
-6
lines changed

Diff for: src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs

+21
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
using System.Diagnostics.CodeAnalysis;
12
using System.Reflection;
3+
using System.Runtime.ExceptionServices;
24
using System.Text.Json;
35
using JetBrains.Annotations;
46
using JsonApiDotNetCore.Configuration;
7+
using JsonApiDotNetCore.Errors;
58
using JsonApiDotNetCore.Resources;
69
using JsonApiDotNetCore.Resources.Annotations;
710
using JsonApiDotNetCore.Serialization.Objects;
@@ -372,4 +375,22 @@ private protected virtual void WriteExtensionInAttributes(Utf8JsonWriter writer,
372375
private protected virtual void WriteExtensionInRelationships(Utf8JsonWriter writer, ResourceObject value)
373376
{
374377
}
378+
379+
/// <summary>
380+
/// Throws a <see cref="JsonApiException" /> in such a way that <see cref="JsonApiReader" /> can reconstruct the source pointer.
381+
/// </summary>
382+
/// <param name="exception">
383+
/// The <see cref="JsonApiException" /> to throw, which may contain a relative source pointer.
384+
/// </param>
385+
[DoesNotReturn]
386+
[ContractAnnotation("=> halt")]
387+
private protected static void CapturedThrow(JsonApiException exception)
388+
{
389+
ExceptionDispatchInfo.SetCurrentStackTrace(exception);
390+
391+
throw new NotSupportedException(null, exception)
392+
{
393+
Source = "System.Text.Json.Rethrowable"
394+
};
395+
}
375396
}

Diff for: src/JsonApiDotNetCore/Serialization/Request/JsonApiReader.cs

+4
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,10 @@ private Document DeserializeDocument(string requestBody)
9797
// https://github.com/dotnet/runtime/issues/50205#issuecomment-808401245
9898
throw new InvalidRequestBodyException(_options.IncludeRequestBodyInErrors ? requestBody : null, null, exception.Message, null, null, exception);
9999
}
100+
catch (NotSupportedException exception) when (exception.HasJsonApiException())
101+
{
102+
throw exception.EnrichSourcePointer();
103+
}
100104
}
101105

102106
private void AssertHasDocument([SysNotNull] Document? document, string requestBody)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
using System.Text.Json.Serialization;
2+
using JsonApiDotNetCore.Errors;
3+
using JsonApiDotNetCore.Serialization.Objects;
4+
5+
namespace JsonApiDotNetCore.Serialization.Request;
6+
7+
/// <summary>
8+
/// A hacky approach to obtain the proper JSON:API source pointer from an exception thrown in a <see cref="JsonConverter" />.
9+
/// </summary>
10+
/// <remarks>
11+
/// <para>
12+
/// This method relies on the behavior at
13+
/// https://github.com/dotnet/runtime/blob/release/8.0/src/libraries/System.Text.Json/src/System/Text/Json/Serialization/JsonConverterOfT.ReadCore.cs#L100,
14+
/// which wraps a thrown <see cref="NotSupportedException" /> and adds the JSON path to the outer exception message, based on internal reader state.
15+
/// </para>
16+
/// <para>
17+
/// To take advantage of this, we expect a custom converter to throw a <see cref="NotSupportedException" /> with a specially-crafted
18+
/// <see cref="Exception.Source" /> and a nested <see cref="JsonApiException" /> containing a relative source pointer and a captured stack trace. Once
19+
/// 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
20+
/// nested <see cref="JsonApiException" /> with.
21+
/// </para>
22+
/// </remarks>
23+
internal static class NotSupportedExceptionExtensions
24+
{
25+
private const string LeadingText = " Path: ";
26+
private const string TrailingText = " | LineNumber: ";
27+
28+
public static bool HasJsonApiException(this NotSupportedException exception)
29+
{
30+
return exception.InnerException is NotSupportedException { InnerException: JsonApiException };
31+
}
32+
33+
public static JsonApiException EnrichSourcePointer(this NotSupportedException exception)
34+
{
35+
var jsonApiException = (JsonApiException)exception.InnerException!.InnerException!;
36+
string? sourcePointer = GetSourcePointerFromMessage(exception.Message);
37+
38+
if (sourcePointer != null)
39+
{
40+
foreach (ErrorObject error in jsonApiException.Errors)
41+
{
42+
if (error.Source == null)
43+
{
44+
error.Source = new ErrorSource
45+
{
46+
Pointer = sourcePointer
47+
};
48+
}
49+
else
50+
{
51+
error.Source.Pointer = sourcePointer + '/' + error.Source.Pointer;
52+
}
53+
}
54+
}
55+
56+
return jsonApiException;
57+
}
58+
59+
private static string? GetSourcePointerFromMessage(string message)
60+
{
61+
string? jsonPath = ExtractJsonPathFromMessage(message);
62+
return JsonPathToSourcePointer(jsonPath);
63+
}
64+
65+
private static string? ExtractJsonPathFromMessage(string message)
66+
{
67+
int startIndex = message.IndexOf(LeadingText, StringComparison.Ordinal);
68+
69+
if (startIndex != -1)
70+
{
71+
int stopIndex = message.IndexOf(TrailingText, startIndex, StringComparison.Ordinal);
72+
73+
if (stopIndex != -1)
74+
{
75+
return message.Substring(startIndex + LeadingText.Length, stopIndex - startIndex - LeadingText.Length);
76+
}
77+
}
78+
79+
return null;
80+
}
81+
82+
private static string? JsonPathToSourcePointer(string? jsonPath)
83+
{
84+
if (jsonPath != null && jsonPath.StartsWith('$'))
85+
{
86+
return jsonPath[1..].Replace('.', '/');
87+
}
88+
89+
return null;
90+
}
91+
}

Diff for: test/JsonApiDotNetCoreTests/UnitTests/Serialization/Extensions/ResourceObjectConverterTests.cs

+41-6
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
using System.Net;
12
using System.Text;
23
using System.Text.Json;
34
using FluentAssertions;
45
using JetBrains.Annotations;
56
using JsonApiDotNetCore.Configuration;
7+
using JsonApiDotNetCore.Errors;
68
using JsonApiDotNetCore.Middleware;
79
using JsonApiDotNetCore.Resources;
810
using JsonApiDotNetCore.Resources.Annotations;
@@ -186,7 +188,16 @@ public void Throws_for_request_body_with_extension_in_attributes_when_extension_
186188
};
187189

188190
// Assert
189-
action.Should().ThrowExactly<JsonException>().WithMessage("Failure requested from attributes.");
191+
JsonApiException? exception = action.Should().ThrowExactly<NotSupportedException>().WithInnerExceptionExactly<JsonApiException>().Which;
192+
193+
exception.StackTrace.Should().Contain(nameof(ExtensionAwareResourceObjectConverter));
194+
exception.Errors.ShouldHaveCount(1);
195+
196+
ErrorObject error = exception.Errors[0];
197+
error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
198+
error.Title.Should().Be("Failure requested from attributes.");
199+
error.Source.ShouldNotBeNull();
200+
error.Source.Pointer.Should().Be("attributes/type-info:fail");
190201
}
191202

192203
[Fact]
@@ -218,7 +229,16 @@ public void Throws_for_request_body_with_extension_in_relationships_when_extensi
218229
};
219230

220231
// Assert
221-
action.Should().ThrowExactly<JsonException>().WithMessage("Failure requested from relationships.");
232+
JsonApiException? exception = action.Should().ThrowExactly<NotSupportedException>().WithInnerExceptionExactly<JsonApiException>().Which;
233+
234+
exception.StackTrace.Should().Contain(nameof(ExtensionAwareResourceObjectConverter));
235+
exception.Errors.ShouldHaveCount(1);
236+
237+
ErrorObject error = exception.Errors[0];
238+
error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
239+
error.Title.Should().Be("Failure requested from relationships.");
240+
error.Source.ShouldNotBeNull();
241+
error.Source.Pointer.Should().Be("relationships/type-info:fail");
222242
}
223243

224244
[Fact]
@@ -401,6 +421,7 @@ public void Writes_extension_in_response_body_when_extension_enabled_with_derive
401421
private sealed class ExtensionAwareResourceObjectConverter : ResourceObjectConverter
402422
{
403423
private const string ExtensionNamespace = "type-info";
424+
private const string ExtensionName = "fail";
404425

405426
private readonly IResourceGraph _resourceGraph;
406427
private readonly JsonApiRequestAccessor _requestAccessor;
@@ -420,11 +441,18 @@ public ExtensionAwareResourceObjectConverter(IResourceGraph resourceGraph, JsonA
420441
private protected override void ValidateExtensionInAttributes(string extensionNamespace, string extensionName, ResourceType resourceType,
421442
Utf8JsonReader reader)
422443
{
423-
if (extensionNamespace == ExtensionNamespace && IsTypeInfoExtensionEnabled && extensionName == "fail")
444+
if (extensionNamespace == ExtensionNamespace && IsTypeInfoExtensionEnabled && extensionName == ExtensionName)
424445
{
425446
if (reader.GetBoolean())
426447
{
427-
throw new JsonException("Failure requested from attributes.");
448+
CapturedThrow(new JsonApiException(new ErrorObject(HttpStatusCode.BadRequest)
449+
{
450+
Title = "Failure requested from attributes.",
451+
Source = new ErrorSource
452+
{
453+
Pointer = $"attributes/{ExtensionNamespace}:{ExtensionName}"
454+
}
455+
}));
428456
}
429457

430458
return;
@@ -436,11 +464,18 @@ private protected override void ValidateExtensionInAttributes(string extensionNa
436464
private protected override void ValidateExtensionInRelationships(string extensionNamespace, string extensionName, ResourceType resourceType,
437465
Utf8JsonReader reader)
438466
{
439-
if (extensionNamespace == ExtensionNamespace && IsTypeInfoExtensionEnabled && extensionName == "fail")
467+
if (extensionNamespace == ExtensionNamespace && IsTypeInfoExtensionEnabled && extensionName == ExtensionName)
440468
{
441469
if (reader.GetBoolean())
442470
{
443-
throw new JsonException("Failure requested from relationships.");
471+
CapturedThrow(new JsonApiException(new ErrorObject(HttpStatusCode.BadRequest)
472+
{
473+
Title = "Failure requested from relationships.",
474+
Source = new ErrorSource
475+
{
476+
Pointer = $"relationships/{ExtensionNamespace}:{ExtensionName}"
477+
}
478+
}));
444479
}
445480

446481
return;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
using System.Net;
2+
using System.Text.Json;
3+
using FluentAssertions;
4+
using JetBrains.Annotations;
5+
using JsonApiDotNetCore.Configuration;
6+
using JsonApiDotNetCore.Errors;
7+
using JsonApiDotNetCore.Resources;
8+
using JsonApiDotNetCore.Serialization.JsonConverters;
9+
using JsonApiDotNetCore.Serialization.Objects;
10+
using JsonApiDotNetCore.Serialization.Request;
11+
using Microsoft.AspNetCore.Http;
12+
using Microsoft.Extensions.Logging.Abstractions;
13+
using TestBuildingBlocks;
14+
using Xunit;
15+
16+
namespace JsonApiDotNetCoreTests.UnitTests.Serialization.Extensions;
17+
18+
public sealed class SourcePointerInExceptionTests
19+
{
20+
private const string RequestBody = """
21+
{
22+
"data": {
23+
"type": "testResources",
24+
"attributes": {
25+
"ext-namespace:ext-name": "ignored"
26+
}
27+
}
28+
}
29+
""";
30+
31+
[Fact]
32+
public async Task Adds_source_pointer_to_JsonApiException_thrown_from_JsonConverter()
33+
{
34+
// Arrange
35+
const string? relativeSourcePointer = null;
36+
37+
var options = new JsonApiOptions();
38+
IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add<TestResource, long>().Build();
39+
var converter = new ThrowingResourceObjectConverter(resourceGraph, relativeSourcePointer);
40+
var reader = new FakeJsonApiReader(RequestBody, options, converter);
41+
var httpContext = new DefaultHttpContext();
42+
43+
// Act
44+
Func<Task> action = async () => await reader.ReadAsync(httpContext.Request);
45+
46+
// Assert
47+
JsonApiException? exception = (await action.Should().ThrowExactlyAsync<JsonApiException>()).Which;
48+
49+
exception.StackTrace.Should().Contain(nameof(ThrowingResourceObjectConverter));
50+
exception.Errors.ShouldHaveCount(1);
51+
52+
ErrorObject error = exception.Errors[0];
53+
error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
54+
error.Title.Should().Be("Extension error");
55+
error.Source.ShouldNotBeNull();
56+
error.Source.Pointer.Should().Be("/data");
57+
}
58+
59+
[Fact]
60+
public async Task Makes_source_pointer_absolute_in_JsonApiException_thrown_from_JsonConverter()
61+
{
62+
// Arrange
63+
const string relativeSourcePointer = "relative/path";
64+
65+
var options = new JsonApiOptions();
66+
IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance).Add<TestResource, long>().Build();
67+
var converter = new ThrowingResourceObjectConverter(resourceGraph, relativeSourcePointer);
68+
var reader = new FakeJsonApiReader(RequestBody, options, converter);
69+
var httpContext = new DefaultHttpContext();
70+
71+
// Act
72+
Func<Task> action = async () => await reader.ReadAsync(httpContext.Request);
73+
74+
// Assert
75+
JsonApiException? exception = (await action.Should().ThrowExactlyAsync<JsonApiException>()).Which;
76+
77+
exception.StackTrace.Should().Contain(nameof(ThrowingResourceObjectConverter));
78+
exception.Errors.ShouldHaveCount(1);
79+
80+
ErrorObject error = exception.Errors[0];
81+
error.StatusCode.Should().Be(HttpStatusCode.UnprocessableEntity);
82+
error.Title.Should().Be("Extension error");
83+
error.Source.ShouldNotBeNull();
84+
error.Source.Pointer.Should().Be("/data/relative/path");
85+
}
86+
87+
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
88+
private sealed class TestResource : Identifiable<long>;
89+
90+
private sealed class ThrowingResourceObjectConverter(IResourceGraph resourceGraph, string? relativeSourcePointer)
91+
: ResourceObjectConverter(resourceGraph)
92+
{
93+
private readonly string? _relativeSourcePointer = relativeSourcePointer;
94+
95+
private protected override void ValidateExtensionInAttributes(string extensionNamespace, string extensionName, ResourceType resourceType,
96+
Utf8JsonReader reader)
97+
{
98+
var exception = new JsonApiException(new ErrorObject(HttpStatusCode.UnprocessableEntity)
99+
{
100+
Title = "Extension error"
101+
});
102+
103+
if (_relativeSourcePointer != null)
104+
{
105+
exception.Errors[0].Source = new ErrorSource
106+
{
107+
Pointer = _relativeSourcePointer
108+
};
109+
}
110+
111+
CapturedThrow(exception);
112+
}
113+
}
114+
115+
private sealed class FakeJsonApiReader : IJsonApiReader
116+
{
117+
private readonly string _requestBody;
118+
119+
private readonly JsonSerializerOptions _serializerOptions;
120+
121+
public FakeJsonApiReader(string requestBody, JsonApiOptions options, ResourceObjectConverter converter)
122+
{
123+
_requestBody = requestBody;
124+
125+
_serializerOptions = new JsonSerializerOptions(options.SerializerOptions);
126+
_serializerOptions.Converters.Add(converter);
127+
}
128+
129+
public Task<object?> ReadAsync(HttpRequest httpRequest)
130+
{
131+
try
132+
{
133+
JsonSerializer.Deserialize<Document>(_requestBody, _serializerOptions);
134+
}
135+
catch (NotSupportedException exception) when (exception.HasJsonApiException())
136+
{
137+
throw exception.EnrichSourcePointer();
138+
}
139+
140+
return Task.FromResult<object?>(null);
141+
}
142+
}
143+
}

0 commit comments

Comments
 (0)