From 39061cda79cb8f960b74bdfb278d1061fb1db429 Mon Sep 17 00:00:00 2001 From: manuc66 Date: Wed, 12 Aug 2026 00:13:33 +0200 Subject: [PATCH 1/4] Add OnUnresolvedSubtype callback to detect unregistered subtypes Fires once per JSON element whose subtype cannot be resolved (unknown or missing discriminator value, or no matching property in property-presence mode), on both the Newtonsoft.Json and System.Text.Json backends. The callback receives the parent type, discriminator name/value, whether the discriminator was present, and the fallback subtype that will be used. Extends the existing fallback point in ResolveType/GetType so no behavior changes when no callback is registered. #112 --- JsonSubTypes.Text.Json/JsonSubtypes.cs | 42 +++++++++++++++- .../JsonSubtypesConverterBuilder.cs | 17 ++++++- ...sonSubtypesWithPropertyConverterBuilder.cs | 17 ++++++- .../UnresolvedSubtypeInfo.cs | 50 +++++++++++++++++++ JsonSubTypes/JsonSubtypes.cs | 36 ++++++++++++- ...onSubtypesByDiscriminatorValueConverter.cs | 12 ++++- ...JsonSubtypesByPropertyPresenceConverter.cs | 2 +- JsonSubTypes/JsonSubtypesConverter.cs | 14 +++++- JsonSubTypes/JsonSubtypesConverterBuilder.cs | 14 +++++- ...sonSubtypesWithPropertyConverterBuilder.cs | 14 +++++- JsonSubTypes/UnresolvedSubtypeInfo.cs | 50 +++++++++++++++++++ 11 files changed, 254 insertions(+), 14 deletions(-) create mode 100644 JsonSubTypes.Text.Json/UnresolvedSubtypeInfo.cs create mode 100644 JsonSubTypes/UnresolvedSubtypeInfo.cs diff --git a/JsonSubTypes.Text.Json/JsonSubtypes.cs b/JsonSubTypes.Text.Json/JsonSubtypes.cs index db13c41..04ef7a9 100644 --- a/JsonSubTypes.Text.Json/JsonSubtypes.cs +++ b/JsonSubTypes.Text.Json/JsonSubtypes.cs @@ -111,6 +111,7 @@ private static readonly ConcurrentDictionary? _runtimeTypeToDiscriminator; + private readonly Action? _onUnresolvedSubtype; public JsonSubtypes() { @@ -128,13 +129,15 @@ internal JsonSubtypes(string? jsonDiscriminatorPropertyName, List? typesByPropertyPresence, Type? fallbackType, bool serializeDiscriminatorProperty, - bool addDiscriminatorFirst) : this(jsonDiscriminatorPropertyName) + bool addDiscriminatorFirst, + Action? onUnresolvedSubtype) : this(jsonDiscriminatorPropertyName) { _subTypeMapping = subTypeMapping; _typesByPropertyPresence = typesByPropertyPresence; _fallbackType = fallbackType; _serializeDiscriminatorProperty = serializeDiscriminatorProperty; _addDiscriminatorFirst = addDiscriminatorFirst; + _onUnresolvedSubtype = onUnresolvedSubtype; if (subTypeMapping != null) { _runtimeTypeToDiscriminator = new Dictionary(); @@ -537,7 +540,42 @@ Type IJsonSubtypes.GetType(JsonDocument jObject, Type parentType, JsonSerializer resolvedType = GetTypeFromDiscriminatorValue(jObject, parentType, jsonSerializerOptions); } - return resolvedType ?? GetFallbackSubType(parentType) ?? parentType; + if (resolvedType != null) + { + return resolvedType; + } + + Type? fallbackSubtype = GetFallbackSubType(parentType); + NotifyUnresolvedSubtype(jObject, parentType, jsonSerializerOptions, fallbackSubtype); + return fallbackSubtype ?? parentType; + } + + private void NotifyUnresolvedSubtype(JsonDocument jObject, Type parentType, + JsonSerializerOptions jsonSerializerOptions, Type? fallbackSubtype) + { + Action? onUnresolvedSubtype = _onUnresolvedSubtype; + if (onUnresolvedSubtype == null) + { + return; + } + + object? discriminatorValue = null; + bool hasDiscriminator = false; + if (JsonDiscriminatorPropertyName != null && + TryGetValueInJson(jObject.RootElement, JsonDiscriminatorPropertyName, jsonSerializerOptions, + out JsonElement discriminatorElement)) + { + hasDiscriminator = true; + discriminatorValue = discriminatorElement.ValueKind switch + { + JsonValueKind.Null => null, + JsonValueKind.String => discriminatorElement.GetString(), + _ => discriminatorElement.ToString() + }; + } + + onUnresolvedSubtype(new UnresolvedSubtypeInfo(parentType, JsonDiscriminatorPropertyName, + discriminatorValue, hasDiscriminator, fallbackSubtype)); } private Type GetType(JsonDocument jObject, Type parentType, JsonSerializerOptions serializer) diff --git a/JsonSubTypes.Text.Json/JsonSubtypesConverterBuilder.cs b/JsonSubTypes.Text.Json/JsonSubtypesConverterBuilder.cs index bf2f4c7..e6ccb00 100644 --- a/JsonSubTypes.Text.Json/JsonSubtypesConverterBuilder.cs +++ b/JsonSubTypes.Text.Json/JsonSubtypesConverterBuilder.cs @@ -17,6 +17,7 @@ public class JsonSubtypesConverterBuilder private Type? _fallbackType; private bool _serializeDiscriminatorProperty; private bool _addDiscriminatorFirst; + private Action? _onUnresolvedSubtype; private JsonSubtypesConverterBuilder(Type baseType, string discriminatorProperty) { @@ -56,6 +57,17 @@ public JsonSubtypesConverterBuilder SetFallbackSubtype() return SetFallbackSubtype(typeof(T)); } + /// + /// Registers a callback invoked when a JSON object's subtype cannot be resolved + /// (unknown or missing discriminator value). The callback is invoked on the thread + /// performing the deserialization, once per unresolved element. + /// + public JsonSubtypesConverterBuilder OnUnresolvedSubtype(Action onUnresolvedSubtype) + { + _onUnresolvedSubtype = onUnresolvedSubtype; + return this; + } + public JsonSubtypesConverterBuilder SerializeDiscriminatorProperty() { return SerializeDiscriminatorProperty(true); @@ -95,13 +107,14 @@ public JsonConverter Build() typeof(List), typeof(Type), typeof(bool), - typeof(bool) + typeof(bool), + typeof(Action) }, null)!; return (JsonConverter)constructor.Invoke( new object?[] { _discriminatorProperty, _subTypeMapping, null, _fallbackType, - _serializeDiscriminatorProperty, _addDiscriminatorFirst + _serializeDiscriminatorProperty, _addDiscriminatorFirst, _onUnresolvedSubtype })!; } } diff --git a/JsonSubTypes.Text.Json/JsonSubtypesWithPropertyConverterBuilder.cs b/JsonSubTypes.Text.Json/JsonSubtypesWithPropertyConverterBuilder.cs index 43df373..137decf 100644 --- a/JsonSubTypes.Text.Json/JsonSubtypesWithPropertyConverterBuilder.cs +++ b/JsonSubTypes.Text.Json/JsonSubtypesWithPropertyConverterBuilder.cs @@ -16,6 +16,7 @@ public class JsonSubtypesWithPropertyConverterBuilder private readonly Dictionary _types = new Dictionary(); private Type? _fallbackType; + private Action? _onUnresolvedSubtype; private JsonSubtypesWithPropertyConverterBuilder(Type baseType) { @@ -60,6 +61,17 @@ public JsonSubtypesWithPropertyConverterBuilder SetFallbackSubtype() return SetFallbackSubtype(typeof(T)); } + /// + /// Registers a callback invoked when a JSON object's subtype cannot be resolved + /// (no registered property matched the JSON object). The callback is invoked on + /// the thread performing the deserialization, once per unresolved element. + /// + public JsonSubtypesWithPropertyConverterBuilder OnUnresolvedSubtype(Action onUnresolvedSubtype) + { + _onUnresolvedSubtype = onUnresolvedSubtype; + return this; + } + [RequiresUnreferencedCode("JsonSubTypes.Text.Json uses reflection to create the subtype converter.")] [RequiresDynamicCode("JsonSubTypes.Text.Json uses reflection to create the subtype converter.")] public JsonConverter Build() @@ -74,10 +86,11 @@ public JsonConverter Build() typeof(List), typeof(Type), typeof(bool), - typeof(bool) + typeof(bool), + typeof(Action) }, null)!; return (JsonConverter)constructor.Invoke( - new object?[] { null, null, _types.Values.ToList(), _fallbackType, false, false })!; + new object?[] { null, null, _types.Values.ToList(), _fallbackType, false, false, _onUnresolvedSubtype })!; } } } diff --git a/JsonSubTypes.Text.Json/UnresolvedSubtypeInfo.cs b/JsonSubTypes.Text.Json/UnresolvedSubtypeInfo.cs new file mode 100644 index 0000000..88d6d6b --- /dev/null +++ b/JsonSubTypes.Text.Json/UnresolvedSubtypeInfo.cs @@ -0,0 +1,50 @@ +using System; + +namespace JsonSubTypes.Text.Json +{ + /// + /// Describes a JSON object whose subtype could not be resolved by the converter + /// (unknown or missing discriminator value, or no matching property in + /// property-presence mode). + /// + public class UnresolvedSubtypeInfo + { + /// Gets the type for which the subtype was resolved. + public Type ParentType { get; } + + /// + /// Gets the name of the discriminator property, or null when the converter + /// resolves by property presence. + /// + public string? DiscriminatorPropertyName { get; } + + /// + /// Gets the discriminator value read from the JSON object, or null when the + /// discriminator property is missing or holds a JSON null. + /// + public object? DiscriminatorValue { get; } + + /// + /// Gets a value indicating whether the discriminator property was present in the + /// JSON object. Use this to distinguish a missing discriminator from an unknown + /// value ( is null in both cases). + /// + public bool HasDiscriminator { get; } + + /// + /// Gets the fallback subtype that will be used for this JSON object, or null + /// when no fallback subtype is configured. + /// + public Type? FallbackSubtype { get; } + + public UnresolvedSubtypeInfo(Type parentType, string? discriminatorPropertyName, object? discriminatorValue, + bool hasDiscriminator, Type? fallbackSubtype) + { + ParentType = parentType; + DiscriminatorPropertyName = discriminatorPropertyName; + DiscriminatorValue = discriminatorValue; + HasDiscriminator = hasDiscriminator; + FallbackSubtype = fallbackSubtype; + } + } +} diff --git a/JsonSubTypes/JsonSubtypes.cs b/JsonSubTypes/JsonSubtypes.cs index 6f2aad2..e49ad32 100644 --- a/JsonSubTypes/JsonSubtypes.cs +++ b/JsonSubTypes/JsonSubtypes.cs @@ -80,6 +80,8 @@ public KnownSubTypeWithPropertyAttribute(Type subType, string propertyName) protected readonly string JsonDiscriminatorPropertyName; + internal Action OnUnresolvedSubtype { get; set; } + [ThreadStatic] private static bool _isInsideRead; [ThreadStatic] private static JsonReader _reader; @@ -250,7 +252,39 @@ private Type ResolveType(JObject jObject, Type parentType, JsonSerializer serial resolvedType = GetTypeFromDiscriminatorValue(jObject, parentType, serializer); } - return resolvedType ?? GetFallbackSubType(parentType); + if (resolvedType != null) + { + return resolvedType; + } + + Type fallbackSubtype = GetFallbackSubType(parentType); + NotifyUnresolvedSubtype(jObject, parentType, fallbackSubtype); + return fallbackSubtype; + } + + private void NotifyUnresolvedSubtype(JObject jObject, Type parentType, Type fallbackSubtype) + { + Action onUnresolvedSubtype = OnUnresolvedSubtype; + if (onUnresolvedSubtype == null) + { + return; + } + + object discriminatorValue = null; + bool hasDiscriminator = false; + if (JsonDiscriminatorPropertyName != null) + { + JToken discriminatorToken; + if (TryGetValueInJson(jObject, JsonDiscriminatorPropertyName, out discriminatorToken) || + (discriminatorToken = jObject.SelectToken(JsonDiscriminatorPropertyName)) != null) + { + hasDiscriminator = true; + discriminatorValue = discriminatorToken.ToObject(); + } + } + + onUnresolvedSubtype(new UnresolvedSubtypeInfo(parentType, JsonDiscriminatorPropertyName, + discriminatorValue, hasDiscriminator, fallbackSubtype)); } private Type GetType(JObject jObject, Type parentType, JsonSerializer serializer) diff --git a/JsonSubTypes/JsonSubtypesByDiscriminatorValueConverter.cs b/JsonSubTypes/JsonSubtypesByDiscriminatorValueConverter.cs index 9d72500..67ce8f3 100644 --- a/JsonSubTypes/JsonSubtypesByDiscriminatorValueConverter.cs +++ b/JsonSubTypes/JsonSubtypesByDiscriminatorValueConverter.cs @@ -39,8 +39,16 @@ public class JsonSubtypesByDiscriminatorValueConverter : JsonSubtypesConverter private readonly NullableDictionary _subTypeMapping; // this constructor is part of the public api since it's protected and this class is public - protected internal JsonSubtypesByDiscriminatorValueConverter(Type baseType, string discriminatorProperty, - NullableDictionary subTypeMapping, bool serializeDiscriminatorProperty, bool addDiscriminatorFirst, Type fallbackType) : base(baseType, discriminatorProperty, fallbackType) + protected internal JsonSubtypesByDiscriminatorValueConverter(Type baseType, string discriminatorProperty, + NullableDictionary subTypeMapping, bool serializeDiscriminatorProperty, bool addDiscriminatorFirst, Type fallbackType) + : this(baseType, discriminatorProperty, subTypeMapping, serializeDiscriminatorProperty, addDiscriminatorFirst, fallbackType, null) + { + } + + // this constructor is part of the public api since it's protected and this class is public + protected internal JsonSubtypesByDiscriminatorValueConverter(Type baseType, string discriminatorProperty, + NullableDictionary subTypeMapping, bool serializeDiscriminatorProperty, bool addDiscriminatorFirst, Type fallbackType, Action onUnresolvedSubtype) + : base(baseType, discriminatorProperty, fallbackType, onUnresolvedSubtype) { _serializeDiscriminatorProperty = serializeDiscriminatorProperty; _subTypeMapping = subTypeMapping; diff --git a/JsonSubTypes/JsonSubtypesByPropertyPresenceConverter.cs b/JsonSubTypes/JsonSubtypesByPropertyPresenceConverter.cs index 191a0fb..1477911 100644 --- a/JsonSubTypes/JsonSubtypesByPropertyPresenceConverter.cs +++ b/JsonSubTypes/JsonSubtypesByPropertyPresenceConverter.cs @@ -7,7 +7,7 @@ internal class JsonSubtypesByPropertyPresenceConverter : JsonSubtypesConverter { private readonly List _jsonPropertyName2Type; - internal JsonSubtypesByPropertyPresenceConverter(Type baseType, List jsonProperty2Type, Type fallbackType) : base(baseType, fallbackType) + internal JsonSubtypesByPropertyPresenceConverter(Type baseType, List jsonProperty2Type, Type fallbackType, Action onUnresolvedSubtype) : base(baseType, fallbackType, onUnresolvedSubtype) { _jsonPropertyName2Type = jsonProperty2Type; } diff --git a/JsonSubTypes/JsonSubtypesConverter.cs b/JsonSubTypes/JsonSubtypesConverter.cs index dfa2284..3a355b5 100644 --- a/JsonSubTypes/JsonSubtypesConverter.cs +++ b/JsonSubTypes/JsonSubtypesConverter.cs @@ -28,16 +28,26 @@ public class JsonSubtypesConverter : JsonSubtypes private readonly Type _baseType; private readonly Type _fallbackType; - internal JsonSubtypesConverter(Type baseType, Type fallbackType) : base() + internal JsonSubtypesConverter(Type baseType, Type fallbackType) : this(baseType, fallbackType, null) + { + } + + internal JsonSubtypesConverter(Type baseType, string jsonDiscriminatorPropertyName, Type fallbackType) : this(baseType, jsonDiscriminatorPropertyName, fallbackType, null) + { + } + + internal JsonSubtypesConverter(Type baseType, Type fallbackType, Action onUnresolvedSubtype) : base() { _baseType = baseType; _fallbackType = fallbackType; + OnUnresolvedSubtype = onUnresolvedSubtype; } - internal JsonSubtypesConverter(Type baseType, string jsonDiscriminatorPropertyName, Type fallbackType) : base(jsonDiscriminatorPropertyName) + internal JsonSubtypesConverter(Type baseType, string jsonDiscriminatorPropertyName, Type fallbackType, Action onUnresolvedSubtype) : base(jsonDiscriminatorPropertyName) { _baseType = baseType; _fallbackType = fallbackType; + OnUnresolvedSubtype = onUnresolvedSubtype; } internal override Type GetFallbackSubType(Type type) diff --git a/JsonSubTypes/JsonSubtypesConverterBuilder.cs b/JsonSubTypes/JsonSubtypesConverterBuilder.cs index c8f4ba6..c3d2f6a 100644 --- a/JsonSubTypes/JsonSubtypesConverterBuilder.cs +++ b/JsonSubTypes/JsonSubtypesConverterBuilder.cs @@ -33,6 +33,7 @@ public class JsonSubtypesConverterBuilder private bool _serializeDiscriminatorProperty; private bool _addDiscriminatorFirst = true; private Type _fallbackSubtype; + private Action _onUnresolvedSubtype; public static JsonSubtypesConverterBuilder Of(Type baseType, string discriminatorProperty) { @@ -83,9 +84,20 @@ public JsonSubtypesConverterBuilder SetFallbackSubtype() return SetFallbackSubtype(typeof(T)); } + /// + /// Registers a callback invoked when a JSON object's subtype cannot be resolved + /// (unknown or missing discriminator value). The callback is invoked on the thread + /// performing the deserialization, once per unresolved element. + /// + public JsonSubtypesConverterBuilder OnUnresolvedSubtype(Action onUnresolvedSubtype) + { + _onUnresolvedSubtype = onUnresolvedSubtype; + return this; + } + public JsonConverter Build() { - return new JsonSubtypesByDiscriminatorValueConverter(_baseType, _discriminatorProperty, _subTypeMapping, _serializeDiscriminatorProperty, _addDiscriminatorFirst, _fallbackSubtype); + return new JsonSubtypesByDiscriminatorValueConverter(_baseType, _discriminatorProperty, _subTypeMapping, _serializeDiscriminatorProperty, _addDiscriminatorFirst, _fallbackSubtype, _onUnresolvedSubtype); } } } diff --git a/JsonSubTypes/JsonSubtypesWithPropertyConverterBuilder.cs b/JsonSubTypes/JsonSubtypesWithPropertyConverterBuilder.cs index fdfd1cd..f6e3bd8 100644 --- a/JsonSubTypes/JsonSubtypesWithPropertyConverterBuilder.cs +++ b/JsonSubTypes/JsonSubtypesWithPropertyConverterBuilder.cs @@ -10,6 +10,7 @@ public class JsonSubtypesWithPropertyConverterBuilder private readonly Type _baseType; private readonly Dictionary _subTypeMapping = new Dictionary(); private Type _fallbackSubtype; + private Action _onUnresolvedSubtype; private JsonSubtypesWithPropertyConverterBuilder(Type baseType) { @@ -53,9 +54,20 @@ public JsonSubtypesWithPropertyConverterBuilder SetFallbackSubtype() return SetFallbackSubtype(typeof(T)); } + /// + /// Registers a callback invoked when a JSON object's subtype cannot be resolved + /// (no registered property matched the JSON object). The callback is invoked on + /// the thread performing the deserialization, once per unresolved element. + /// + public JsonSubtypesWithPropertyConverterBuilder OnUnresolvedSubtype(Action onUnresolvedSubtype) + { + _onUnresolvedSubtype = onUnresolvedSubtype; + return this; + } + public JsonConverter Build() { - return new JsonSubtypesByPropertyPresenceConverter(_baseType, _subTypeMapping.Values.ToList(), _fallbackSubtype); + return new JsonSubtypesByPropertyPresenceConverter(_baseType, _subTypeMapping.Values.ToList(), _fallbackSubtype, _onUnresolvedSubtype); } } } diff --git a/JsonSubTypes/UnresolvedSubtypeInfo.cs b/JsonSubTypes/UnresolvedSubtypeInfo.cs new file mode 100644 index 0000000..738bd60 --- /dev/null +++ b/JsonSubTypes/UnresolvedSubtypeInfo.cs @@ -0,0 +1,50 @@ +using System; + +namespace JsonSubTypes +{ + /// + /// Describes a JSON object whose subtype could not be resolved by the converter + /// (unknown or missing discriminator value, or no matching property in + /// property-presence mode). + /// + public class UnresolvedSubtypeInfo + { + /// Gets the type for which the subtype was resolved. + public Type ParentType { get; } + + /// + /// Gets the name of the discriminator property, or null when the converter + /// resolves by property presence. + /// + public string DiscriminatorPropertyName { get; } + + /// + /// Gets the discriminator value read from the JSON object, or null when the + /// discriminator property is missing or holds a JSON null. + /// + public object DiscriminatorValue { get; } + + /// + /// Gets a value indicating whether the discriminator property was present in the + /// JSON object. Use this to distinguish a missing discriminator from an unknown + /// value ( is null in both cases). + /// + public bool HasDiscriminator { get; } + + /// + /// Gets the fallback subtype that will be used for this JSON object, or null + /// when no fallback subtype is configured. + /// + public Type FallbackSubtype { get; } + + public UnresolvedSubtypeInfo(Type parentType, string discriminatorPropertyName, object discriminatorValue, + bool hasDiscriminator, Type fallbackSubtype) + { + ParentType = parentType; + DiscriminatorPropertyName = discriminatorPropertyName; + DiscriminatorValue = discriminatorValue; + HasDiscriminator = hasDiscriminator; + FallbackSubtype = fallbackSubtype; + } + } +} From 462233aecfaa1026918f56050e8e63006260ebe3 Mon Sep 17 00:00:00 2001 From: manuc66 Date: Wed, 12 Aug 2026 00:13:37 +0200 Subject: [PATCH 2/4] Add tests for OnUnresolvedSubtype on both backends Covers resolved subtype (no callback), unknown discriminator value, missing discriminator, no configured fallback, one callback per unresolved element in a nested tree, and property-presence mode. #112 --- .../UnresolvedSubtypeCallbackTests.cs | 186 ++++++++++++++++ .../UnresolvedSubtypeCallbackTests.cs | 198 ++++++++++++++++++ 2 files changed, 384 insertions(+) create mode 100644 JsonSubTypes.Tests/UnresolvedSubtypeCallbackTests.cs create mode 100644 JsonSubTypes.Text.Json.Tests/UnresolvedSubtypeCallbackTests.cs diff --git a/JsonSubTypes.Tests/UnresolvedSubtypeCallbackTests.cs b/JsonSubTypes.Tests/UnresolvedSubtypeCallbackTests.cs new file mode 100644 index 0000000..0ed95ee --- /dev/null +++ b/JsonSubTypes.Tests/UnresolvedSubtypeCallbackTests.cs @@ -0,0 +1,186 @@ +using System.Collections.Generic; +using Newtonsoft.Json; +using NUnit.Framework; + +namespace JsonSubTypes.Tests +{ + [TestFixture] + public class UnresolvedSubtypeCallbackTests + { + public abstract class Animal + { + public int Age { get; set; } + } + + public class Dog : Animal + { + public bool CanBark { get; set; } + } + + public class Cat : Animal + { + public int Lives { get; set; } + } + + public class UnknownAnimal : Animal + { + } + + public interface IStep + { + string Type { get; set; } + IList Steps { get; set; } + } + + public class RegisteredStep : IStep + { + public string Type { get; set; } + public IList Steps { get; set; } + } + + public class UnknownStepsTrap : IStep + { + public string Type { get; set; } + public IList Steps { get; set; } + } + + private static JsonSerializerSettings DiscriminatorSettings(IList notifications, + bool withFallback) + { + var builder = JsonSubtypesConverterBuilder + .Of(typeof(Animal), "type") + .RegisterSubtype(typeof(Cat), "Cat") + .RegisterSubtype(typeof(Dog), "Dog") + .OnUnresolvedSubtype(notifications.Add); + if (withFallback) + { + builder.SetFallbackSubtype(typeof(UnknownAnimal)); + } + + return new JsonSerializerSettings + { + Converters = { builder.Build() } + }; + } + + [Test] + public void CallbackNotInvokedWhenSubtypeIsResolved() + { + var notifications = new List(); + var settings = DiscriminatorSettings(notifications, true); + + var result = JsonConvert.DeserializeObject("{\"type\":\"Cat\",\"age\":3,\"lives\":7}", settings); + + Assert.IsInstanceOf(result); + Assert.AreEqual(0, notifications.Count); + } + + [Test] + public void CallbackInvokedForUnknownDiscriminatorValue() + { + var notifications = new List(); + var settings = DiscriminatorSettings(notifications, true); + + var result = JsonConvert.DeserializeObject("{\"type\":\"NonExistentType42\",\"age\":3}", settings); + + Assert.IsInstanceOf(result); + Assert.AreEqual(1, notifications.Count); + Assert.AreEqual(typeof(Animal), notifications[0].ParentType); + Assert.AreEqual("type", notifications[0].DiscriminatorPropertyName); + Assert.AreEqual("NonExistentType42", notifications[0].DiscriminatorValue); + Assert.IsTrue(notifications[0].HasDiscriminator); + Assert.AreEqual(typeof(UnknownAnimal), notifications[0].FallbackSubtype); + } + + [Test] + public void CallbackInvokedForMissingDiscriminator() + { + var notifications = new List(); + var settings = DiscriminatorSettings(notifications, true); + + var result = JsonConvert.DeserializeObject("{\"age\":3}", settings); + + Assert.IsInstanceOf(result); + Assert.AreEqual(1, notifications.Count); + Assert.AreEqual(typeof(Animal), notifications[0].ParentType); + Assert.IsNull(notifications[0].DiscriminatorValue); + Assert.IsFalse(notifications[0].HasDiscriminator); + } + + [Test] + public void CallbackInvokedEvenWithoutFallbackSubtype() + { + var notifications = new List(); + var settings = DiscriminatorSettings(notifications, false); + + Assert.Throws(() => + JsonConvert.DeserializeObject("{\"type\":\"NonExistentType42\"}", settings)); + + Assert.AreEqual(1, notifications.Count); + Assert.IsNull(notifications[0].FallbackSubtype); + Assert.AreEqual("NonExistentType42", notifications[0].DiscriminatorValue); + } + + [Test] + public void CallbackInvokedForEachUnresolvedElementInTree() + { + var notifications = new List(); + var settings = new JsonSerializerSettings + { + Converters = + { + JsonSubtypesConverterBuilder + .Of(typeof(IStep), "Type") + .SetFallbackSubtype(typeof(UnknownStepsTrap)) + .RegisterSubtype(typeof(RegisteredStep), "RegisteredStep") + .OnUnresolvedSubtype(notifications.Add) + .Build() + } + }; + + var json = "[" + + "{\"Type\":\"NonExistentType42\"}," + + "{\"Type\":\"RegisteredStep\"}," + + "{\"Type\":\"AnotherUnknownType\",\"Steps\":[{\"Type\":\"AlsoUnknown\"}]}" + + "]"; + + var result = JsonConvert.DeserializeObject>(json, settings); + + Assert.IsInstanceOf(result[0]); + Assert.IsInstanceOf(result[1]); + Assert.IsInstanceOf(result[2]); + Assert.IsInstanceOf(result[2].Steps[0]); + Assert.AreEqual(3, notifications.Count); + Assert.AreEqual(new[] { "NonExistentType42", "AnotherUnknownType", "AlsoUnknown" }, + notifications.ConvertAll(n => n.DiscriminatorValue)); + } + + [Test] + public void CallbackInvokedInPropertyPresenceMode() + { + var notifications = new List(); + var settings = new JsonSerializerSettings + { + Converters = + { + JsonSubtypesWithPropertyConverterBuilder + .Of(typeof(Animal)) + .RegisterSubtypeWithProperty(typeof(Cat), "catLives") + .RegisterSubtypeWithProperty(typeof(Dog), "canBark") + .SetFallbackSubtype(typeof(UnknownAnimal)) + .OnUnresolvedSubtype(notifications.Add) + .Build() + } + }; + + var result = JsonConvert.DeserializeObject("{\"age\":3}", settings); + + Assert.IsInstanceOf(result); + Assert.AreEqual(1, notifications.Count); + Assert.AreEqual(typeof(Animal), notifications[0].ParentType); + Assert.IsNull(notifications[0].DiscriminatorPropertyName); + Assert.IsFalse(notifications[0].HasDiscriminator); + Assert.AreEqual(typeof(UnknownAnimal), notifications[0].FallbackSubtype); + } + } +} diff --git a/JsonSubTypes.Text.Json.Tests/UnresolvedSubtypeCallbackTests.cs b/JsonSubTypes.Text.Json.Tests/UnresolvedSubtypeCallbackTests.cs new file mode 100644 index 0000000..97a9786 --- /dev/null +++ b/JsonSubTypes.Text.Json.Tests/UnresolvedSubtypeCallbackTests.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; +using JsonSubTypes.Text.Json; +using NUnit.Framework; + +namespace JsonSubTypes.Tests +{ + [TestFixture] + public class UnresolvedSubtypeCallbackTests + { + public abstract class Animal + { + [JsonPropertyName("age")] + public int Age { get; set; } + } + + public class Dog : Animal + { + public bool CanBark { get; set; } + } + + public class Cat : Animal + { + public int Lives { get; set; } + } + + public class UnknownAnimal : Animal + { + } + + public interface IStep + { + [JsonPropertyName("Type")] + string Type { get; set; } + + List Steps { get; set; } + } + + public class RegisteredStep : IStep + { + [JsonPropertyName("Type")] + public string Type { get; set; } + + public List Steps { get; set; } + } + + public class UnknownStepsTrap : IStep + { + [JsonPropertyName("Type")] + public string Type { get; set; } + + public List Steps { get; set; } + } + + private static JsonSerializerOptions DiscriminatorOptions(IList notifications, + bool withFallback) + { + var builder = JsonSubtypesConverterBuilder + .Of(typeof(Animal), "type") + .RegisterSubtype(typeof(Cat), "Cat") + .RegisterSubtype(typeof(Dog), "Dog") + .OnUnresolvedSubtype(notifications.Add); + if (withFallback) + { + builder.SetFallbackSubtype(typeof(UnknownAnimal)); + } + + return new JsonSerializerOptions + { + Converters = { builder.Build() } + }; + } + + [Test] + public void CallbackNotInvokedWhenSubtypeIsResolved() + { + var notifications = new List(); + var options = DiscriminatorOptions(notifications, true); + + var result = JsonSerializer.Deserialize("{\"type\":\"Cat\",\"age\":3,\"lives\":7}", options); + + Assert.AreEqual(typeof(Cat), result?.GetType()); + Assert.AreEqual(0, notifications.Count); + } + + [Test] + public void CallbackInvokedForUnknownDiscriminatorValue() + { + var notifications = new List(); + var options = DiscriminatorOptions(notifications, true); + + var result = JsonSerializer.Deserialize("{\"type\":\"NonExistentType42\",\"age\":3}", options); + + Assert.AreEqual(typeof(UnknownAnimal), result?.GetType()); + Assert.AreEqual(1, notifications.Count); + Assert.AreEqual(typeof(Animal), notifications[0].ParentType); + Assert.AreEqual("type", notifications[0].DiscriminatorPropertyName); + Assert.AreEqual("NonExistentType42", notifications[0].DiscriminatorValue); + Assert.IsTrue(notifications[0].HasDiscriminator); + Assert.AreEqual(typeof(UnknownAnimal), notifications[0].FallbackSubtype); + } + + [Test] + public void CallbackInvokedForMissingDiscriminator() + { + var notifications = new List(); + var options = DiscriminatorOptions(notifications, true); + + var result = JsonSerializer.Deserialize("{\"age\":3}", options); + + Assert.AreEqual(typeof(UnknownAnimal), result?.GetType()); + Assert.AreEqual(1, notifications.Count); + Assert.AreEqual(typeof(Animal), notifications[0].ParentType); + Assert.IsNull(notifications[0].DiscriminatorValue); + Assert.IsFalse(notifications[0].HasDiscriminator); + } + + [Test] + public void CallbackInvokedEvenWithoutFallbackSubtype() + { + var notifications = new List(); + var options = DiscriminatorOptions(notifications, false); + + Assert.Throws(() => + JsonSerializer.Deserialize("{\"type\":\"NonExistentType42\"}", options)); + + Assert.AreEqual(1, notifications.Count); + Assert.IsNull(notifications[0].FallbackSubtype); + Assert.AreEqual("NonExistentType42", notifications[0].DiscriminatorValue); + } + + [Test] + public void CallbackInvokedForEachUnresolvedElementInTree() + { + var notifications = new List(); + var options = new JsonSerializerOptions + { + Converters = + { + JsonSubtypesConverterBuilder + .Of(typeof(IStep), "Type") + .SetFallbackSubtype(typeof(UnknownStepsTrap)) + .RegisterSubtype(typeof(RegisteredStep), "RegisteredStep") + .OnUnresolvedSubtype(notifications.Add) + .Build() + } + }; + + var json = "[" + + "{\"Type\":\"NonExistentType42\"}," + + "{\"Type\":\"RegisteredStep\"}," + + "{\"Type\":\"AnotherUnknownType\",\"Steps\":[{\"Type\":\"AlsoUnknown\"}]}" + + "]"; + + var result = JsonSerializer.Deserialize>(json, options); + + Assert.AreEqual(3, result.Count); + Assert.IsInstanceOf(result[0]); + Assert.IsInstanceOf(result[1]); + Assert.IsInstanceOf(result[2]); + Assert.IsInstanceOf(result[2].Steps[0]); + Assert.AreEqual(3, notifications.Count); + Assert.AreEqual("NonExistentType42", notifications[0].DiscriminatorValue); + Assert.AreEqual("AnotherUnknownType", notifications[1].DiscriminatorValue); + Assert.AreEqual("AlsoUnknown", notifications[2].DiscriminatorValue); + } + + [Test] + public void CallbackInvokedInPropertyPresenceMode() + { + var notifications = new List(); + var options = new JsonSerializerOptions + { + Converters = + { + JsonSubtypesWithPropertyConverterBuilder + .Of(typeof(Animal)) + .RegisterSubtypeWithProperty(typeof(Cat), "catLives") + .RegisterSubtypeWithProperty(typeof(Dog), "canBark") + .SetFallbackSubtype(typeof(UnknownAnimal)) + .OnUnresolvedSubtype(notifications.Add) + .Build() + } + }; + + var result = JsonSerializer.Deserialize("{\"age\":3}", options); + + Assert.AreEqual(typeof(UnknownAnimal), result?.GetType()); + Assert.AreEqual(1, notifications.Count); + Assert.AreEqual(typeof(Animal), notifications[0].ParentType); + Assert.IsNull(notifications[0].DiscriminatorPropertyName); + Assert.IsFalse(notifications[0].HasDiscriminator); + Assert.AreEqual(typeof(UnknownAnimal), notifications[0].FallbackSubtype); + } + } +} From 44f81baf6a83db8da24714040e974e7632f75090 Mon Sep 17 00:00:00 2001 From: manuc66 Date: Wed, 12 Aug 2026 00:13:40 +0200 Subject: [PATCH 3/4] Document OnUnresolvedSubtype in README and changelog #112 --- CHANGELOG.md | 6 ++++++ README.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb575c7..15e7de7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### JsonSubTypes +#### Added +- `OnUnresolvedSubtype` on `JsonSubtypesConverterBuilder` and `JsonSubtypesWithPropertyConverterBuilder`: a callback invoked once per JSON element whose subtype cannot be resolved (unknown or missing discriminator value, or no matching property). #112 #### Fixed - Deserialization with an open generic base type (e.g. `Base<>`) now closes the generic subtype correctly (e.g. `Nested1` for `Base`) instead of failing. #177 - Errors and exceptions raised while deserializing a subtype now carry the fully qualified JSON path (e.g. `Property2.Value` instead of `Value`), matching stock Newtonsoft.Json error handling. #182 +### JsonSubTypes.Text.Json +#### Added +- `OnUnresolvedSubtype` on `JsonSubtypesConverterBuilder` and `JsonSubtypesWithPropertyConverterBuilder`: the `System.Text.Json` equivalent of the Newtonsoft.Json callback above. #112 + ## [1.0.0-rc.2] - 2026-08-10 ### Changed - Rebuilt with Source Link, deterministic builds and `.snupkg` symbol packages so symbols validate against the published package. diff --git a/README.md b/README.md index 59747b0..2feab7b 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,34 @@ settings.Converters.Add(JsonSubtypesWithPropertyConverterBuilder .Build()); ``` +## Detecting unregistered subtypes + +When deserializing a polymorphic tree, `OnUnresolvedSubtype` lets you collect every JSON +object whose subtype could not be resolved (unknown or missing discriminator value, or no +matching property in property-presence mode), instead of relying on a "trap" fallback type. + +```cs +var notifications = new List(); +settings.Converters.Add(JsonSubtypesConverterBuilder + .Of(typeof(IStep), "Type") + .SetFallbackSubtype(typeof(UnknownStepsTrap)) + .RegisterSubtype(typeof(RegisteredStep), "RegisteredStep") + .OnUnresolvedSubtype(notifications.Add) + .Build()); + +var steps = JsonConvert.DeserializeObject>(json, settings); +``` + +The callback is invoked once per unresolved element, on the thread performing the +deserialization. `UnresolvedSubtypeInfo` carries the parent type, the discriminator property +name, the discriminator value read from the JSON, a `HasDiscriminator` flag (to distinguish a +missing discriminator from an unknown value), and the fallback subtype that will be used +(`null` when none is configured). If you share a converter across threads, the callback is +invoked from multiple threads and must be thread-safe. The same API is available on +`JsonSubtypesWithPropertyConverterBuilder` and, for `System.Text.Json`, on +`JsonSubtypesConverterBuilder`/`JsonSubtypesWithPropertyConverterBuilder` in the +`JsonSubTypes.Text.Json` namespace. + ## System.Text.Json variant > **Status: experimental.** The `JsonSubTypes.Text.Json` package is a **release candidate** (`1.0.0-rc.x`) and not yet part of the project's stable offering. The code is fully tested (133 unit tests) and the API is complete, but the stable `1.0.0` release will follow once the package has been exercised in more real-world projects. From 8e3912c62e2ec44651a03289b1af3ff4a5ceed01 Mon Sep 17 00:00:00 2001 From: manuc66 Date: Wed, 12 Aug 2026 00:23:14 +0200 Subject: [PATCH 4/4] Match manuc66's sparse XML-doc style: drop docs on builders and UnresolvedSubtypeInfo The public API keeps the README as its documentation; no XML doc comments on fluent builder methods or value classes, consistent with the rest of the repo. --- .../JsonSubtypesConverterBuilder.cs | 5 ---- ...sonSubtypesWithPropertyConverterBuilder.cs | 5 ---- .../UnresolvedSubtypeInfo.cs | 27 ------------------- JsonSubTypes/JsonSubtypesConverterBuilder.cs | 5 ---- ...sonSubtypesWithPropertyConverterBuilder.cs | 5 ---- JsonSubTypes/UnresolvedSubtypeInfo.cs | 27 ------------------- 6 files changed, 74 deletions(-) diff --git a/JsonSubTypes.Text.Json/JsonSubtypesConverterBuilder.cs b/JsonSubTypes.Text.Json/JsonSubtypesConverterBuilder.cs index e6ccb00..625ecd7 100644 --- a/JsonSubTypes.Text.Json/JsonSubtypesConverterBuilder.cs +++ b/JsonSubTypes.Text.Json/JsonSubtypesConverterBuilder.cs @@ -57,11 +57,6 @@ public JsonSubtypesConverterBuilder SetFallbackSubtype() return SetFallbackSubtype(typeof(T)); } - /// - /// Registers a callback invoked when a JSON object's subtype cannot be resolved - /// (unknown or missing discriminator value). The callback is invoked on the thread - /// performing the deserialization, once per unresolved element. - /// public JsonSubtypesConverterBuilder OnUnresolvedSubtype(Action onUnresolvedSubtype) { _onUnresolvedSubtype = onUnresolvedSubtype; diff --git a/JsonSubTypes.Text.Json/JsonSubtypesWithPropertyConverterBuilder.cs b/JsonSubTypes.Text.Json/JsonSubtypesWithPropertyConverterBuilder.cs index 137decf..4a5cd2b 100644 --- a/JsonSubTypes.Text.Json/JsonSubtypesWithPropertyConverterBuilder.cs +++ b/JsonSubTypes.Text.Json/JsonSubtypesWithPropertyConverterBuilder.cs @@ -61,11 +61,6 @@ public JsonSubtypesWithPropertyConverterBuilder SetFallbackSubtype() return SetFallbackSubtype(typeof(T)); } - /// - /// Registers a callback invoked when a JSON object's subtype cannot be resolved - /// (no registered property matched the JSON object). The callback is invoked on - /// the thread performing the deserialization, once per unresolved element. - /// public JsonSubtypesWithPropertyConverterBuilder OnUnresolvedSubtype(Action onUnresolvedSubtype) { _onUnresolvedSubtype = onUnresolvedSubtype; diff --git a/JsonSubTypes.Text.Json/UnresolvedSubtypeInfo.cs b/JsonSubTypes.Text.Json/UnresolvedSubtypeInfo.cs index 88d6d6b..5a56cfe 100644 --- a/JsonSubTypes.Text.Json/UnresolvedSubtypeInfo.cs +++ b/JsonSubTypes.Text.Json/UnresolvedSubtypeInfo.cs @@ -2,39 +2,12 @@ namespace JsonSubTypes.Text.Json { - /// - /// Describes a JSON object whose subtype could not be resolved by the converter - /// (unknown or missing discriminator value, or no matching property in - /// property-presence mode). - /// public class UnresolvedSubtypeInfo { - /// Gets the type for which the subtype was resolved. public Type ParentType { get; } - - /// - /// Gets the name of the discriminator property, or null when the converter - /// resolves by property presence. - /// public string? DiscriminatorPropertyName { get; } - - /// - /// Gets the discriminator value read from the JSON object, or null when the - /// discriminator property is missing or holds a JSON null. - /// public object? DiscriminatorValue { get; } - - /// - /// Gets a value indicating whether the discriminator property was present in the - /// JSON object. Use this to distinguish a missing discriminator from an unknown - /// value ( is null in both cases). - /// public bool HasDiscriminator { get; } - - /// - /// Gets the fallback subtype that will be used for this JSON object, or null - /// when no fallback subtype is configured. - /// public Type? FallbackSubtype { get; } public UnresolvedSubtypeInfo(Type parentType, string? discriminatorPropertyName, object? discriminatorValue, diff --git a/JsonSubTypes/JsonSubtypesConverterBuilder.cs b/JsonSubTypes/JsonSubtypesConverterBuilder.cs index c3d2f6a..8895b20 100644 --- a/JsonSubTypes/JsonSubtypesConverterBuilder.cs +++ b/JsonSubTypes/JsonSubtypesConverterBuilder.cs @@ -84,11 +84,6 @@ public JsonSubtypesConverterBuilder SetFallbackSubtype() return SetFallbackSubtype(typeof(T)); } - /// - /// Registers a callback invoked when a JSON object's subtype cannot be resolved - /// (unknown or missing discriminator value). The callback is invoked on the thread - /// performing the deserialization, once per unresolved element. - /// public JsonSubtypesConverterBuilder OnUnresolvedSubtype(Action onUnresolvedSubtype) { _onUnresolvedSubtype = onUnresolvedSubtype; diff --git a/JsonSubTypes/JsonSubtypesWithPropertyConverterBuilder.cs b/JsonSubTypes/JsonSubtypesWithPropertyConverterBuilder.cs index f6e3bd8..4858f3b 100644 --- a/JsonSubTypes/JsonSubtypesWithPropertyConverterBuilder.cs +++ b/JsonSubTypes/JsonSubtypesWithPropertyConverterBuilder.cs @@ -54,11 +54,6 @@ public JsonSubtypesWithPropertyConverterBuilder SetFallbackSubtype() return SetFallbackSubtype(typeof(T)); } - /// - /// Registers a callback invoked when a JSON object's subtype cannot be resolved - /// (no registered property matched the JSON object). The callback is invoked on - /// the thread performing the deserialization, once per unresolved element. - /// public JsonSubtypesWithPropertyConverterBuilder OnUnresolvedSubtype(Action onUnresolvedSubtype) { _onUnresolvedSubtype = onUnresolvedSubtype; diff --git a/JsonSubTypes/UnresolvedSubtypeInfo.cs b/JsonSubTypes/UnresolvedSubtypeInfo.cs index 738bd60..26a5d20 100644 --- a/JsonSubTypes/UnresolvedSubtypeInfo.cs +++ b/JsonSubTypes/UnresolvedSubtypeInfo.cs @@ -2,39 +2,12 @@ namespace JsonSubTypes { - /// - /// Describes a JSON object whose subtype could not be resolved by the converter - /// (unknown or missing discriminator value, or no matching property in - /// property-presence mode). - /// public class UnresolvedSubtypeInfo { - /// Gets the type for which the subtype was resolved. public Type ParentType { get; } - - /// - /// Gets the name of the discriminator property, or null when the converter - /// resolves by property presence. - /// public string DiscriminatorPropertyName { get; } - - /// - /// Gets the discriminator value read from the JSON object, or null when the - /// discriminator property is missing or holds a JSON null. - /// public object DiscriminatorValue { get; } - - /// - /// Gets a value indicating whether the discriminator property was present in the - /// JSON object. Use this to distinguish a missing discriminator from an unknown - /// value ( is null in both cases). - /// public bool HasDiscriminator { get; } - - /// - /// Gets the fallback subtype that will be used for this JSON object, or null - /// when no fallback subtype is configured. - /// public Type FallbackSubtype { get; } public UnresolvedSubtypeInfo(Type parentType, string discriminatorPropertyName, object discriminatorValue,