Skip to content

Marshall JsonApiException thrown from JsonConverter #1690

New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Merged
merged 1 commit into from
Feb 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Runtime.ExceptionServices;
using System.Text.Json;
using JetBrains.Annotations;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Errors;
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Resources.Annotations;
using JsonApiDotNetCore.Serialization.Objects;
Expand Down Expand Up @@ -372,4 +375,22 @@ private protected virtual void WriteExtensionInAttributes(Utf8JsonWriter writer,
private protected virtual void WriteExtensionInRelationships(Utf8JsonWriter writer, ResourceObject value)
{
}

/// <summary>
/// Throws a <see cref="JsonApiException" /> in such a way that <see cref="JsonApiReader" /> can reconstruct the source pointer.
/// </summary>
/// <param name="exception">
/// The <see cref="JsonApiException" /> to throw, which may contain a relative source pointer.
/// </param>
[DoesNotReturn]
[ContractAnnotation("=> halt")]
private protected static void CapturedThrow(JsonApiException exception)
{
ExceptionDispatchInfo.SetCurrentStackTrace(exception);

throw new NotSupportedException(null, exception)
{
Source = "System.Text.Json.Rethrowable"
};
}
}
4 changes: 4 additions & 0 deletions src/JsonApiDotNetCore/Serialization/Request/JsonApiReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ private Document DeserializeDocument(string requestBody)
// https://github.com/dotnet/runtime/issues/50205#issuecomment-808401245
throw new InvalidRequestBodyException(_options.IncludeRequestBodyInErrors ? requestBody : null, null, exception.Message, null, null, exception);
}
catch (NotSupportedException exception) when (exception.HasJsonApiException())
{
throw exception.EnrichSourcePointer();
}
}

private void AssertHasDocument([SysNotNull] Document? document, string requestBody)
Expand Down
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;
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
using System.Net;
using System.Text;
using System.Text.Json;
using FluentAssertions;
using JetBrains.Annotations;
using JsonApiDotNetCore.Configuration;
using JsonApiDotNetCore.Errors;
using JsonApiDotNetCore.Middleware;
using JsonApiDotNetCore.Resources;
using JsonApiDotNetCore.Resources.Annotations;
Expand Down Expand Up @@ -186,7 +188,16 @@ public void Throws_for_request_body_with_extension_in_attributes_when_extension_
};

// Assert
action.Should().ThrowExactly<JsonException>().WithMessage("Failure requested from attributes.");
JsonApiException? exception = action.Should().ThrowExactly<NotSupportedException>().WithInnerExceptionExactly<JsonApiException>().Which;

exception.StackTrace.Should().Contain(nameof(ExtensionAwareResourceObjectConverter));
exception.Errors.ShouldHaveCount(1);

ErrorObject error = exception.Errors[0];
error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
error.Title.Should().Be("Failure requested from attributes.");
error.Source.ShouldNotBeNull();
error.Source.Pointer.Should().Be("attributes/type-info:fail");
}

[Fact]
Expand Down Expand Up @@ -218,7 +229,16 @@ public void Throws_for_request_body_with_extension_in_relationships_when_extensi
};

// Assert
action.Should().ThrowExactly<JsonException>().WithMessage("Failure requested from relationships.");
JsonApiException? exception = action.Should().ThrowExactly<NotSupportedException>().WithInnerExceptionExactly<JsonApiException>().Which;

exception.StackTrace.Should().Contain(nameof(ExtensionAwareResourceObjectConverter));
exception.Errors.ShouldHaveCount(1);

ErrorObject error = exception.Errors[0];
error.StatusCode.Should().Be(HttpStatusCode.BadRequest);
error.Title.Should().Be("Failure requested from relationships.");
error.Source.ShouldNotBeNull();
error.Source.Pointer.Should().Be("relationships/type-info:fail");
}

[Fact]
Expand Down Expand Up @@ -401,6 +421,7 @@ public void Writes_extension_in_response_body_when_extension_enabled_with_derive
private sealed class ExtensionAwareResourceObjectConverter : ResourceObjectConverter
{
private const string ExtensionNamespace = "type-info";
private const string ExtensionName = "fail";

private readonly IResourceGraph _resourceGraph;
private readonly JsonApiRequestAccessor _requestAccessor;
Expand All @@ -420,11 +441,18 @@ public ExtensionAwareResourceObjectConverter(IResourceGraph resourceGraph, JsonA
private protected override void ValidateExtensionInAttributes(string extensionNamespace, string extensionName, ResourceType resourceType,
Utf8JsonReader reader)
{
if (extensionNamespace == ExtensionNamespace && IsTypeInfoExtensionEnabled && extensionName == "fail")
if (extensionNamespace == ExtensionNamespace && IsTypeInfoExtensionEnabled && extensionName == ExtensionName)
{
if (reader.GetBoolean())
{
throw new JsonException("Failure requested from attributes.");
CapturedThrow(new JsonApiException(new ErrorObject(HttpStatusCode.BadRequest)
{
Title = "Failure requested from attributes.",
Source = new ErrorSource
{
Pointer = $"attributes/{ExtensionNamespace}:{ExtensionName}"
}
}));
}

return;
Expand All @@ -436,11 +464,18 @@ private protected override void ValidateExtensionInAttributes(string extensionNa
private protected override void ValidateExtensionInRelationships(string extensionNamespace, string extensionName, ResourceType resourceType,
Utf8JsonReader reader)
{
if (extensionNamespace == ExtensionNamespace && IsTypeInfoExtensionEnabled && extensionName == "fail")
if (extensionNamespace == ExtensionNamespace && IsTypeInfoExtensionEnabled && extensionName == ExtensionName)
{
if (reader.GetBoolean())
{
throw new JsonException("Failure requested from relationships.");
CapturedThrow(new JsonApiException(new ErrorObject(HttpStatusCode.BadRequest)
{
Title = "Failure requested from relationships.",
Source = new ErrorSource
{
Pointer = $"relationships/{ExtensionNamespace}:{ExtensionName}"
}
}));
}

return;
Expand Down
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_JsonApiException_thrown_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_JsonApiException_thrown_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);
}
}
}
Loading