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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
## Description

<!-- What does this PR do, and why? Link any related issues. -->

## Checklist

Please make sure the following points are addressed before the PR is reviewed:

- [ ] **Changelog**: an entry was added to the `[Unreleased]` section of `CHANGELOG.md` describing the change (bug fix, feature, breaking change), with the related issue number when applicable.
- [ ] **Tests**: the change is covered by tests, and the test suite passes.
- [ ] **Documentation**: README or XML docs were updated when the public API or behavior changed.
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### JsonSubTypes
#### Fixed
- Deserialization with an open generic base type (e.g. `Base<>`) now closes the generic subtype correctly (e.g. `Nested1<int>` for `Base<int>`) instead of failing. #177

## [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.

## [1.0.0-rc.1] - 2026-08-10
### Added
- New package `JsonSubTypes.Text.Json` (1.0.0-rc.1) bringing polymorphic subtype serialization to `System.Text.Json` (.NET 8+).
- New package `JsonSubTypes.Text.Json` bringing polymorphic subtype serialization to `System.Text.Json` (.NET 8+).
- Attribute-based and builder-based subtype registration.
- Discriminator mapping by property presence (`KnownSubTypeWithProperty`).
- Fallback subtype support (`FallBackSubType`).
Expand Down
156 changes: 156 additions & 0 deletions JsonSubTypes.Tests/GenericEdgeCaseTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
using System;
using Newtonsoft.Json;
using NUnit.Framework;

namespace JsonSubTypes.Tests
{
[TestFixture]
public class GenericEdgeCaseTests
{
public abstract class ArityBase<T>
{
public abstract string Kind { get; }
}

public class TwoParam<T, U> : ArityBase<T>
{
public override string Kind => "1";
}

[Test]
public void ArityMismatchThrowsCleanJsonSerializationException()
{
var settings = new JsonSerializerSettings();
settings.Converters.Add(JsonSubtypesConverterBuilder
.Of(typeof(ArityBase<>), "Kind")
.RegisterSubtype(typeof(TwoParam<,>), "1")
.Build());

var json = "{\"Kind\":\"1\"}";

var exception = Assert.Throws<JsonSerializationException>(
() => JsonConvert.DeserializeObject<ArityBase<int>>(json, settings));
Assert.That(exception.Message, Does.Contain("generic").IgnoreCase);
}

public interface IShape<T>
{
T Value { get; set; }
}

public abstract class ShapeBase<T> : IShape<T>
{
public T Value { get; set; }
public abstract string Kind { get; }
}

public class Circle<T> : ShapeBase<T>
{
public override string Kind => "circle";
}

public class UnrelatedShape<T> : IShape<T>
{
public T Value { get; set; }
}

[Test]
public void UnrelatedInterfaceImplementorIsNotClaimed()
{
var converter = JsonSubtypesConverterBuilder
.Of(typeof(IShape<>), "Kind")
.RegisterSubtype(typeof(Circle<>), "circle")
.Build();

Assert.IsFalse(converter.CanConvert(typeof(UnrelatedShape<int>)));
}

[Test]
public void UnrelatedInterfaceImplementorSerializesNormallyWithDiscriminator()
{
var settings = new JsonSerializerSettings();
settings.Converters.Add(JsonSubtypesConverterBuilder
.Of(typeof(IShape<>), "Kind")
.SerializeDiscriminatorProperty()
.RegisterSubtype(typeof(Circle<>), "circle")
.Build());

var json = JsonConvert.SerializeObject(new UnrelatedShape<int> { Value = 5 }, settings);

Assert.AreEqual("{\"Value\":5}", json);
}

public abstract class MultiBase<T>
{
public abstract string Kind { get; }
}

public abstract class MultiMid<T> : MultiBase<T>
{
}

public class MultiLeaf<T> : MultiMid<T>
{
public override string Kind => "leaf";
}

[Test]
public void MultiLevelGenericHierarchyDeserializes()
{
var settings = new JsonSerializerSettings();
settings.Converters.Add(JsonSubtypesConverterBuilder
.Of(typeof(MultiBase<>), "Kind")
.RegisterSubtype(typeof(MultiLeaf<>), "leaf")
.Build());

var result = JsonConvert.DeserializeObject<MultiBase<int>>("{\"Kind\":\"leaf\"}", settings);

Assert.IsInstanceOf<MultiLeaf<int>>(result);
}

public class BareBase<T>
{
}

public class BareOne<T> : BareBase<T>
{
}

[Test]
public void ExplicitClosedFormRegistrationTakesPrecedence()
{
var settings = new JsonSerializerSettings();
settings.Converters.Add(JsonSubtypesConverterBuilder
.Of(typeof(BareBase<>), "Kind")
.SerializeDiscriminatorProperty()
.RegisterSubtype(typeof(BareOne<>), "1")
.RegisterSubtype(typeof(BareOne<int>), "5")
.Build());

var jsonInt = JsonConvert.SerializeObject(new BareOne<int>(), settings);
StringAssert.Contains("\"Kind\":\"5\"", jsonInt);

var jsonString = JsonConvert.SerializeObject(new BareOne<string>(), settings);
StringAssert.Contains("\"Kind\":\"1\"", jsonString);
}

public class GenericFallback<T> : BareBase<T>
{
}

[Test]
public void GenericFallbackSubtypeIsClosed()
{
var settings = new JsonSerializerSettings();
settings.Converters.Add(JsonSubtypesConverterBuilder
.Of(typeof(BareBase<>), "Kind")
.RegisterSubtype(typeof(BareOne<>), "1")
.SetFallbackSubtype(typeof(GenericFallback<>))
.Build());

var result = JsonConvert.DeserializeObject<BareBase<int>>("{\"Kind\":\"zzz\"}", settings);

Assert.IsInstanceOf<GenericFallback<int>>(result);
}
}
}
76 changes: 75 additions & 1 deletion JsonSubTypes.Tests/GenericTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
using NUnit.Framework;

