Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit 7eec5ca

Browse files
Marshall JsonApiException thrown from JsonConverter such that the source pointer can be reconstructed (#1690)
1 parent caba090 commit 7eec5ca

File tree

5 files changed

+300
-6
lines changed

5 files changed

+300
-6
lines changed

‎src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs

Lines changed: 21 additions & 0 deletions
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
}

‎src/JsonApiDotNetCore/Serialization/Request/JsonApiReader.cs

Lines changed: 4 additions & 0 deletions
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)
Lines changed: 91 additions & 0 deletions
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+
}

‎test/JsonApiDotNetCoreTests/UnitTests/Serialization/Extensions/ResourceObjectConverterTests.cs

Lines changed: 41 additions & 6 deletions
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;
Lines changed: 143 additions & 0 deletions
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)

AltStyle によって変換されたページ (->オリジナル) /