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; }
+ }
+}