Skip to content

Commit

Permalink
Merge pull request #1691 from json-api-dotnet/merge-master-into-openapi
Browse files Browse the repository at this point in the history
Merge master into openapi
  • Loading branch information
bkoelman authored Feb 23, 2025
2 parents 6d33a9a + 1c8ae15 commit fcd3aa1
Show file tree
Hide file tree
Showing 5 changed files with 300 additions and 6 deletions.
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);
}
}
}

0 comments on commit fcd3aa1

Please sign in to comment.