diff --git a/src/JsonApiDotNetCore/Configuration/DefaultJsonApiApplicationBuilderEvents.cs b/src/JsonApiDotNetCore/Configuration/DefaultJsonApiApplicationBuilderEvents.cs new file mode 100644 index 000000000..dbab4e076 --- /dev/null +++ b/src/JsonApiDotNetCore/Configuration/DefaultJsonApiApplicationBuilderEvents.cs @@ -0,0 +1,20 @@ +using JsonApiDotNetCore.Serialization.JsonConverters; + +namespace JsonApiDotNetCore.Configuration; + +internal sealed class DefaultJsonApiApplicationBuilderEvents : IJsonApiApplicationBuilderEvents +{ + private readonly IJsonApiOptions _options; + + public DefaultJsonApiApplicationBuilderEvents(IJsonApiOptions options) + { + ArgumentGuard.NotNull(options); + + _options = options; + } + + public void ResourceGraphBuilt(IResourceGraph resourceGraph) + { + _options.SerializerOptions.Converters.Add(new ResourceObjectConverter(resourceGraph)); + } +} diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiApplicationBuilderEvents.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiApplicationBuilderEvents.cs new file mode 100644 index 000000000..525240bfc --- /dev/null +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiApplicationBuilderEvents.cs @@ -0,0 +1,6 @@ +namespace JsonApiDotNetCore.Configuration; + +internal interface IJsonApiApplicationBuilderEvents +{ + void ResourceGraphBuilt(IResourceGraph resourceGraph); +} diff --git a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs index 51ab46d57..7141125e4 100644 --- a/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/IJsonApiOptions.cs @@ -176,15 +176,15 @@ public interface IJsonApiOptions /// /// Lists the JSON:API extensions that are turned on. Empty by default, but if your project contains a controller that derives from - /// , the and - /// extensions are automatically added. + /// , the and + /// extensions are automatically added. /// /// /// To implement a custom JSON:API extension, add it here and override to indicate which /// combinations of extensions are available, depending on the current endpoint. Use to obtain the active /// extensions when implementing extension-specific logic. /// - IReadOnlySet Extensions { get; } + IReadOnlySet Extensions { get; } /// /// Enables to customize the settings that are used by the . diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs index 913d7a306..8f9419558 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiApplicationBuilder.cs @@ -7,7 +7,6 @@ using JsonApiDotNetCore.QueryStrings; using JsonApiDotNetCore.Repositories; using JsonApiDotNetCore.Resources; -using JsonApiDotNetCore.Serialization.JsonConverters; using JsonApiDotNetCore.Serialization.Request; using JsonApiDotNetCore.Serialization.Request.Adapters; using JsonApiDotNetCore.Serialization.Response; @@ -74,6 +73,8 @@ public void ConfigureResourceGraph(ICollection dbContextTypes, Action { var loggerFactory = serviceProvider.GetRequiredService(); + var events = serviceProvider.GetRequiredService(); + var resourceGraphBuilder = new ResourceGraphBuilder(_options, loggerFactory); var scanner = new ResourcesAssemblyScanner(_assemblyCache, resourceGraphBuilder); @@ -93,8 +94,7 @@ public void ConfigureResourceGraph(ICollection dbContextTypes, Action dbContextTypes) _services.TryAddScoped(); _services.TryAddScoped(); _services.TryAddSingleton(); + _services.TryAddSingleton(); } private void AddMiddlewareLayer() diff --git a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs index 0446fef4f..084070b27 100644 --- a/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs +++ b/src/JsonApiDotNetCore/Configuration/JsonApiOptions.cs @@ -12,7 +12,7 @@ namespace JsonApiDotNetCore.Configuration; [PublicAPI] public sealed class JsonApiOptions : IJsonApiOptions { - private static readonly IReadOnlySet EmptyExtensionSet = new HashSet().AsReadOnly(); + private static readonly IReadOnlySet EmptyExtensionSet = new HashSet().AsReadOnly(); private readonly Lazy _lazySerializerWriteOptions; private readonly Lazy _lazySerializerReadOptions; @@ -100,7 +100,7 @@ public bool AllowClientGeneratedIds public IsolationLevel? TransactionIsolationLevel { get; set; } /// - public IReadOnlySet Extensions { get; set; } = EmptyExtensionSet; + public IReadOnlySet Extensions { get; set; } = EmptyExtensionSet; /// public JsonSerializerOptions SerializerOptions { get; } = new() @@ -142,15 +142,15 @@ public JsonApiOptions() /// /// The JSON:API extensions to add. /// - public void IncludeExtensions(params JsonApiExtension[] extensionsToAdd) + public void IncludeExtensions(params JsonApiMediaTypeExtension[] extensionsToAdd) { ArgumentGuard.NotNull(extensionsToAdd); if (!Extensions.IsSupersetOf(extensionsToAdd)) { - var extensions = new HashSet(Extensions); + var extensions = new HashSet(Extensions); - foreach (JsonApiExtension extension in extensionsToAdd) + foreach (JsonApiMediaTypeExtension extension in extensionsToAdd) { extensions.Add(extension); } diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiContentNegotiator.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiContentNegotiator.cs index 130f0b09d..f80c91026 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiContentNegotiator.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiContentNegotiator.cs @@ -12,5 +12,5 @@ public interface IJsonApiContentNegotiator /// Validates the Content-Type and Accept HTTP headers from the incoming request. Throws a if unsupported. Otherwise, /// returns the list of negotiated JSON:API extensions, which should always be a subset of . /// - IReadOnlySet Negotiate(); + IReadOnlySet Negotiate(); } diff --git a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs index c8e9acae7..959c89d66 100644 --- a/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs +++ b/src/JsonApiDotNetCore/Middleware/IJsonApiRequest.cs @@ -63,7 +63,7 @@ public interface IJsonApiRequest /// /// The JSON:API extensions enabled for the current request. This is always a subset of . /// - IReadOnlySet Extensions { get; } + IReadOnlySet Extensions { get; } /// /// Performs a shallow copy. diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiContentNegotiator.cs b/src/JsonApiDotNetCore/Middleware/JsonApiContentNegotiator.cs index a425437ce..5a3c08ee2 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiContentNegotiator.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiContentNegotiator.cs @@ -36,7 +36,7 @@ public JsonApiContentNegotiator(IJsonApiOptions options, IHttpContextAccessor ht } /// - public IReadOnlySet Negotiate() + public IReadOnlySet Negotiate() { IReadOnlyList possibleMediaTypes = GetPossibleMediaTypes(); @@ -66,7 +66,7 @@ public IReadOnlySet Negotiate() return mediaType; } - private IReadOnlySet ValidateAcceptHeader(IReadOnlyList possibleMediaTypes, JsonApiMediaType? requestMediaType) + private IReadOnlySet ValidateAcceptHeader(IReadOnlyList possibleMediaTypes, JsonApiMediaType? requestMediaType) { string[] acceptHeaderValues = HttpContext.Request.Headers.GetCommaSeparatedValues("Accept"); JsonApiMediaType? bestMatch = null; @@ -166,12 +166,12 @@ protected virtual IReadOnlyList GetPossibleMediaTypes() if (IsOperationsEndpoint()) { - if (_options.Extensions.Contains(JsonApiExtension.AtomicOperations)) + if (_options.Extensions.Contains(JsonApiMediaTypeExtension.AtomicOperations)) { mediaTypes.Add(JsonApiMediaType.AtomicOperations); } - if (_options.Extensions.Contains(JsonApiExtension.RelaxedAtomicOperations)) + if (_options.Extensions.Contains(JsonApiMediaTypeExtension.RelaxedAtomicOperations)) { mediaTypes.Add(JsonApiMediaType.RelaxedAtomicOperations); } diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiMediaType.cs b/src/JsonApiDotNetCore/Middleware/JsonApiMediaType.cs index b2fb0f64e..57e5c40e5 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiMediaType.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiMediaType.cs @@ -22,23 +22,23 @@ public sealed class JsonApiMediaType : IEquatable /// /// Gets the JSON:API media type with the "https://jsonapi.org/ext/atomic" extension. /// - public static readonly JsonApiMediaType AtomicOperations = new([JsonApiExtension.AtomicOperations]); + public static readonly JsonApiMediaType AtomicOperations = new([JsonApiMediaTypeExtension.AtomicOperations]); /// /// Gets the JSON:API media type with the "atomic-operations" extension. /// - public static readonly JsonApiMediaType RelaxedAtomicOperations = new([JsonApiExtension.RelaxedAtomicOperations]); + public static readonly JsonApiMediaType RelaxedAtomicOperations = new([JsonApiMediaTypeExtension.RelaxedAtomicOperations]); - public IReadOnlySet Extensions { get; } + public IReadOnlySet Extensions { get; } - public JsonApiMediaType(IReadOnlySet extensions) + public JsonApiMediaType(IReadOnlySet extensions) { ArgumentGuard.NotNull(extensions); Extensions = extensions; } - public JsonApiMediaType(IEnumerable extensions) + public JsonApiMediaType(IEnumerable extensions) { ArgumentGuard.NotNull(extensions); @@ -69,7 +69,7 @@ private static (JsonApiMediaType MediaType, decimal QualityFactor)? TryParse(str if (isBaseMatch) { - HashSet extensions = []; + HashSet extensions = []; decimal qualityFactor = 1.0m; @@ -97,13 +97,13 @@ private static (JsonApiMediaType MediaType, decimal QualityFactor)? TryParse(str return null; } - private static void ParseExtensions(NameValueHeaderValue parameter, HashSet extensions) + private static void ParseExtensions(NameValueHeaderValue parameter, HashSet extensions) { string parameterValue = parameter.GetUnescapedValue().ToString(); foreach (string extValue in parameterValue.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { - var extension = new JsonApiExtension(extValue); + var extension = new JsonApiMediaTypeExtension(extValue); extensions.Add(extension); } } @@ -114,7 +114,7 @@ public override string ToString() List parameters = []; bool requiresEscape = false; - foreach (JsonApiExtension extension in Extensions) + foreach (JsonApiMediaTypeExtension extension in Extensions) { var extHeaderValue = new NameValueHeaderValue(ExtSegment); extHeaderValue.SetAndEscapeValue(extension.UnescapedValue); @@ -178,7 +178,7 @@ public override int GetHashCode() { int hashCode = 0; - foreach (JsonApiExtension extension in Extensions) + foreach (JsonApiMediaTypeExtension extension in Extensions) { hashCode = HashCode.Combine(hashCode, extension); } diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiExtension.cs b/src/JsonApiDotNetCore/Middleware/JsonApiMediaTypeExtension.cs similarity index 64% rename from src/JsonApiDotNetCore/Middleware/JsonApiExtension.cs rename to src/JsonApiDotNetCore/Middleware/JsonApiMediaTypeExtension.cs index d41cdf29f..0e07eb40e 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiExtension.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiMediaTypeExtension.cs @@ -6,14 +6,14 @@ namespace JsonApiDotNetCore.Middleware; /// Represents a JSON:API extension (in unescaped format), which occurs as an "ext" parameter inside an HTTP Accept or Content-Type header. /// [PublicAPI] -public sealed class JsonApiExtension : IEquatable +public sealed class JsonApiMediaTypeExtension : IEquatable { - public static readonly JsonApiExtension AtomicOperations = new("https://jsonapi.org/ext/atomic"); - public static readonly JsonApiExtension RelaxedAtomicOperations = new("atomic-operations"); + public static readonly JsonApiMediaTypeExtension AtomicOperations = new("https://jsonapi.org/ext/atomic"); + public static readonly JsonApiMediaTypeExtension RelaxedAtomicOperations = new("atomic-operations"); public string UnescapedValue { get; } - public JsonApiExtension(string unescapedValue) + public JsonApiMediaTypeExtension(string unescapedValue) { ArgumentGuard.NotNullNorEmpty(unescapedValue); @@ -25,7 +25,7 @@ public override string ToString() return UnescapedValue; } - public bool Equals(JsonApiExtension? other) + public bool Equals(JsonApiMediaTypeExtension? other) { if (other is null) { @@ -42,7 +42,7 @@ public bool Equals(JsonApiExtension? other) public override bool Equals(object? other) { - return Equals(other as JsonApiExtension); + return Equals(other as JsonApiMediaTypeExtension); } public override int GetHashCode() diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs index 9eab8505e..30632eb84 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiMiddleware.cs @@ -66,7 +66,7 @@ public async Task InvokeAsync(HttpContext httpContext, IJsonApiRequest request) try { ValidateIfMatchHeader(httpContext.Request); - IReadOnlySet extensions = _contentNegotiator.Negotiate(); + IReadOnlySet extensions = _contentNegotiator.Negotiate(); if (isResourceRequest) { @@ -130,7 +130,7 @@ private void ValidateIfMatchHeader(HttpRequest httpRequest) } private static void SetupResourceRequest(JsonApiRequest request, ResourceType primaryResourceType, RouteValueDictionary routeValues, - HttpRequest httpRequest, IReadOnlySet extensions) + HttpRequest httpRequest, IReadOnlySet extensions) { AssertNoAtomicOperationsExtension(extensions); @@ -184,9 +184,9 @@ private static void SetupResourceRequest(JsonApiRequest request, ResourceType pr request.Extensions = extensions; } - private static void AssertNoAtomicOperationsExtension(IReadOnlySet extensions) + private static void AssertNoAtomicOperationsExtension(IReadOnlySet extensions) { - if (extensions.Contains(JsonApiExtension.AtomicOperations) || extensions.Contains(JsonApiExtension.RelaxedAtomicOperations)) + if (extensions.Contains(JsonApiMediaTypeExtension.AtomicOperations) || extensions.Contains(JsonApiMediaTypeExtension.RelaxedAtomicOperations)) { throw new InvalidOperationException("Incorrect content negotiation implementation detected: Unexpected atomic:operations extension found."); } @@ -214,7 +214,7 @@ internal static bool IsRouteForOperations(RouteValueDictionary routeValues) return actionName == "PostOperations"; } - private static void SetupOperationsRequest(JsonApiRequest request, IReadOnlySet extensions) + private static void SetupOperationsRequest(JsonApiRequest request, IReadOnlySet extensions) { AssertHasAtomicOperationsExtension(extensions); @@ -223,9 +223,9 @@ private static void SetupOperationsRequest(JsonApiRequest request, IReadOnlySet< request.Extensions = extensions; } - private static void AssertHasAtomicOperationsExtension(IReadOnlySet extensions) + private static void AssertHasAtomicOperationsExtension(IReadOnlySet extensions) { - if (!extensions.Contains(JsonApiExtension.AtomicOperations) && !extensions.Contains(JsonApiExtension.RelaxedAtomicOperations)) + if (!extensions.Contains(JsonApiMediaTypeExtension.AtomicOperations) && !extensions.Contains(JsonApiMediaTypeExtension.RelaxedAtomicOperations)) { throw new InvalidOperationException("Incorrect content negotiation implementation detected: Missing atomic:operations extension."); } diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs index f883e4bd6..bba58aaab 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRequest.cs @@ -8,7 +8,7 @@ namespace JsonApiDotNetCore.Middleware; [PublicAPI] public sealed class JsonApiRequest : IJsonApiRequest { - private static readonly IReadOnlySet EmptyExtensionSet = new HashSet().AsReadOnly(); + private static readonly IReadOnlySet EmptyExtensionSet = new HashSet().AsReadOnly(); /// public EndpointKind Kind { get; set; } @@ -38,7 +38,7 @@ public sealed class JsonApiRequest : IJsonApiRequest public string? TransactionId { get; set; } /// - public IReadOnlySet Extensions { get; set; } = EmptyExtensionSet; + public IReadOnlySet Extensions { get; set; } = EmptyExtensionSet; /// public void CopyFrom(IJsonApiRequest other) diff --git a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs index 9424f97eb..72cac28da 100644 --- a/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs +++ b/src/JsonApiDotNetCore/Middleware/JsonApiRoutingConvention.cs @@ -113,7 +113,7 @@ public void Apply(ApplicationModel application) else { var options = (JsonApiOptions)_options; - options.IncludeExtensions(JsonApiExtension.AtomicOperations, JsonApiExtension.RelaxedAtomicOperations); + options.IncludeExtensions(JsonApiMediaTypeExtension.AtomicOperations, JsonApiMediaTypeExtension.RelaxedAtomicOperations); } if (IsRoutingConventionDisabled(controller)) diff --git a/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs b/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs index b76fc343a..fcb950b39 100644 --- a/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs +++ b/src/JsonApiDotNetCore/Serialization/JsonConverters/ResourceObjectConverter.cs @@ -13,7 +13,7 @@ namespace JsonApiDotNetCore.Serialization.JsonConverters; /// Converts to/from JSON. /// [UsedImplicitly(ImplicitUseKindFlags.InstantiatedNoFixedConstructorSignature)] -public sealed class ResourceObjectConverter : JsonObjectConverter +public class ResourceObjectConverter : JsonObjectConverter { private static readonly JsonEncodedText TypeText = JsonEncodedText.Encode("type"); private static readonly JsonEncodedText IdText = JsonEncodedText.Encode("id"); @@ -99,7 +99,7 @@ public override ResourceObject Read(ref Utf8JsonReader reader, Type typeToConver } case "relationships": { - resourceObject.Relationships = ReadSubTree>(ref reader, options); + resourceObject.Relationships = ReadRelationships(ref reader, options); break; } case "links": @@ -157,7 +157,7 @@ public override ResourceObject Read(ref Utf8JsonReader reader, Type typeToConver return null; } - private static Dictionary ReadAttributes(ref Utf8JsonReader reader, JsonSerializerOptions options, ResourceType resourceType) + private Dictionary ReadAttributes(ref Utf8JsonReader reader, JsonSerializerOptions options, ResourceType resourceType) { var attributes = new Dictionary(); @@ -174,6 +174,18 @@ public override ResourceObject Read(ref Utf8JsonReader reader, Type typeToConver string attributeName = reader.GetString() ?? string.Empty; reader.Read(); + int extensionSeparatorIndex = attributeName.IndexOf(':'); + + if (extensionSeparatorIndex != -1) + { + string extensionNamespace = attributeName[..extensionSeparatorIndex]; + string extensionName = attributeName[(extensionSeparatorIndex + 1)..]; + + ValidateExtensionInAttributes(extensionNamespace, extensionName, reader); + reader.Skip(); + continue; + } + AttrAttribute? attribute = resourceType.FindAttributeByPublicName(attributeName); PropertyInfo? property = attribute?.Property; @@ -219,6 +231,57 @@ public override ResourceObject Read(ref Utf8JsonReader reader, Type typeToConver throw GetEndOfStreamError(); } + // Currently exposed for internal use only, so we don't need a breaking change when adding support for multiple extensions. + private protected virtual void ValidateExtensionInAttributes(string extensionNamespace, string extensionName, Utf8JsonReader reader) + { + throw new JsonException($"Unsupported usage of JSON:API extension '{extensionNamespace}' in attributes."); + } + + private Dictionary ReadRelationships(ref Utf8JsonReader reader, JsonSerializerOptions options) + { + var relationships = new Dictionary(); + + while (reader.Read()) + { + switch (reader.TokenType) + { + case JsonTokenType.EndObject: + { + return relationships; + } + case JsonTokenType.PropertyName: + { + string relationshipName = reader.GetString() ?? string.Empty; + reader.Read(); + + int extensionSeparatorIndex = relationshipName.IndexOf(':'); + + if (extensionSeparatorIndex != -1) + { + string extensionNamespace = relationshipName[..extensionSeparatorIndex]; + string extensionName = relationshipName[(extensionSeparatorIndex + 1)..]; + + ValidateExtensionInRelationships(extensionNamespace, extensionName, reader); + reader.Skip(); + continue; + } + + var relationshipObject = ReadSubTree(ref reader, options); + relationships[relationshipName] = relationshipObject; + break; + } + } + } + + throw GetEndOfStreamError(); + } + + // Currently exposed for internal use only, so we don't need a breaking change when adding support for multiple extensions. + private protected virtual void ValidateExtensionInRelationships(string extensionNamespace, string extensionName, Utf8JsonReader reader) + { + throw new JsonException($"Unsupported usage of JSON:API extension '{extensionNamespace}' in relationships."); + } + /// /// Ensures that attribute values are not wrapped in s. /// @@ -244,13 +307,33 @@ public override void Write(Utf8JsonWriter writer, ResourceObject value, JsonSeri if (!value.Attributes.IsNullOrEmpty()) { writer.WritePropertyName(AttributesText); - WriteSubTree(writer, value.Attributes, options); + writer.WriteStartObject(); + + WriteExtensionInAttributes(writer, value); + + foreach ((string attributeName, object? attributeValue) in value.Attributes) + { + writer.WritePropertyName(attributeName); + WriteSubTree(writer, attributeValue, options); + } + + writer.WriteEndObject(); } if (!value.Relationships.IsNullOrEmpty()) { writer.WritePropertyName(RelationshipsText); - WriteSubTree(writer, value.Relationships, options); + writer.WriteStartObject(); + + WriteExtensionInRelationships(writer, value); + + foreach ((string relationshipName, RelationshipObject? relationshipValue) in value.Relationships) + { + writer.WritePropertyName(relationshipName); + WriteSubTree(writer, relationshipValue, options); + } + + writer.WriteEndObject(); } if (value.Links != null && value.Links.HasValue()) @@ -267,4 +350,14 @@ public override void Write(Utf8JsonWriter writer, ResourceObject value, JsonSeri writer.WriteEndObject(); } + + // Currently exposed for internal use only, so we don't need a breaking change when adding support for multiple extensions. + private protected virtual void WriteExtensionInAttributes(Utf8JsonWriter writer, ResourceObject value) + { + } + + // Currently exposed for internal use only, so we don't need a breaking change when adding support for multiple extensions. + private protected virtual void WriteExtensionInRelationships(Utf8JsonWriter writer, ResourceObject value) + { + } } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/CustomExtensionsAcceptHeaderTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/CustomExtensionsAcceptHeaderTests.cs index 9be730554..b5f6367b7 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/CustomExtensionsAcceptHeaderTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/CustomExtensionsAcceptHeaderTests.cs @@ -30,7 +30,7 @@ public CustomExtensionsAcceptHeaderTests(IntegrationTestContext(); - options.IncludeExtensions(ServerTimeExtensions.ServerTime, ServerTimeExtensions.RelaxedServerTime); + options.IncludeExtensions(ServerTimeMediaTypeExtension.ServerTime, ServerTimeMediaTypeExtension.RelaxedServerTime); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/CustomExtensionsContentTypeTests.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/CustomExtensionsContentTypeTests.cs index 2a44fb89b..3450b571f 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/CustomExtensionsContentTypeTests.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/CustomExtensionsContentTypeTests.cs @@ -40,7 +40,7 @@ public CustomExtensionsContentTypeTests(IntegrationTestContext(); - options.IncludeExtensions(ServerTimeExtensions.ServerTime, ServerTimeExtensions.RelaxedServerTime); + options.IncludeExtensions(ServerTimeMediaTypeExtension.ServerTime, ServerTimeMediaTypeExtension.RelaxedServerTime); } [Fact] diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeContentNegotiator.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeContentNegotiator.cs index af5081757..e6e507e2b 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeContentNegotiator.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeContentNegotiator.cs @@ -17,22 +17,24 @@ protected override IReadOnlyList GetPossibleMediaTypes() if (IsOperationsEndpoint()) { - if (_options.Extensions.Contains(JsonApiExtension.AtomicOperations)) + if (_options.Extensions.Contains(JsonApiMediaTypeExtension.AtomicOperations)) { mediaTypes.Add(JsonApiMediaType.AtomicOperations); } - if (_options.Extensions.Contains(JsonApiExtension.AtomicOperations) && _options.Extensions.Contains(ServerTimeExtensions.ServerTime)) + if (_options.Extensions.Contains(JsonApiMediaTypeExtension.AtomicOperations) && + _options.Extensions.Contains(ServerTimeMediaTypeExtension.ServerTime)) { mediaTypes.Add(ServerTimeMediaTypes.AtomicOperationsWithServerTime); } - if (_options.Extensions.Contains(JsonApiExtension.RelaxedAtomicOperations)) + if (_options.Extensions.Contains(JsonApiMediaTypeExtension.RelaxedAtomicOperations)) { mediaTypes.Add(JsonApiMediaType.RelaxedAtomicOperations); } - if (_options.Extensions.Contains(JsonApiExtension.RelaxedAtomicOperations) && _options.Extensions.Contains(ServerTimeExtensions.RelaxedServerTime)) + if (_options.Extensions.Contains(JsonApiMediaTypeExtension.RelaxedAtomicOperations) && + _options.Extensions.Contains(ServerTimeMediaTypeExtension.RelaxedServerTime)) { mediaTypes.Add(ServerTimeMediaTypes.RelaxedAtomicOperationsWithRelaxedServerTime); } @@ -41,12 +43,12 @@ protected override IReadOnlyList GetPossibleMediaTypes() { mediaTypes.Add(JsonApiMediaType.Default); - if (_options.Extensions.Contains(ServerTimeExtensions.ServerTime)) + if (_options.Extensions.Contains(ServerTimeMediaTypeExtension.ServerTime)) { mediaTypes.Add(ServerTimeMediaTypes.ServerTime); } - if (_options.Extensions.Contains(ServerTimeExtensions.RelaxedServerTime)) + if (_options.Extensions.Contains(ServerTimeMediaTypeExtension.RelaxedServerTime)) { mediaTypes.Add(ServerTimeMediaTypes.RelaxedServerTime); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeExtensions.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeExtensions.cs deleted file mode 100644 index d320c110f..000000000 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeExtensions.cs +++ /dev/null @@ -1,11 +0,0 @@ -using JsonApiDotNetCore.Middleware; - -#pragma warning disable AV1008 // Class should not be static - -namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation.CustomExtensions; - -internal static class ServerTimeExtensions -{ - public static readonly JsonApiExtension ServerTime = new("https://www.jsonapi.net/ext/server-time"); - public static readonly JsonApiExtension RelaxedServerTime = new("server-time"); -} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeMediaTypeExtension.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeMediaTypeExtension.cs new file mode 100644 index 000000000..35756e99a --- /dev/null +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeMediaTypeExtension.cs @@ -0,0 +1,11 @@ +using JsonApiDotNetCore.Middleware; + +#pragma warning disable AV1008 // Class should not be static + +namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation.CustomExtensions; + +internal static class ServerTimeMediaTypeExtension +{ + public static readonly JsonApiMediaTypeExtension ServerTime = new("https://www.jsonapi.net/ext/server-time"); + public static readonly JsonApiMediaTypeExtension RelaxedServerTime = new("server-time"); +} diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeMediaTypes.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeMediaTypes.cs index dc355536f..8fd5f0d37 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeMediaTypes.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeMediaTypes.cs @@ -6,16 +6,16 @@ namespace JsonApiDotNetCoreTests.IntegrationTests.ContentNegotiation.CustomExten internal static class ServerTimeMediaTypes { - public static readonly JsonApiMediaType ServerTime = new([ServerTimeExtensions.ServerTime]); - public static readonly JsonApiMediaType RelaxedServerTime = new([ServerTimeExtensions.RelaxedServerTime]); + public static readonly JsonApiMediaType ServerTime = new([ServerTimeMediaTypeExtension.ServerTime]); + public static readonly JsonApiMediaType RelaxedServerTime = new([ServerTimeMediaTypeExtension.RelaxedServerTime]); public static readonly JsonApiMediaType AtomicOperationsWithServerTime = new([ - JsonApiExtension.AtomicOperations, - ServerTimeExtensions.ServerTime + JsonApiMediaTypeExtension.AtomicOperations, + ServerTimeMediaTypeExtension.ServerTime ]); public static readonly JsonApiMediaType RelaxedAtomicOperationsWithRelaxedServerTime = new([ - JsonApiExtension.RelaxedAtomicOperations, - ServerTimeExtensions.RelaxedServerTime + JsonApiMediaTypeExtension.RelaxedAtomicOperations, + ServerTimeMediaTypeExtension.RelaxedServerTime ]); } diff --git a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeResponseMeta.cs b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeResponseMeta.cs index 3432e118a..178722847 100644 --- a/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeResponseMeta.cs +++ b/test/JsonApiDotNetCoreTests/IntegrationTests/ContentNegotiation/CustomExtensions/ServerTimeResponseMeta.cs @@ -9,7 +9,7 @@ internal sealed class ServerTimeResponseMeta(IJsonApiRequest request, RequestDoc public IDictionary? GetMeta() { - if (request.Extensions.Contains(ServerTimeExtensions.ServerTime) || request.Extensions.Contains(ServerTimeExtensions.RelaxedServerTime)) + if (request.Extensions.Contains(ServerTimeMediaTypeExtension.ServerTime) || request.Extensions.Contains(ServerTimeMediaTypeExtension.RelaxedServerTime)) { if (_documentStore.Document is not { Meta: not null } || !_documentStore.Document.Meta.TryGetValue("useLocalTime", out object? useLocalTimeValue) || useLocalTimeValue == null || !bool.TryParse(useLocalTimeValue.ToString(), out bool useLocalTime)) diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Middleware/JsonApiMiddlewareTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Middleware/JsonApiMiddlewareTests.cs index 0e6c78def..fbd577d51 100644 --- a/test/JsonApiDotNetCoreTests/UnitTests/Middleware/JsonApiMiddlewareTests.cs +++ b/test/JsonApiDotNetCoreTests/UnitTests/Middleware/JsonApiMiddlewareTests.cs @@ -47,7 +47,7 @@ public async Task Sets_request_properties_correctly(string requestMethod, string { // Arrange var options = new JsonApiOptions(); - options.IncludeExtensions(JsonApiExtension.AtomicOperations); + options.IncludeExtensions(JsonApiMediaTypeExtension.AtomicOperations); var request = new JsonApiRequest(); diff --git a/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Extensions/ResourceObjectConverterTests.cs b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Extensions/ResourceObjectConverterTests.cs new file mode 100644 index 000000000..f87ded5c1 --- /dev/null +++ b/test/JsonApiDotNetCoreTests/UnitTests/Serialization/Extensions/ResourceObjectConverterTests.cs @@ -0,0 +1,542 @@ +using System.Text; +using System.Text.Json; +using FluentAssertions; +using JetBrains.Annotations; +using JsonApiDotNetCore; +using JsonApiDotNetCore.Configuration; +using JsonApiDotNetCore.Middleware; +using JsonApiDotNetCore.Resources; +using JsonApiDotNetCore.Resources.Annotations; +using JsonApiDotNetCore.Serialization.JsonConverters; +using JsonApiDotNetCore.Serialization.Objects; +using Microsoft.Extensions.Logging.Abstractions; +using TestBuildingBlocks; +using Xunit; + +namespace JsonApiDotNetCoreTests.UnitTests.Serialization.Extensions; + +public sealed class ResourceObjectConverterTests +{ + private static readonly JsonApiMediaTypeExtension TypeInfoMediaTypeExtension = new("https://www.jsonapi.net/ext/type-info"); + + private static readonly JsonWriterOptions WriterOptions = new() + { + Indented = true + }; + + [Fact] + public void Permits_request_body_without_extension_usage() + { + // Arrange + TestContext testContext = TestContext.WithoutExtension; + + const string requestJson = """ + { + "type": "derivedTypes", + "attributes": { + "baseValue": "baseAttribute", + "derivedValue": "derivedAttribute" + }, + "relationships": { + "parent": { + "data": { + "type": "baseTypes", + "id": "1" + } + } + } + } + """; + + var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(requestJson)); + + // Act + ResourceObject resourceObject = testContext.Converter.Read(ref reader, typeof(ResourceObject), testContext.SerializerReadOptions); + + // Assert + resourceObject.Attributes.ShouldContainKey("baseValue").Should().Be("baseAttribute"); + resourceObject.Attributes.ShouldContainKey("derivedValue").Should().Be("derivedAttribute"); + + resourceObject.Relationships.ShouldContainKey("parent").With(value => + { + value.ShouldNotBeNull(); + value.Data.SingleValue.ShouldNotBeNull(); + value.Data.SingleValue.Type.Should().Be("baseTypes"); + value.Data.SingleValue.Id.Should().Be("1"); + }); + } + + [Fact] + public void Blocks_request_body_with_extension_in_attributes_when_extension_not_enabled() + { + // Arrange + TestContext testContext = TestContext.WithoutExtension; + + const string requestJson = """ + { + "type": "derivedTypes", + "attributes": { + "type-info:fail": false, + "baseValue": "baseAttribute", + "derivedValue": "derivedAttribute" + } + } + """; + + // Act + Action action = () => + { + var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(requestJson)); + _ = testContext.Converter.Read(ref reader, typeof(ResourceObject), testContext.SerializerReadOptions); + }; + + // Assert + action.Should().ThrowExactly().WithMessage("Unsupported usage of JSON:API extension 'type-info' in attributes."); + } + + [Fact] + public void Blocks_request_body_with_extension_in_relationships_when_extension_not_enabled() + { + // Arrange + TestContext testContext = TestContext.WithoutExtension; + + const string requestJson = """ + { + "type": "derivedTypes", + "relationships": { + "type-info:fail": false, + "parent": { + "data": { + "type": "baseTypes", + "id": "1" + } + } + } + } + """; + + // Act + Action action = () => + { + var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(requestJson)); + _ = testContext.Converter.Read(ref reader, typeof(ResourceObject), testContext.SerializerReadOptions); + }; + + // Assert + action.Should().ThrowExactly().WithMessage("Unsupported usage of JSON:API extension 'type-info' in relationships."); + } + + [Fact] + public void Permits_request_body_with_extension_when_extension_enabled() + { + // Arrange + TestContext testContext = TestContext.WithExtension; + + const string requestJson = """ + { + "type": "derivedTypes", + "attributes": { + "type-info:fail": false, + "baseValue": "baseAttribute", + "derivedValue": "derivedAttribute" + }, + "relationships": { + "type-info:fail": false, + "parent": { + "data": { + "type": "baseTypes", + "id": "1" + } + } + } + } + """; + + var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(requestJson)); + + // Act + ResourceObject resourceObject = testContext.Converter.Read(ref reader, typeof(ResourceObject), testContext.SerializerReadOptions); + + // Assert + resourceObject.Attributes.ShouldNotBeNull(); + resourceObject.Relationships.ShouldNotBeNull(); + } + + [Fact] + public void Throws_for_request_body_with_extension_in_attributes_when_extension_enabled() + { + // Arrange + TestContext testContext = TestContext.WithExtension; + + const string requestJson = """ + { + "type": "derivedTypes", + "attributes": { + "type-info:fail": true, + "baseValue": "baseAttribute", + "derivedValue": "derivedAttribute" + } + } + """; + + // Act + Action action = () => + { + var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(requestJson)); + _ = testContext.Converter.Read(ref reader, typeof(ResourceObject), testContext.SerializerReadOptions); + }; + + // Assert + action.Should().ThrowExactly().WithMessage("Failure requested from attributes."); + } + + [Fact] + public void Throws_for_request_body_with_extension_in_relationships_when_extension_enabled() + { + // Arrange + TestContext testContext = TestContext.WithExtension; + + const string requestJson = """ + { + "type": "derivedTypes", + "relationships": { + "type-info:fail": true, + "parent": { + "data": { + "type": "baseTypes", + "id": "1" + } + } + } + } + """; + + // Act + Action action = () => + { + var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(requestJson)); + _ = testContext.Converter.Read(ref reader, typeof(ResourceObject), testContext.SerializerReadOptions); + }; + + // Assert + action.Should().ThrowExactly().WithMessage("Failure requested from relationships."); + } + + [Fact] + public void Hides_extension_in_response_body_when_extension_not_enabled() + { + // Arrange + TestContext testContext = TestContext.WithoutExtension; + + var resourceObject = new ResourceObject + { + Type = "derivedTypes", + Id = "1", + Attributes = new Dictionary + { + ["baseValue"] = "baseAttribute", + ["derivedValue"] = "derivedAttribute" + }, + Relationships = new Dictionary + { + ["parent"] = new() + { + Data = new SingleOrManyData(new ResourceIdentifierObject + { + Type = "baseTypes", + Id = "1" + }) + } + } + }; + + using var stream = new MemoryStream(); + + using (var writer = new Utf8JsonWriter(stream, WriterOptions)) + { + // Act + testContext.Converter.Write(writer, resourceObject, testContext.SerializerWriteOptions); + } + + // Assert + string responseJson = Encoding.UTF8.GetString(stream.ToArray()); + + responseJson.Should().BeJson(""" + { + "type": "derivedTypes", + "id": "1", + "attributes": { + "baseValue": "baseAttribute", + "derivedValue": "derivedAttribute" + }, + "relationships": { + "parent": { + "data": { + "type": "baseTypes", + "id": "1" + } + } + } + } + """); + } + + [Fact] + public void Hides_extension_in_response_body_when_extension_enabled_with_base_type() + { + // Arrange + TestContext testContext = TestContext.WithExtension; + + var resourceObject = new ResourceObject + { + Type = "baseTypes", + Id = "1", + Attributes = new Dictionary + { + ["baseValue"] = "baseAttribute" + }, + Relationships = new Dictionary + { + ["parent"] = new() + { + Data = new SingleOrManyData(new ResourceIdentifierObject + { + Type = "baseTypes", + Id = "1" + }) + } + } + }; + + using var stream = new MemoryStream(); + + using (var writer = new Utf8JsonWriter(stream, WriterOptions)) + { + // Act + testContext.Converter.Write(writer, resourceObject, testContext.SerializerWriteOptions); + } + + // Assert + string responseJson = Encoding.UTF8.GetString(stream.ToArray()); + + responseJson.Should().BeJson(""" + { + "type": "baseTypes", + "id": "1", + "attributes": { + "baseValue": "baseAttribute" + }, + "relationships": { + "parent": { + "data": { + "type": "baseTypes", + "id": "1" + } + } + } + } + """); + } + + [Fact] + public void Writes_extension_in_response_body_when_extension_enabled_with_derived_type() + { + // Arrange + TestContext testContext = TestContext.WithExtension; + + var resourceObject = new ResourceObject + { + Type = "derivedTypes", + Id = "1", + Attributes = new Dictionary + { + ["baseValue"] = "baseAttribute", + ["derivedValue"] = "derivedAttribute" + }, + Relationships = new Dictionary + { + ["parent"] = new() + { + Data = new SingleOrManyData(new ResourceIdentifierObject + { + Type = "baseTypes", + Id = "1" + }) + } + } + }; + + using var stream = new MemoryStream(); + + using (var writer = new Utf8JsonWriter(stream, WriterOptions)) + { + // Act + testContext.Converter.Write(writer, resourceObject, testContext.SerializerWriteOptions); + } + + // Assert + string responseJson = Encoding.UTF8.GetString(stream.ToArray()); + + responseJson.Should().BeJson(""" + { + "type": "derivedTypes", + "id": "1", + "attributes": { + "type-info:baseType": "baseTypes", + "baseValue": "baseAttribute", + "derivedValue": "derivedAttribute" + }, + "relationships": { + "type-info:baseType": "baseTypes", + "parent": { + "data": { + "type": "baseTypes", + "id": "1" + } + } + } + } + """); + } + + private sealed class ExtensionAwareResourceObjectConverter : ResourceObjectConverter + { + private const string ExtensionNamespace = "type-info"; + + private readonly IResourceGraph _resourceGraph; + private readonly JsonApiRequestAccessor _requestAccessor; + + private bool IsTypeInfoExtensionEnabled => _requestAccessor.Request.Extensions.Contains(TypeInfoMediaTypeExtension); + + public ExtensionAwareResourceObjectConverter(IResourceGraph resourceGraph, JsonApiRequestAccessor requestAccessor) + : base(resourceGraph) + { + ArgumentGuard.NotNull(resourceGraph); + ArgumentGuard.NotNull(requestAccessor); + + _resourceGraph = resourceGraph; + _requestAccessor = requestAccessor; + } + + private protected override void ValidateExtensionInAttributes(string extensionNamespace, string extensionName, Utf8JsonReader reader) + { + if (extensionNamespace == ExtensionNamespace && IsTypeInfoExtensionEnabled && extensionName == "fail") + { + if (reader.GetBoolean()) + { + throw new JsonException("Failure requested from attributes."); + } + + return; + } + + base.ValidateExtensionInAttributes(extensionNamespace, extensionName, reader); + } + + private protected override void ValidateExtensionInRelationships(string extensionNamespace, string extensionName, Utf8JsonReader reader) + { + if (extensionNamespace == ExtensionNamespace && IsTypeInfoExtensionEnabled && extensionName == "fail") + { + if (reader.GetBoolean()) + { + throw new JsonException("Failure requested from relationships."); + } + + return; + } + + base.ValidateExtensionInRelationships(extensionNamespace, extensionName, reader); + } + + private protected override void WriteExtensionInAttributes(Utf8JsonWriter writer, ResourceObject value) + { + WriteBaseType(writer, value); + } + + private protected override void WriteExtensionInRelationships(Utf8JsonWriter writer, ResourceObject value) + { + WriteBaseType(writer, value); + } + + private void WriteBaseType(Utf8JsonWriter writer, ResourceObject value) + { + if (IsTypeInfoExtensionEnabled && value.Type != null) + { + ResourceType? resourceType = _resourceGraph.FindResourceType(value.Type); + + if (resourceType is { BaseType: not null }) + { + writer.WriteString($"{ExtensionNamespace}:baseType", resourceType.BaseType.PublicName); + } + } + } + } + + private sealed class JsonApiRequestAccessor + { + public IJsonApiRequest Request { get; } + + public JsonApiRequestAccessor(IJsonApiRequest request) + { + ArgumentGuard.NotNull(request); + + Request = request; + } + } + + private sealed class TestContext + { + public static TestContext WithExtension { get; } = new(true); + public static TestContext WithoutExtension { get; } = new(false); + + public ExtensionAwareResourceObjectConverter Converter { get; } + public JsonSerializerOptions SerializerReadOptions { get; } + public JsonSerializerOptions SerializerWriteOptions { get; } + + private TestContext(bool includeExtension) + { + var options = new JsonApiOptions(); + var request = new JsonApiRequest(); + + if (includeExtension) + { + request.Extensions = new HashSet([TypeInfoMediaTypeExtension]); + } + + // @formatter:wrap_chained_method_calls chop_always + // @formatter:wrap_before_first_method_call true + + IResourceGraph resourceGraph = new ResourceGraphBuilder(options, NullLoggerFactory.Instance) + .Add() + .Add() + .Build(); + + // @formatter:wrap_before_first_method_call restore + // @formatter:wrap_chained_method_calls restore + + var requestAccessor = new JsonApiRequestAccessor(request); + Converter = new ExtensionAwareResourceObjectConverter(resourceGraph, requestAccessor); + + options.SerializerOptions.Converters.Add(Converter); + SerializerReadOptions = ((IJsonApiOptions)options).SerializerReadOptions; + SerializerWriteOptions = ((IJsonApiOptions)options).SerializerWriteOptions; + } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private class BaseType : Identifiable + { + [Attr] + public string? BaseValue { get; set; } + + [HasOne] + public BaseType? Parent { get; set; } + } + + [UsedImplicitly(ImplicitUseTargetFlags.Members)] + private sealed class DerivedType : BaseType + { + [Attr] + public string? DerivedValue { get; set; } + } +}