From c491827d41bb33f281cd878238e44a40a87ce7c9 Mon Sep 17 00:00:00 2001 From: manuc66 Date: Sat, 15 Aug 2026 12:00:09 +0200 Subject: [PATCH 1/5] Replace the global assembly registry with a declarative JsonSubTypesTypeResolution attribute JsonSubTypesTypeResolution.AddAssembly was a process-wide mutable registry that leaked state across serialization profiles. Replace it with [JsonSubTypesTypeResolution("AssemblyName")] on the base type, cached per type. The attribute takes an assembly name rather than a Type: a Type would force the base to reference the plugin assembly, which together with the plugin's reference to the base creates a project cycle. Naming the assembly keeps the base free of a compile-time reference to the plugin. The cross-assembly tests now use SharedAnimal (base) with the plugin assembly declared on it and PluginDog (in the plugin project) resolved by name, plus a negative test on a base without the attribute. --- CHANGELOG.md | 1 + .../SharedAnimal.cs | 1 + .../ReviewBugTests.cs | 38 ++++------- .../JsonSubTypesTypeResolution.cs | 65 +++++++++++++------ JsonSubTypes.Text.Json/JsonSubtypes.cs | 2 +- MIGRATION.md | 2 +- README.md | 4 +- 7 files changed, 64 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 878ca6e..29cdc46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `[JsonSubTypesTypeResolution("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 diff --git a/JsonSubTypes.Text.Json.Tests.Shared/SharedAnimal.cs b/JsonSubTypes.Text.Json.Tests.Shared/SharedAnimal.cs index 9d41974..060d274 100644 --- a/JsonSubTypes.Text.Json.Tests.Shared/SharedAnimal.cs +++ b/JsonSubTypes.Text.Json.Tests.Shared/SharedAnimal.cs @@ -1,6 +1,7 @@ namespace JsonSubTypes.Text.Json.Tests.Shared { [JsonSubTypeConverter(typeof(JsonSubtypes), "Kind")] + [JsonSubTypesTypeResolution("JsonSubTypes.Text.Json.Tests.Plugin")] public class SharedAnimal { public string? Kind { get; set; } diff --git a/JsonSubTypes.Text.Json.Tests/ReviewBugTests.cs b/JsonSubTypes.Text.Json.Tests/ReviewBugTests.cs index 06f78b9..1c6fcbd 100644 --- a/JsonSubTypes.Text.Json.Tests/ReviewBugTests.cs +++ b/JsonSubTypes.Text.Json.Tests/ReviewBugTests.cs @@ -208,37 +208,27 @@ public void NameBasedResolutionRejectsNonSubtypes() [Test] public void CrossAssemblyResolvedWhenAssemblyRegistered() { - JsonSubTypesTypeResolution.ClearAssemblies(); - JsonSubTypesTypeResolution.AddAssembly(typeof(PluginDog).Assembly); - try - { - var dog = JsonSerializer.Deserialize( - $"{{\"Kind\":\"{typeof(PluginDog).FullName}\",\"CanBark\":true}}"); + var dog = JsonSerializer.Deserialize( + $"{{\"Kind\":\"{typeof(PluginDog).FullName}\",\"CanBark\":true}}"); - Assert.IsInstanceOf(dog); - Assert.IsTrue((dog as PluginDog)?.CanBark == true); - } - finally - { - JsonSubTypesTypeResolution.ClearAssemblies(); - } + Assert.IsInstanceOf(dog); + Assert.IsTrue((dog as PluginDog)?.CanBark == true); } [Test] public void CrossAssemblyNotResolvedByDefault() { - JsonSubTypesTypeResolution.ClearAssemblies(); - try - { - var animal = JsonSerializer.Deserialize( - $"{{\"Kind\":\"{typeof(PluginDog).FullName}\",\"CanBark\":true}}"); + var animal = JsonSerializer.Deserialize( + $"{{\"Kind\":\"{typeof(PluginDog).FullName}\",\"CanBark\":true}}"); - Assert.IsInstanceOf(animal); - } - finally - { - JsonSubTypesTypeResolution.ClearAssemblies(); - } + Assert.IsInstanceOf(animal); + } + + // A base type without the attribute: name-based resolution stays in its own assembly. + [JsonSubTypeConverter(typeof(JsonSubtypes), "Kind")] + public class OtherBase + { + public string Kind { get; set; } } } } diff --git a/JsonSubTypes.Text.Json/JsonSubTypesTypeResolution.cs b/JsonSubTypes.Text.Json/JsonSubTypesTypeResolution.cs index 9477bce..2bcb4a2 100644 --- a/JsonSubTypes.Text.Json/JsonSubTypesTypeResolution.cs +++ b/JsonSubTypes.Text.Json/JsonSubTypesTypeResolution.cs @@ -1,40 +1,63 @@ +using System; using System.Collections.Concurrent; using System.Collections.Generic; -using System.Linq; using System.Reflection; namespace JsonSubTypes.Text.Json; -public static class JsonSubTypesTypeResolution +/// +/// Declares an additional assembly to search when resolving subtypes by name from the JSON +/// discriminator for the decorated polymorphic base type. The assembly is referenced by name so +/// the base type does not need a compile-time reference to it, which is what keeps the plugin +/// pattern cycle-free: the plugin references the base, the base merely names the plugin. +/// +/// +/// The assignability guard still applies: only types assignable from the base type are +/// considered. This attribute replaces the global JsonSubTypesTypeResolution.AddAssembly +/// registry, which leaked state across serialization profiles; resolution is now declared on the +/// base type and cached per type. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface, AllowMultiple = true)] +public class JsonSubTypesTypeResolution(string assemblyName) : Attribute { - private static readonly ConcurrentDictionary Assemblies = new(); + public string AssemblyName { get; } = assemblyName; +} - public static void AddAssembly(Assembly assembly) - { - Assemblies.TryAdd(assembly, 0); - } +internal static class TypeResolution +{ + private static readonly ConcurrentDictionary AssembliesByBaseType = new(); - public static void RemoveAssembly(Assembly assembly) + public static Assembly[] GetSearchAssemblies(TypeInfo baseType) { - Assemblies.TryRemove(assembly, out _); - } + return AssembliesByBaseType.GetOrAdd(baseType, static type => + { + List assemblies = [type.Assembly]; + foreach (object attribute in type.GetCustomAttributes(false)) + { + if (attribute is JsonSubTypesTypeResolution resolution) + { + Assembly? assembly = FindAssembly(resolution.AssemblyName); + if (assembly != null && !assemblies.Contains(assembly)) + { + assemblies.Add(assembly); + } + } + } - public static void ClearAssemblies() - { - Assemblies.Clear(); + return [.. assemblies]; + }); } - public static IReadOnlyCollection SearchAssemblies => Assemblies.Keys.ToArray(); - - internal static IEnumerable GetSearchAssemblies(Assembly parentAssembly) + private static Assembly? FindAssembly(string assemblyName) { - yield return parentAssembly; - foreach (Assembly assembly in Assemblies.Keys) + foreach (Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) { - if (assembly != parentAssembly) + if (string.Equals(assembly.GetName().Name, assemblyName, StringComparison.Ordinal)) { - yield return assembly; + return assembly; } } + + return null; } -} \ No newline at end of file +} diff --git a/JsonSubTypes.Text.Json/JsonSubtypes.cs b/JsonSubTypes.Text.Json/JsonSubtypes.cs index 15343e4..21401f5 100644 --- a/JsonSubTypes.Text.Json/JsonSubtypes.cs +++ b/JsonSubTypes.Text.Json/JsonSubtypes.cs @@ -813,7 +813,7 @@ 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)) + foreach (Assembly assembly in TypeResolution.GetSearchAssemblies(parentType)) { Type? typeByName = assembly.GetType(typeName); if (typeByName == null && searchLocation != null) diff --git a/MIGRATION.md b/MIGRATION.md index 3b5b336..b718d23 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -53,7 +53,7 @@ Behaviour that actually differs — check your tests against these: - **Property order differs.** STJ emits most-derived-first; there is no `[JsonProperty(Order = N)]` support. - **`MaxDepth` needs one more level** because the write path round-trips through a `JsonDocument`. - **Fallback paths are narrower**: serializing the base type directly or an unknown discriminator uses a reflection-based path that honors `[JsonIgnore]`, `[JsonPropertyName]`, naming policy and `DefaultIgnoreCondition`, but not per-property `[JsonConverter]`, `[JsonInclude]` fields, `required` members or parameterized constructors. -- **Cross-assembly subtypes** require opt-in (`JsonSubTypesTypeResolution.AddAssembly`); Newtonsoft never supported them. +- **Cross-assembly subtypes** require opt-in: `[JsonSubTypesTypeResolution("AssemblyName")]` on the base type; Newtonsoft never supported them. - **Security**: the name-based resolution warning in the README applies to both; see the [security section](./#security) there. ## Between the System.Text.Json engines diff --git a/README.md b/README.md index cc0f004..c569463 100644 --- a/README.md +++ b/README.md @@ -368,7 +368,7 @@ public interface IExpression { } - A property declared with a base class or interface type is serialized using the **declared type's contract**: subtype members are omitted unless a converter that claims the declared type is applied (attribute on the type, or builder registered in `JsonSerializerOptions`). The Newtonsoft version serialized the runtime type by default. - Property order differs: `System.Text.Json` emits properties most-derived-first, while the Newtonsoft version honored `[JsonProperty(Order = N)]`. There is no `Order` support in `System.Text.Json`. - Deeply nested graphs need `MaxDepth` about one level higher than with the Newtonsoft/plain serialization: the discriminator write path round-trips through a `JsonDocument`, which consumes one depth level. (A 64-level chain requires `MaxDepth = 66` instead of 65.) -- Name-based type resolution stays scoped to the base type's assembly by default. Cross-assembly subtypes require an explicit opt-in: `JsonSubTypesTypeResolution.AddAssembly(...)`, a capability the Newtonsoft version does not have. +- Name-based type resolution stays scoped to the base type's assembly by default. Cross-assembly subtypes require an explicit opt-in: `[JsonSubTypesTypeResolution("AssemblyName")]` on the base type, a capability the Newtonsoft version does not have. - `JsonNamingPolicy` and `PropertyNameCaseInsensitive` are respected when matching the discriminator property, and `JsonStringEnumConverter` is respected when mapping discriminator values. Note that `JsonStringEnumConverter` (.NET 8) does **not** honor `[EnumMember(Value = ...)]` — use enum names or `[JsonStringEnumMemberName]` (.NET 9+). - Dotted or nested discriminator property paths (e.g. `"nested.property"`) are supported. - **Fallback paths**: serializing the base type itself (rather than a subtype) and deserializing an unknown discriminator back to the base use a reflection-based writer/reader, because the base type's contract is owned by the converter (`System.Text.Json` exposes no property metadata for converter-owned types). `[JsonPropertyName]`, `[JsonIgnore]` (including `JsonIgnoreCondition`), the naming policy and `DefaultIgnoreCondition` are honored; per-property `[JsonConverter]`, `[JsonInclude]` fields, `required` members and parameterized constructors are not supported on these two paths. @@ -380,7 +380,7 @@ public interface IExpression { } When a subtype is resolved by *name* — which happens for both packages **only when no subtype mapping is declared at all** (no `[KnownSubType]` attribute, no `RegisterSubtype` builder call) — the converter turns the JSON discriminator string into a type name and instantiates the matching type. Declaring a mapping at all switches the converter to that mapping, even when no entry matches; the name-based path is never used then. -Only types assignable from the polymorphic base type can be resolved, but any such type present in the base type's assembly (for Newtonsoft.Json) or in that assembly plus any assembly registered via `JsonSubTypesTypeResolution` (for `System.Text.Json`) can be instantiated with attacker-controlled JSON. Do **not** expose a name-based hierarchy to untrusted JSON without validating the payload upstream; prefer explicit `[KnownSubType]` or builder mappings whenever the discriminator can come from outside your own code. +Only types assignable from the polymorphic base type can be resolved, but any such type present in the base type's assembly (for Newtonsoft.Json) or in that assembly plus any assembly named by a `[JsonSubTypesTypeResolution]` attribute on the base type (for `System.Text.Json`) can be instantiated with attacker-controlled JSON. Do **not** expose a name-based hierarchy to untrusted JSON without validating the payload upstream; prefer explicit `[KnownSubType]` or builder mappings whenever the discriminator can come from outside your own code. ### Which engine should I use? From b99e25bdbfa8e8b663dc2f308c89a97be2611b0a Mon Sep 17 00:00:00 2001 From: manuc66 Date: Sat, 15 Aug 2026 20:25:17 +0200 Subject: [PATCH 2/5] Rename JsonSubTypesTypeResolution to KnownSubTypeOtherAssembly The attribute's previous name was inherited from the global AddAssembly registry it replaced and said nothing about its contract. KnownSubTypeOtherAssembly is a noun consistent with the KnownSubType family, and Other carries the extension beyond the base type's own assembly. The file follows the class name. --- CHANGELOG.md | 2 +- .../SharedAnimal.cs | 2 +- JsonSubTypes.Text.Json/JsonSubtypes.cs | 2 +- ...Resolution.cs => KnownSubTypeOtherAssembly.cs} | 15 ++++++++------- MIGRATION.md | 2 +- README.md | 4 ++-- 6 files changed, 14 insertions(+), 13 deletions(-) rename JsonSubTypes.Text.Json/{JsonSubTypesTypeResolution.cs => KnownSubTypeOtherAssembly.cs} (72%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29cdc46..2d430df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +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 `[JsonSubTypesTypeResolution("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. +- 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 diff --git a/JsonSubTypes.Text.Json.Tests.Shared/SharedAnimal.cs b/JsonSubTypes.Text.Json.Tests.Shared/SharedAnimal.cs index 060d274..f3ed827 100644 --- a/JsonSubTypes.Text.Json.Tests.Shared/SharedAnimal.cs +++ b/JsonSubTypes.Text.Json.Tests.Shared/SharedAnimal.cs @@ -1,7 +1,7 @@ namespace JsonSubTypes.Text.Json.Tests.Shared { [JsonSubTypeConverter(typeof(JsonSubtypes), "Kind")] - [JsonSubTypesTypeResolution("JsonSubTypes.Text.Json.Tests.Plugin")] + [KnownSubTypeOtherAssembly("JsonSubTypes.Text.Json.Tests.Plugin")] public class SharedAnimal { public string? Kind { get; set; } diff --git a/JsonSubTypes.Text.Json/JsonSubtypes.cs b/JsonSubTypes.Text.Json/JsonSubtypes.cs index 21401f5..3e7bc97 100644 --- a/JsonSubTypes.Text.Json/JsonSubtypes.cs +++ b/JsonSubTypes.Text.Json/JsonSubtypes.cs @@ -106,7 +106,7 @@ internal interface IJsonSubtypes /// Name-based resolution (used only when no 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 . Any such type present in +/// assembly registered via . Any such type present in /// those assemblies can be instantiated with attacker-controlled JSON. /// /// diff --git a/JsonSubTypes.Text.Json/JsonSubTypesTypeResolution.cs b/JsonSubTypes.Text.Json/KnownSubTypeOtherAssembly.cs similarity index 72% rename from JsonSubTypes.Text.Json/JsonSubTypesTypeResolution.cs rename to JsonSubTypes.Text.Json/KnownSubTypeOtherAssembly.cs index 2bcb4a2..61923d8 100644 --- a/JsonSubTypes.Text.Json/JsonSubTypesTypeResolution.cs +++ b/JsonSubTypes.Text.Json/KnownSubTypeOtherAssembly.cs @@ -6,10 +6,11 @@ namespace JsonSubTypes.Text.Json; /// -/// Declares an additional assembly to search when resolving subtypes by name from the JSON -/// discriminator for the decorated polymorphic base type. The assembly is referenced by name so -/// the base type does not need a compile-time reference to it, which is what keeps the plugin -/// pattern cycle-free: the plugin references the base, the base merely names the plugin. +/// Declares another assembly to search, in addition to the base type's own assembly, when +/// resolving subtypes by name from the JSON discriminator for the decorated polymorphic base +/// type. The assembly is referenced by name so the base type does not need a compile-time +/// reference to it, which is what keeps the plugin pattern cycle-free: the plugin references the +/// base, the base merely names the plugin. /// /// /// The assignability guard still applies: only types assignable from the base type are @@ -18,7 +19,7 @@ namespace JsonSubTypes.Text.Json; /// base type and cached per type. /// [AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface, AllowMultiple = true)] -public class JsonSubTypesTypeResolution(string assemblyName) : Attribute +public class KnownSubTypeOtherAssembly(string assemblyName) : Attribute { public string AssemblyName { get; } = assemblyName; } @@ -34,9 +35,9 @@ public static Assembly[] GetSearchAssemblies(TypeInfo baseType) List assemblies = [type.Assembly]; foreach (object attribute in type.GetCustomAttributes(false)) { - if (attribute is JsonSubTypesTypeResolution resolution) + if (attribute is KnownSubTypeOtherAssembly otherAssembly) { - Assembly? assembly = FindAssembly(resolution.AssemblyName); + Assembly? assembly = FindAssembly(otherAssembly.AssemblyName); if (assembly != null && !assemblies.Contains(assembly)) { assemblies.Add(assembly); diff --git a/MIGRATION.md b/MIGRATION.md index b718d23..da5eb56 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -53,7 +53,7 @@ Behaviour that actually differs — check your tests against these: - **Property order differs.** STJ emits most-derived-first; there is no `[JsonProperty(Order = N)]` support. - **`MaxDepth` needs one more level** because the write path round-trips through a `JsonDocument`. - **Fallback paths are narrower**: serializing the base type directly or an unknown discriminator uses a reflection-based path that honors `[JsonIgnore]`, `[JsonPropertyName]`, naming policy and `DefaultIgnoreCondition`, but not per-property `[JsonConverter]`, `[JsonInclude]` fields, `required` members or parameterized constructors. -- **Cross-assembly subtypes** require opt-in: `[JsonSubTypesTypeResolution("AssemblyName")]` on the base type; Newtonsoft never supported them. +- **Cross-assembly subtypes** require opt-in: `[KnownSubTypeOtherAssembly("AssemblyName")]` on the base type; Newtonsoft never supported them. - **Security**: the name-based resolution warning in the README applies to both; see the [security section](./#security) there. ## Between the System.Text.Json engines diff --git a/README.md b/README.md index c569463..3e685c4 100644 --- a/README.md +++ b/README.md @@ -368,7 +368,7 @@ public interface IExpression { } - A property declared with a base class or interface type is serialized using the **declared type's contract**: subtype members are omitted unless a converter that claims the declared type is applied (attribute on the type, or builder registered in `JsonSerializerOptions`). The Newtonsoft version serialized the runtime type by default. - Property order differs: `System.Text.Json` emits properties most-derived-first, while the Newtonsoft version honored `[JsonProperty(Order = N)]`. There is no `Order` support in `System.Text.Json`. - Deeply nested graphs need `MaxDepth` about one level higher than with the Newtonsoft/plain serialization: the discriminator write path round-trips through a `JsonDocument`, which consumes one depth level. (A 64-level chain requires `MaxDepth = 66` instead of 65.) -- Name-based type resolution stays scoped to the base type's assembly by default. Cross-assembly subtypes require an explicit opt-in: `[JsonSubTypesTypeResolution("AssemblyName")]` on the base type, a capability the Newtonsoft version does not have. +- Name-based type resolution stays scoped to the base type's assembly by default. Cross-assembly subtypes require an explicit opt-in: `[KnownSubTypeOtherAssembly("AssemblyName")]` on the base type, a capability the Newtonsoft version does not have. - `JsonNamingPolicy` and `PropertyNameCaseInsensitive` are respected when matching the discriminator property, and `JsonStringEnumConverter` is respected when mapping discriminator values. Note that `JsonStringEnumConverter` (.NET 8) does **not** honor `[EnumMember(Value = ...)]` — use enum names or `[JsonStringEnumMemberName]` (.NET 9+). - Dotted or nested discriminator property paths (e.g. `"nested.property"`) are supported. - **Fallback paths**: serializing the base type itself (rather than a subtype) and deserializing an unknown discriminator back to the base use a reflection-based writer/reader, because the base type's contract is owned by the converter (`System.Text.Json` exposes no property metadata for converter-owned types). `[JsonPropertyName]`, `[JsonIgnore]` (including `JsonIgnoreCondition`), the naming policy and `DefaultIgnoreCondition` are honored; per-property `[JsonConverter]`, `[JsonInclude]` fields, `required` members and parameterized constructors are not supported on these two paths. @@ -380,7 +380,7 @@ public interface IExpression { } When a subtype is resolved by *name* — which happens for both packages **only when no subtype mapping is declared at all** (no `[KnownSubType]` attribute, no `RegisterSubtype` builder call) — the converter turns the JSON discriminator string into a type name and instantiates the matching type. Declaring a mapping at all switches the converter to that mapping, even when no entry matches; the name-based path is never used then. -Only types assignable from the polymorphic base type can be resolved, but any such type present in the base type's assembly (for Newtonsoft.Json) or in that assembly plus any assembly named by a `[JsonSubTypesTypeResolution]` attribute on the base type (for `System.Text.Json`) can be instantiated with attacker-controlled JSON. Do **not** expose a name-based hierarchy to untrusted JSON without validating the payload upstream; prefer explicit `[KnownSubType]` or builder mappings whenever the discriminator can come from outside your own code. +Only types assignable from the polymorphic base type can be resolved, but any such type present in the base type's assembly (for Newtonsoft.Json) or in that assembly plus any assembly named by a `[KnownSubTypeOtherAssembly]` attribute on the base type (for `System.Text.Json`) can be instantiated with attacker-controlled JSON. Do **not** expose a name-based hierarchy to untrusted JSON without validating the payload upstream; prefer explicit `[KnownSubType]` or builder mappings whenever the discriminator can come from outside your own code. ### Which engine should I use? From 102b07990a26dbd6f146360d340039651a81904b Mon Sep 17 00:00:00 2001 From: manuc66 Date: Sat, 15 Aug 2026 20:45:14 +0200 Subject: [PATCH 3/5] Add the self-declaring plugin pattern via KnownSubTypeOf and RegisterSubtypeAssembly Support the inverse philosophy of the base-declares approach: the subtype declares itself as a child of the base through [KnownSubTypeOf(typeof(Base), "value")], and the host registers the plugin assembly at runtime through RegisterSubtypeAssembly. This covers the real plugin scenario the attribute cannot: assemblies loaded at runtime whose names are unknown at compile time. The scan mirrors the runtime cross-assembly approach: register the assembly, scan its types for self-declared subtypes, and map those that carry a discriminator value; types without a value resolve by name in the registered assembly. ReflectionTypeLoadException is handled so an assembly with unloadable optional types is not fatal. Both philosophies coexist: the base can still name other assemblies via [KnownSubTypeOtherAssembly]. --- ...JsonSubTypes.Text.Json.Tests.Plugin.csproj | 1 + .../PluginDog.cs | 2 + .../SelfDeclaredDog.cs | 28 +++++++++++ .../SelfDeclaredBase.cs | 10 ++++ .../ReviewBugTests.cs | 36 ++++++++++++++ JsonSubTypes.Text.Json/JsonSubtypes.cs | 16 +++++-- .../JsonSubtypesConverterBuilder.cs | 48 ++++++++++++++++++- ...sonSubtypesWithPropertyConverterBuilder.cs | 5 +- .../KnownSubTypeOfAttribute.cs | 22 +++++++++ 9 files changed, 160 insertions(+), 8 deletions(-) create mode 100644 JsonSubTypes.Text.Json.Tests.Plugin/SelfDeclaredDog.cs create mode 100644 JsonSubTypes.Text.Json.Tests.Shared/SelfDeclaredBase.cs create mode 100644 JsonSubTypes.Text.Json/KnownSubTypeOfAttribute.cs diff --git a/JsonSubTypes.Text.Json.Tests.Plugin/JsonSubTypes.Text.Json.Tests.Plugin.csproj b/JsonSubTypes.Text.Json.Tests.Plugin/JsonSubTypes.Text.Json.Tests.Plugin.csproj index d1f5799..ee64e8d 100644 --- a/JsonSubTypes.Text.Json.Tests.Plugin/JsonSubTypes.Text.Json.Tests.Plugin.csproj +++ b/JsonSubTypes.Text.Json.Tests.Plugin/JsonSubTypes.Text.Json.Tests.Plugin.csproj @@ -5,5 +5,6 @@ + diff --git a/JsonSubTypes.Text.Json.Tests.Plugin/PluginDog.cs b/JsonSubTypes.Text.Json.Tests.Plugin/PluginDog.cs index bf18392..0b75f5e 100644 --- a/JsonSubTypes.Text.Json.Tests.Plugin/PluginDog.cs +++ b/JsonSubTypes.Text.Json.Tests.Plugin/PluginDog.cs @@ -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; } diff --git a/JsonSubTypes.Text.Json.Tests.Plugin/SelfDeclaredDog.cs b/JsonSubTypes.Text.Json.Tests.Plugin/SelfDeclaredDog.cs new file mode 100644 index 0000000..35fc6f0 --- /dev/null +++ b/JsonSubTypes.Text.Json.Tests.Plugin/SelfDeclaredDog.cs @@ -0,0 +1,28 @@ +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; } + } +} diff --git a/JsonSubTypes.Text.Json.Tests.Shared/SelfDeclaredBase.cs b/JsonSubTypes.Text.Json.Tests.Shared/SelfDeclaredBase.cs new file mode 100644 index 0000000..50765a0 --- /dev/null +++ b/JsonSubTypes.Text.Json.Tests.Shared/SelfDeclaredBase.cs @@ -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; } + } +} diff --git a/JsonSubTypes.Text.Json.Tests/ReviewBugTests.cs b/JsonSubTypes.Text.Json.Tests/ReviewBugTests.cs index 1c6fcbd..4e28153 100644 --- a/JsonSubTypes.Text.Json.Tests/ReviewBugTests.cs +++ b/JsonSubTypes.Text.Json.Tests/ReviewBugTests.cs @@ -230,5 +230,41 @@ 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("Kind") + .RegisterSubtypeAssembly(typeof(SelfDeclaredDog).Assembly) + .Build()); + + var dog = JsonSerializer.Deserialize("{\"Kind\":\"Dog\",\"CanBark\":true}", options); + + Assert.IsInstanceOf(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("Kind") + .RegisterSubtypeAssembly(typeof(SelfDeclaredCat).Assembly) + .Build()); + + var cat = JsonSerializer.Deserialize( + $"{{\"Kind\":\"{typeof(SelfDeclaredCat).FullName}\",\"Purrs\":true}}", options); + + Assert.IsInstanceOf(cat); + Assert.IsTrue((cat as SelfDeclaredCat)?.Purrs == true); + } } } diff --git a/JsonSubTypes.Text.Json/JsonSubtypes.cs b/JsonSubTypes.Text.Json/JsonSubtypes.cs index 3e7bc97..0aec7e4 100644 --- a/JsonSubTypes.Text.Json/JsonSubtypes.cs +++ b/JsonSubTypes.Text.Json/JsonSubtypes.cs @@ -147,9 +147,11 @@ private static readonly ConditionalWeakTable? _runtimeTypeToDiscriminator; + private readonly Assembly[] _additionalAssemblies; public JsonSubtypes() { + _additionalAssemblies = []; } public JsonSubtypes(string? jsonDiscriminatorPropertyName) @@ -157,6 +159,7 @@ public JsonSubtypes(string? jsonDiscriminatorPropertyName) JsonDiscriminatorPropertyName = jsonDiscriminatorPropertyName; _serializeDiscriminatorProperty = jsonDiscriminatorPropertyName != null; _addDiscriminatorFirst = true; + _additionalAssemblies = []; } internal JsonSubtypes(string? jsonDiscriminatorPropertyName, @@ -164,13 +167,15 @@ internal JsonSubtypes(string? jsonDiscriminatorPropertyName, List? 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(); @@ -733,7 +738,7 @@ .. GetAttributes(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, @@ -801,7 +806,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) { @@ -813,7 +818,10 @@ private static bool TryGetProperty(JsonElement obj, string name, JsonSerializerO ? null : parentTypeFullName.Substring(0, parentTypeFullName.Length - parentType.Name.Length); - foreach (Assembly assembly in TypeResolution.GetSearchAssemblies(parentType)) + Assembly[] attributeAssemblies = TypeResolution.GetSearchAssemblies(parentType); + IEnumerable assemblies = attributeAssemblies + .Concat(instanceAssemblies.Where(a => !attributeAssemblies.Contains(a))); + foreach (Assembly assembly in assemblies) { Type? typeByName = assembly.GetType(typeName); if (typeByName == null && searchLocation != null) diff --git a/JsonSubTypes.Text.Json/JsonSubtypesConverterBuilder.cs b/JsonSubTypes.Text.Json/JsonSubtypesConverterBuilder.cs index 75cbe5d..bb6ac5e 100644 --- a/JsonSubTypes.Text.Json/JsonSubtypesConverterBuilder.cs +++ b/JsonSubTypes.Text.Json/JsonSubtypesConverterBuilder.cs @@ -16,6 +16,7 @@ public class JsonSubtypesConverterBuilder private readonly Type _baseType; private readonly string _discriminatorProperty; private readonly NullableDictionary _subTypeMapping = new(); + private readonly List _additionalAssemblies = []; private Type? _fallbackType; private bool _serializeDiscriminatorProperty; private bool _addDiscriminatorFirst; @@ -49,6 +50,47 @@ public JsonSubtypesConverterBuilder RegisterSubtype(object? value) return RegisterSubtype(typeof(T), value); } + /// + /// Adds an assembly to search, in addition to the base type's own assembly, when resolving + /// subtypes by name from the discriminator. Unlike [KnownSubTypeOtherAssembly], this + /// accepts an assembly loaded at runtime, which the attribute cannot name at compile time. + /// Types in the assembly that carry [KnownSubTypeOf(base)] with a discriminator value + /// are also registered as subtypes of the base type (the self-declaring plugin pattern). + /// + public JsonSubtypesConverterBuilder RegisterSubtypeAssembly(Assembly assembly) + { + _additionalAssemblies.Add(assembly); + ScanForSelfDeclaredSubtypes(assembly); + return this; + } + + private void ScanForSelfDeclaredSubtypes(Assembly assembly) + { + Type[] types; + try + { + types = assembly.GetTypes(); + } + catch (ReflectionTypeLoadException e) + { + // An assembly can reference types it cannot load (e.g. an optional dependency that is + // not deployed). Scan the types that did load; skipping the rest is safe because a + // self-declared subtype must be loadable to be instantiated. + types = e.Types.Where(t => t != null).Cast().ToArray(); + } + + foreach (Type type in types) + { + foreach (KnownSubTypeOfAttribute attribute in type.GetCustomAttributes(inherit: false)) + { + if (attribute.BaseType == _baseType && attribute.DiscriminatorValue != null) + { + _subTypeMapping.Add(attribute.DiscriminatorValue, type); + } + } + } + } + public JsonSubtypesConverterBuilder SetFallbackSubtype(Type fallbackSubtype) { _fallbackType = fallbackSubtype; @@ -127,12 +169,14 @@ public JsonConverter Build() typeof(List), typeof(Type), typeof(bool), - typeof(bool) + typeof(bool), + typeof(Assembly[]) ], null)!; return (JsonConverter)constructor.Invoke( [ _discriminatorProperty, _subTypeMapping, null, _fallbackType, - _serializeDiscriminatorProperty, _addDiscriminatorFirst + _serializeDiscriminatorProperty, _addDiscriminatorFirst, + _additionalAssemblies.ToArray() ]); } diff --git a/JsonSubTypes.Text.Json/JsonSubtypesWithPropertyConverterBuilder.cs b/JsonSubTypes.Text.Json/JsonSubtypesWithPropertyConverterBuilder.cs index c44702c..c52a75d 100644 --- a/JsonSubTypes.Text.Json/JsonSubtypesWithPropertyConverterBuilder.cs +++ b/JsonSubTypes.Text.Json/JsonSubtypesWithPropertyConverterBuilder.cs @@ -71,9 +71,10 @@ public JsonConverter Build() typeof(List), typeof(Type), typeof(bool), - typeof(bool) + typeof(bool), + typeof(Assembly[]) ], null)!; return (JsonConverter)constructor.Invoke( - [null, null, _types.Values.ToList(), _fallbackType, false, false]); + [null, null, _types.Values.ToList(), _fallbackType, false, false, Array.Empty()]); } } diff --git a/JsonSubTypes.Text.Json/KnownSubTypeOfAttribute.cs b/JsonSubTypes.Text.Json/KnownSubTypeOfAttribute.cs new file mode 100644 index 0000000..938ab33 --- /dev/null +++ b/JsonSubTypes.Text.Json/KnownSubTypeOfAttribute.cs @@ -0,0 +1,22 @@ +using System; + +namespace JsonSubTypes.Text.Json; + +/// +/// Declares the decorated type as a subtype of , discovered by +/// when the containing +/// assembly is registered. With set, the type is also +/// mapped to that discriminator value (a fully self-declaring plugin); without it, the type is +/// resolved by name like any other type in the registered assembly. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface, AllowMultiple = true)] +public class KnownSubTypeOfAttribute(Type baseType, object? discriminatorValue = null) : Attribute +{ + public Type BaseType { get; } = baseType; + + /// + /// The discriminator value this subtype maps to, when the plugin declares its own mapping. + /// null (the default) means the subtype is only resolved by name. + /// + public object? DiscriminatorValue { get; } = discriminatorValue; +} From cb6db9a76bf754803b97ab2ab4b66d93ac4c6a7b Mon Sep 17 00:00:00 2001 From: manuc66 Date: Sat, 15 Aug 2026 20:58:39 +0200 Subject: [PATCH 4/5] Add RegisterDynamicSubtype to the runtime converter The generator already exposed RegisterDynamicSubtype, but the runtime converter did not: README claimed parity that did not exist. Add the same runtime hook to JsonSubtypes, mapping a discriminator to a subtype after the converter is built, with last-registration-wins like the generator. This covers the simple plugin need (register a subtype at runtime) without editing the plugin or scanning an assembly, complementing RegisterSubtypeAssembly. NullableDictionary gains a Set method for the overwrite semantics. --- .../ReviewBugTests.cs | 19 +++++++++++++++++ JsonSubTypes.Text.Json/JsonSubtypes.cs | 21 +++++++++++++++++++ JsonSubTypes.Text.Json/NullableDictionary.cs | 13 ++++++++++++ 3 files changed, 53 insertions(+) diff --git a/JsonSubTypes.Text.Json.Tests/ReviewBugTests.cs b/JsonSubTypes.Text.Json.Tests/ReviewBugTests.cs index 4e28153..a9f43f2 100644 --- a/JsonSubTypes.Text.Json.Tests/ReviewBugTests.cs +++ b/JsonSubTypes.Text.Json.Tests/ReviewBugTests.cs @@ -266,5 +266,24 @@ public void SelfDeclaredSubtypeResolvedByNameInRegisteredAssembly() Assert.IsInstanceOf(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)JsonSubtypesConverterBuilder + .Of("Kind") + .Build(); + converter.RegisterDynamicSubtype("dog", typeof(SelfDeclaredDog)); + + var options = new JsonSerializerOptions(); + options.Converters.Add(converter); + + var dog = JsonSerializer.Deserialize("{\"Kind\":\"dog\",\"CanBark\":true}", options); + + Assert.IsInstanceOf(dog); + Assert.IsTrue((dog as SelfDeclaredDog)?.CanBark == true); + } } } diff --git a/JsonSubTypes.Text.Json/JsonSubtypes.cs b/JsonSubTypes.Text.Json/JsonSubtypes.cs index 0aec7e4..5cea367 100644 --- a/JsonSubTypes.Text.Json/JsonSubtypes.cs +++ b/JsonSubTypes.Text.Json/JsonSubtypes.cs @@ -191,6 +191,27 @@ public override bool CanConvert(Type objectType) return objectType == typeof(T); } + /// + /// 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 + /// to without editing the plugin or + /// scanning an assembly. The last registration for a discriminator wins, like the builder. + /// + 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); diff --git a/JsonSubTypes.Text.Json/NullableDictionary.cs b/JsonSubTypes.Text.Json/NullableDictionary.cs index 281e548..5d44267 100644 --- a/JsonSubTypes.Text.Json/NullableDictionary.cs +++ b/JsonSubTypes.Text.Json/NullableDictionary.cs @@ -44,6 +44,19 @@ public void Add(TKey? key, TValue value) } } + public void Set(TKey? key, TValue value) + { + if (key is null) + { + _hasNullKey = true; + _nullKeyValue = value; + } + else + { + _dictionary[key] = value; + } + } + public IEnumerable NotNullKeys() { return _dictionary.Keys; From 0a878d20ef0456f08ba84142631041220500f5c7 Mon Sep 17 00:00:00 2001 From: manuc66 Date: Sat, 15 Aug 2026 21:00:47 +0200 Subject: [PATCH 5/5] Add self-declared subtypes by property presence via KnownSubTypeWithPropertyOf Mirror the KnownSubTypeOf work for property-presence discrimination. The subtype declares itself through [KnownSubTypeWithPropertyOf(typeof(Base), "Property")] and JsonSubtypesWithPropertyConverterBuilder.RegisterSubtypeAssembly scans the registered assembly for those declarations, mapping the property presence. Same ReflectionTypeLoadException handling as the value-based scan. --- .../SelfDeclaredDog.cs | 13 ++++++++ .../ReviewBugTests.cs | 19 +++++++++++ ...sonSubtypesWithPropertyConverterBuilder.cs | 32 +++++++++++++++++++ .../KnownSubTypeWithPropertyOfAttribute.cs | 17 ++++++++++ 4 files changed, 81 insertions(+) create mode 100644 JsonSubTypes.Text.Json/KnownSubTypeWithPropertyOfAttribute.cs diff --git a/JsonSubTypes.Text.Json.Tests.Plugin/SelfDeclaredDog.cs b/JsonSubTypes.Text.Json.Tests.Plugin/SelfDeclaredDog.cs index 35fc6f0..c10224c 100644 --- a/JsonSubTypes.Text.Json.Tests.Plugin/SelfDeclaredDog.cs +++ b/JsonSubTypes.Text.Json.Tests.Plugin/SelfDeclaredDog.cs @@ -25,4 +25,17 @@ 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; } + } } diff --git a/JsonSubTypes.Text.Json.Tests/ReviewBugTests.cs b/JsonSubTypes.Text.Json.Tests/ReviewBugTests.cs index a9f43f2..1dd5302 100644 --- a/JsonSubTypes.Text.Json.Tests/ReviewBugTests.cs +++ b/JsonSubTypes.Text.Json.Tests/ReviewBugTests.cs @@ -285,5 +285,24 @@ public void DynamicSubtypeRegisteredAtRuntime() Assert.IsInstanceOf(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() + .RegisterSubtypeAssembly(typeof(SelfDeclaredEmployee).Assembly) + .Build()); + + var employee = JsonSerializer.Deserialize( + "{\"FirstName\":\"A\",\"JobTitle\":\"Dev\"}", options); + + Assert.IsInstanceOf(employee); + Assert.AreEqual("Dev", (employee as SelfDeclaredEmployee)?.JobTitle); + } } } diff --git a/JsonSubTypes.Text.Json/JsonSubtypesWithPropertyConverterBuilder.cs b/JsonSubTypes.Text.Json/JsonSubtypesWithPropertyConverterBuilder.cs index c52a75d..c89ec93 100644 --- a/JsonSubTypes.Text.Json/JsonSubtypesWithPropertyConverterBuilder.cs +++ b/JsonSubTypes.Text.Json/JsonSubtypesWithPropertyConverterBuilder.cs @@ -58,6 +58,38 @@ public JsonSubtypesWithPropertyConverterBuilder SetFallbackSubtype() return SetFallbackSubtype(typeof(T)); } + /// + /// Adds an assembly whose self-declared subtypes are registered by property presence. Types + /// in the assembly that carry [KnownSubTypeWithPropertyOf(base, "Property")] are added + /// as subtypes of the base type, identified by the presence of that property in the JSON. + /// + public JsonSubtypesWithPropertyConverterBuilder RegisterSubtypeAssembly(Assembly assembly) + { + Type[] types; + try + { + types = assembly.GetTypes(); + } + catch (ReflectionTypeLoadException e) + { + // Skip unloadable types; a self-declared subtype must be loadable to be instantiated. + types = e.Types.Where(t => t != null).Cast().ToArray(); + } + + foreach (Type type in types) + { + foreach (KnownSubTypeWithPropertyOfAttribute attribute in type.GetCustomAttributes(inherit: false)) + { + if (attribute.BaseType == _baseType) + { + _types[attribute.PropertyName] = new TypeWithPropertyMatchingAttributes(type, attribute.PropertyName, false); + } + } + } + + 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() diff --git a/JsonSubTypes.Text.Json/KnownSubTypeWithPropertyOfAttribute.cs b/JsonSubTypes.Text.Json/KnownSubTypeWithPropertyOfAttribute.cs new file mode 100644 index 0000000..b55a010 --- /dev/null +++ b/JsonSubTypes.Text.Json/KnownSubTypeWithPropertyOfAttribute.cs @@ -0,0 +1,17 @@ +using System; + +namespace JsonSubTypes.Text.Json; + +/// +/// Declares the decorated type as a subtype of , identified by the +/// presence of in the JSON. Discovered by +/// when the +/// containing assembly is registered — the self-declaring plugin pattern for property-presence +/// discrimination. Mirrors from the subtype side. +/// +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Interface, AllowMultiple = true)] +public class KnownSubTypeWithPropertyOfAttribute(Type baseType, string propertyName) : Attribute +{ + public Type BaseType { get; } = baseType; + public string PropertyName { get; } = propertyName; +}