namespace JsonSubTypes.Tests
{[JsonConverter(typeof(JsonSubtypes), "Type")]
{
[JsonConverter(typeof(JsonSubtypes), "Type")]
[JsonSubtypes.KnownSubType(typeof(Some<>), "Some")]
public interface IResult
{
Expand Down Expand Up @@ -49,4 +50,77 @@ public void DeserializingSubTypeWithDateParsesCorrectly()
Console.WriteLine(result);
}
}



[TestFixture]
public class GenericBaseTests
{
interface IShape<T>
{
T Value { get; set; }

string Kind { get; }
}

abstract class ShapeBase<T> : IShape<T>
{
public T Value { get; set; }

public abstract string Kind { get; }
}

class Square<T> : ShapeBase<T>
{
public override string Kind => "square";
}

class Circle<T> : ShapeBase<T>
{
public override string Kind => "circle";
}

[Test]
public void Deserialize_BaseConcreteSubtype_WithJsonSubtypes_OnAbstractBase_ReturnsSquare()
{
var settings = new JsonSerializerSettings();
settings.Converters.Add(JsonSubtypesConverterBuilder
.Of(typeof(ShapeBase<>), "Kind")
.RegisterSubtype(typeof(Square<>), "square")
.RegisterSubtype(typeof(Circle<>), "circle")
.Build());

var json = JsonConvert.SerializeObject(new Square<int>
{
Value = 42,
}, settings);

var shape = JsonConvert.DeserializeObject<ShapeBase<int>>(json, settings);

Assert.IsInstanceOf<Square<int>>(shape);
Assert.AreEqual(42, shape.Value);
}

[Test]
public void Deserialize_InterfaceConcreteSubtype_WithJsonSubtypes_OnInterface_ReturnsSquare()
{
var settings = new JsonSerializerSettings();
settings.Converters.Add(JsonSubtypesConverterBuilder
.Of(typeof(IShape<>), "Kind")
.RegisterSubtype(typeof(Square<>), "square")
.RegisterSubtype(typeof(Circle<>), "circle")
.Build());

var json = JsonConvert.SerializeObject(new Square<int>
{
Value = 42,
}, settings);

var shape = JsonConvert.DeserializeObject<IShape<int>>(json, settings);

Assert.IsInstanceOf<Square<int>>(shape);
Assert.AreEqual(42, shape.Value);
}
}

}
22 changes: 22 additions & 0 deletions JsonSubTypes/JsonSubtypes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,19 @@ private Type GetType(JObject jObject, Type parentType, JsonSerializer serializer
currentTypeResolver = GetTypeResolver(ToTypeInfo(targetType), jsonConverterCollection);
}

