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/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); + } + } +} 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..625ecd7 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,12 @@ public JsonSubtypesConverterBuilder SetFallbackSubtype() return SetFallbackSubtype(typeof(T)); } + public JsonSubtypesConverterBuilder OnUnresolvedSubtype(Action onUnresolvedSubtype) + { + _onUnresolvedSubtype = onUnresolvedSubtype; + return this; + } + public JsonSubtypesConverterBuilder SerializeDiscriminatorProperty() { return SerializeDiscriminatorProperty(true); @@ -95,13 +102,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..4a5cd2b 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,12 @@ public JsonSubtypesWithPropertyConverterBuilder SetFallbackSubtype() return SetFallbackSubtype(typeof(T)); } + 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 +81,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..5a56cfe --- /dev/null +++ b/JsonSubTypes.Text.Json/UnresolvedSubtypeInfo.cs @@ -0,0 +1,23 @@ +using System; + +namespace JsonSubTypes.Text.Json +{ + public class UnresolvedSubtypeInfo + { + public Type ParentType { get; } + public string? DiscriminatorPropertyName { get; } + public object? DiscriminatorValue { get; } + public bool HasDiscriminator { get; } + 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..8895b20 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,15 @@ public JsonSubtypesConverterBuilder SetFallbackSubtype() return SetFallbackSubtype(typeof(T)); } + 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..4858f3b 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,15 @@ public JsonSubtypesWithPropertyConverterBuilder SetFallbackSubtype() return SetFallbackSubtype(typeof(T)); } + 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..26a5d20 --- /dev/null +++ b/JsonSubTypes/UnresolvedSubtypeInfo.cs @@ -0,0 +1,23 @@ +using System; + +namespace JsonSubTypes +{ + public class UnresolvedSubtypeInfo + { + public Type ParentType { get; } + public string DiscriminatorPropertyName { get; } + public object DiscriminatorValue { get; } + public bool HasDiscriminator { get; } + 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/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.