Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### JsonSubTypes.Text.Json
#### Changed
- Replaced the global `JsonSubTypesTypeResolution.AddAssembly` registry with a declarative `[KnownSubTypeOtherAssembly("AssemblyName")]` attribute on the base type. Resolution is now per-type instead of process-wide, so it no longer leaks across serialization profiles. The attribute takes an assembly name, keeping the base type free of a compile-time reference to the plugin.
- Renamed `FallBackSubTypeAttribute` to `FallbackSubTypeAttribute` and `FallBackToNearestAncestor()` to `FallbackToNearestAncestor()` for consistent capitalization. The `FallBack*` names still work in `JsonSubTypes` (Newtonsoft), which keeps its historical API.

### JsonSubTypes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\JsonSubTypes.Text.Json.Tests.Shared\JsonSubTypes.Text.Json.Tests.Shared.csproj" />
<ProjectReference Include="..\JsonSubTypes.Text.Json\JsonSubTypes.Text.Json.csproj" />
</ItemGroup>
</Project>
2 changes: 2 additions & 0 deletions JsonSubTypes.Text.Json.Tests.Plugin/PluginDog.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
using JsonSubTypes.Text.Json;
using JsonSubTypes.Text.Json.Tests.Shared;

namespace JsonSubTypes.Text.Json.Tests.Plugin
{
[KnownSubTypeOf(typeof(SharedAnimal), "Dog")]
public class PluginDog : SharedAnimal
{
public bool CanBark { get; set; }
Expand Down
41 changes: 41 additions & 0 deletions JsonSubTypes.Text.Json.Tests.Plugin/SelfDeclaredDog.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using JsonSubTypes.Text.Json;
using JsonSubTypes.Text.Json.Tests.Shared;

namespace JsonSubTypes.Text.Json.Tests.Plugin
{
// A subtype in a separate assembly that declares itself as a subtype of SelfDeclaredBase
// through [KnownSubTypeOf]. The host registers the plugin assembly at runtime; the base type
// knows nothing about this type or its assembly.
[KnownSubTypeOf(typeof(SelfDeclaredBase), "Dog")]
public class SelfDeclaredDog : SelfDeclaredBase
{
public bool CanBark { get; set; }
}

// Self-declared without a discriminator value: resolved by type name in the registered
// assembly rather than by a discriminator value. Lives in an assembly with no value-mapped
// subtypes, so the name-based path stays active.
[KnownSubTypeOf(typeof(SelfDeclaredCatBase))]
public class SelfDeclaredCat : SelfDeclaredCatBase
{
public bool Purrs { get; set; }
}

public class SelfDeclaredCatBase
{
public string? Kind { get; set; }
}

// Self-declared by property presence: identified by the presence of "JobTitle" in the JSON,
// in an assembly the host registers at runtime. The base type knows nothing about it.
[KnownSubTypeWithPropertyOf(typeof(SelfDeclaredEmployeeBase), "JobTitle")]
public class SelfDeclaredEmployee : SelfDeclaredEmployeeBase
{
public string? JobTitle { get; set; }
}

public class SelfDeclaredEmployeeBase
{
public string? FirstName { get; set; }
}
}
10 changes: 10 additions & 0 deletions JsonSubTypes.Text.Json.Tests.Shared/SelfDeclaredBase.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace JsonSubTypes.Text.Json.Tests.Shared
{
// A base type without a [JsonSubTypeConverter] attribute or a KnownSubTypeOtherAssembly:
// used to verify the self-declaring plugin pattern, where the subtype registers itself and
// the host registers the plugin assembly at runtime.
public class SelfDeclaredBase
{
public string? Kind { get; set; }
}
}
1 change: 1 addition & 0 deletions JsonSubTypes.Text.Json.Tests.Shared/SharedAnimal.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
namespace JsonSubTypes.Text.Json.Tests.Shared
{
[JsonSubTypeConverter(typeof(JsonSubtypes<SharedAnimal>), "Kind")]
[KnownSubTypeOtherAssembly("JsonSubTypes.Text.Json.Tests.Plugin")]
public class SharedAnimal
{
public string? Kind { get; set; }
Expand Down
112 changes: 88 additions & 24 deletions JsonSubTypes.Text.Json.Tests/ReviewBugTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -208,37 +208,101 @@ public void NameBasedResolutionRejectsNonSubtypes()
[Test]
public void CrossAssemblyResolvedWhenAssemblyRegistered()
{
JsonSubTypesTypeResolution.ClearAssemblies();
JsonSubTypesTypeResolution.AddAssembly(typeof(PluginDog).Assembly);
try
{
var dog = JsonSerializer.Deserialize<SharedAnimal>(
$"{{\"Kind\":\"{typeof(PluginDog).FullName}\",\"CanBark\":true}}");
var dog = JsonSerializer.Deserialize<SharedAnimal>(
$"{{\"Kind\":\"{typeof(PluginDog).FullName}\",\"CanBark\":true}}");

Assert.IsInstanceOf<PluginDog>(dog);
Assert.IsTrue((dog as PluginDog)?.CanBark == true);
}
finally
{
JsonSubTypesTypeResolution.ClearAssemblies();
}
Assert.IsInstanceOf<PluginDog>(dog);
Assert.IsTrue((dog as PluginDog)?.CanBark == true);
}

[Test]
public void CrossAssemblyNotResolvedByDefault()
{
JsonSubTypesTypeResolution.ClearAssemblies();
try
{
var animal = JsonSerializer.Deserialize<SharedAnimal>(
$"{{\"Kind\":\"{typeof(PluginDog).FullName}\",\"CanBark\":true}}");
var animal = JsonSerializer.Deserialize<OtherBase>(
$"{{\"Kind\":\"{typeof(PluginDog).FullName}\",\"CanBark\":true}}");

Assert.IsInstanceOf<SharedAnimal>(animal);
}
finally
{
JsonSubTypesTypeResolution.ClearAssemblies();
}
Assert.IsInstanceOf<OtherBase>(animal);
}

// A base type without the attribute: name-based resolution stays in its own assembly.
[JsonSubTypeConverter(typeof(JsonSubtypes<OtherBase>), "Kind")]
public class OtherBase
{
public string Kind { get; set; }
}

[Test]
public void SelfDeclaredSubtypeResolvedViaRegisteredAssembly()
{
// SelfDeclaredDog declares itself through [KnownSubTypeOf(typeof(SelfDeclaredBase), "Dog")]
// in the plugin assembly. The host registers that assembly at runtime; the base type
// knows nothing about the subtype or its assembly. The scan picks up the mapping.
var options = new JsonSerializerOptions();
options.Converters.Add(JsonSubtypesConverterBuilder
.Of<SelfDeclaredBase>("Kind")
.RegisterSubtypeAssembly(typeof(SelfDeclaredDog).Assembly)
.Build());

var dog = JsonSerializer.Deserialize<SelfDeclaredBase>("{\"Kind\":\"Dog\",\"CanBark\":true}", options);

Assert.IsInstanceOf<SelfDeclaredDog>(dog);
Assert.IsTrue((dog as SelfDeclaredDog)?.CanBark == true);
}

[Test]
public void SelfDeclaredSubtypeResolvedByNameInRegisteredAssembly()
{
// SelfDeclaredCat declares itself without a value, so it is resolved by type name in
// the registered plugin assembly rather than by a discriminator value.
var options = new JsonSerializerOptions();
options.Converters.Add(JsonSubtypesConverterBuilder
.Of<SelfDeclaredCatBase>("Kind")
.RegisterSubtypeAssembly(typeof(SelfDeclaredCat).Assembly)
.Build());

var cat = JsonSerializer.Deserialize<SelfDeclaredCatBase>(
$"{{\"Kind\":\"{typeof(SelfDeclaredCat).FullName}\",\"Purrs\":true}}", options);

Assert.IsInstanceOf<SelfDeclaredCat>(cat);
Assert.IsTrue((cat as SelfDeclaredCat)?.Purrs == true);
}

[Test]
public void DynamicSubtypeRegisteredAtRuntime()
{
// The runtime hook: register a subtype after the converter is built, without editing
// the plugin or scanning an assembly. Mirrors the generator's RegisterDynamicSubtype.
var converter = (JsonSubtypes<SelfDeclaredBase>)JsonSubtypesConverterBuilder
.Of<SelfDeclaredBase>("Kind")
.Build();
converter.RegisterDynamicSubtype("dog", typeof(SelfDeclaredDog));

var options = new JsonSerializerOptions();
options.Converters.Add(converter);

var dog = JsonSerializer.Deserialize<SelfDeclaredBase>("{\"Kind\":\"dog\",\"CanBark\":true}", options);

Assert.IsInstanceOf<SelfDeclaredDog>(dog);
Assert.IsTrue((dog as SelfDeclaredDog)?.CanBark == true);
}

[Test]
public void SelfDeclaredSubtypeByPropertyPresence()
{
// SelfDeclaredEmployee declares itself through
// [KnownSubTypeWithPropertyOf(typeof(SelfDeclaredEmployeeBase), "JobTitle")]. The host
// registers the plugin assembly; the scan maps the property presence.
var options = new JsonSerializerOptions();
options.Converters.Add(JsonSubtypesWithPropertyConverterBuilder
.Of<SelfDeclaredEmployeeBase>()
.RegisterSubtypeAssembly(typeof(SelfDeclaredEmployee).Assembly)
.Build());

var employee = JsonSerializer.Deserialize<SelfDeclaredEmployeeBase>(
"{\"FirstName\":\"A\",\"JobTitle\":\"Dev\"}", options);

Assert.IsInstanceOf<SelfDeclaredEmployee>(employee);
Assert.AreEqual("Dev", (employee as SelfDeclaredEmployee)?.JobTitle);
}
}
}
40 changes: 0 additions & 40 deletions JsonSubTypes.Text.Json/JsonSubTypesTypeResolution.cs

This file was deleted.

39 changes: 34 additions & 5 deletions JsonSubTypes.Text.Json/JsonSubtypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ internal interface IJsonSubtypes
/// Name-based resolution (used only when no <see cref="KnownSubTypeAttribute"/> mapping is
/// declared) instantiates the type whose name matches the discriminator, provided it is
/// assignable from the polymorphic base type and lives in the base type's assembly or in an
/// assembly registered via <see cref="JsonSubTypesTypeResolution"/>. Any such type present in
/// assembly registered via <see cref="KnownSubTypeOtherAssembly"/>. Any such type present in
/// those assemblies can be instantiated with attacker-controlled JSON.
/// </para>
/// <para>
Expand Down Expand Up @@ -147,30 +147,35 @@ private static readonly ConditionalWeakTable<JsonSerializerOptions, IJsonSubtype
private readonly bool _serializeDiscriminatorProperty;
private readonly bool _addDiscriminatorFirst;
private readonly Dictionary<Type, object?>? _runtimeTypeToDiscriminator;
private readonly Assembly[] _additionalAssemblies;

public JsonSubtypes()
{
_additionalAssemblies = [];
}

public JsonSubtypes(string? jsonDiscriminatorPropertyName)
{
JsonDiscriminatorPropertyName = jsonDiscriminatorPropertyName;
_serializeDiscriminatorProperty = jsonDiscriminatorPropertyName != null;
_addDiscriminatorFirst = true;
_additionalAssemblies = [];
}

internal JsonSubtypes(string? jsonDiscriminatorPropertyName,
NullableDictionary<object, Type>? subTypeMapping,
List<TypeWithPropertyMatchingAttributes>? typesByPropertyPresence,
Type? fallbackType,
bool serializeDiscriminatorProperty,
bool addDiscriminatorFirst) : this(jsonDiscriminatorPropertyName)
bool addDiscriminatorFirst,
Assembly[] additionalAssemblies) : this(jsonDiscriminatorPropertyName)
{
_subTypeMapping = subTypeMapping;
_typesByPropertyPresence = typesByPropertyPresence;
_fallbackType = fallbackType;
_serializeDiscriminatorProperty = serializeDiscriminatorProperty;
_addDiscriminatorFirst = addDiscriminatorFirst;
_additionalAssemblies = additionalAssemblies;
if (subTypeMapping != null)
{
_runtimeTypeToDiscriminator = new Dictionary<Type, object?>();
Expand All @@ -186,6 +191,27 @@ public override bool CanConvert(Type objectType)
return objectType == typeof(T);
}

/// <summary>
/// Registers a subtype at runtime, after the converter is built. This is the runtime hook for
/// hierarchies whose subtypes are only known at runtime (plugins, loaded assemblies): it maps
/// <paramref name="discriminator"/> to <paramref name="type"/> without editing the plugin or
/// scanning an assembly. The last registration for a discriminator wins, like the builder.
/// </summary>
public void RegisterDynamicSubtype(object discriminator, Type type)
{
if (_subTypeMapping == null)
{
throw new InvalidOperationException(
"RegisterDynamicSubtype requires a builder-built converter. Build one with JsonSubtypesConverterBuilder.Of(...).Build() first.");
}

_subTypeMapping.Set(discriminator, type);
if (_runtimeTypeToDiscriminator != null)
{
_runtimeTypeToDiscriminator[type] = discriminator;
}
}

public override T? Read(ref Utf8JsonReader reader, Type objectType, JsonSerializerOptions serializer)
{
return ReadJson(ref reader, objectType, serializer);
Expand Down Expand Up @@ -733,7 +759,7 @@ .. GetAttributes<KnownSubTypeWithPropertyAttribute>(parentType.GetTypeInfo())
JsonValueKind.String => discriminatorValue.GetString(),
_ => discriminatorValue.ToString()
};
return GetTypeByName(discriminatorStringValue, parentType.GetTypeInfo());
return GetTypeByName(discriminatorStringValue, parentType.GetTypeInfo(), _additionalAssemblies);
}

private static bool TryGetValueInJson(JsonElement root, string propertyName,
Expand Down Expand Up @@ -801,7 +827,7 @@ private static bool TryGetProperty(JsonElement obj, string name, JsonSerializerO
return false;
}

private static Type? GetTypeByName(string? typeName, TypeInfo parentType)
private static Type? GetTypeByName(string? typeName, TypeInfo parentType, Assembly[] instanceAssemblies)
{
if (typeName == null)
{
Expand All @@ -813,7 +839,10 @@ private static bool TryGetProperty(JsonElement obj, string name, JsonSerializerO
? null
: parentTypeFullName.Substring(0, parentTypeFullName.Length - parentType.Name.Length);

foreach (Assembly assembly in JsonSubTypesTypeResolution.GetSearchAssemblies(parentType.Assembly))
Assembly[] attributeAssemblies = TypeResolution.GetSearchAssemblies(parentType);
IEnumerable<Assembly> assemblies = attributeAssemblies
.Concat(instanceAssemblies.Where(a => !attributeAssemblies.Contains(a)));
foreach (Assembly assembly in assemblies)
{
Type? typeByName = assembly.GetType(typeName);
if (typeByName == null && searchLocation != null)
Expand Down
Loading