if (targetType != null && ToTypeInfo(targetType).IsGenericTypeDefinition && !ToTypeInfo(parentType).IsGenericTypeDefinition)
{
Type[] parentTypeArguments = GetGenericTypeArguments(parentType).ToArray();
if (GetGenericTypeParameterCount(targetType) != parentTypeArguments.Length)
{
throw new JsonSerializationException(
"Could not close generic subtype " + targetType.FullName + " with the generic arguments of " +
parentType.FullName + ": different number of generic parameters.");
}

return ToTypeInfo(targetType).MakeGenericType(parentTypeArguments);
}

return targetType;
}

Expand Down Expand Up @@ -523,6 +536,15 @@ private static IEnumerable<Type> GetGenericTypeArguments(Type type)
#endif
}

private static int GetGenericTypeParameterCount(Type type)
{
#if (NETSTANDARD1_3)
return type.GetTypeInfo().GenericTypeParameters.Length;
#else
return type.GetGenericArguments().Length;
#endif
}

internal static TypeInfo ToTypeInfo(Type type)
{
#if (!NETSTANDARD1_3)
Expand Down
31 changes: 29 additions & 2 deletions JsonSubTypes/JsonSubtypesByDiscriminatorValueConverter.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

Expand Down Expand Up @@ -69,7 +70,25 @@ internal override NullableDictionary<object, Type> GetSubTypeMapping(Type type)

public override bool CanConvert(Type objectType)
{
return base.CanConvert(objectType) || _supportedTypes.ContainsKey(objectType);
if (base.CanConvert(objectType) || _supportedTypes.ContainsKey(objectType))
{
return true;
}

// Claim closed forms of registered generic subtypes only, e.g.
// Nested1<int> when Nested1<> is registered. Matching against the
// base type would also claim unrelated types that merely implement
// the generic base interface.
foreach (Type registeredType in _supportedTypes.Keys)
{
if (ToTypeInfo(registeredType).IsGenericTypeDefinition &&
IsClosedGenericFormOf(objectType, registeredType))
{
return true;
}
}

return false;
}

public override bool CanWrite
Expand Down Expand Up @@ -109,7 +128,15 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s

if (!_supportedTypes.TryGetValue(value.GetType(), out object supportedType))
{
throw new JsonSerializationException("Impossible to serialize type: " + value.GetType().FullName + " because there is no registered mapping for the discriminator property");
var matchingGenericSupportedType = _supportedTypes.Keys
.FirstOrDefault(x => IsClosedGenericFormOf(value.GetType(), x));

if (matchingGenericSupportedType == null ||
!_supportedTypes.TryGetValue(matchingGenericSupportedType, out supportedType))
{
throw new JsonSerializationException("Impossible to serialize type: " + value.GetType().FullName +
" because there is no registered mapping for the discriminator property");
}
}
JToken typeMappingPropertyValue = JToken.FromObject(supportedType, serializer);
if (_addDiscriminatorFirst)
Expand Down
Loading
Loading