In .NET 7 and later Microsoft has added the ability to programmatically customize the serialization contract that System.Text.Json creates for each .NET type. Using this API you can add a typeInfo modifier to serialize selected (or all) private properties of selected types.
E.g., you might want to:
Serialize all private properties marked with some custom attribute.
Serialize all private properties of a specific type.
Serialize a specific private property of a specific type by name.
Given these requirements, define the following attribute and modifiers:
[System.AttributeUsage(System.AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
public class JsonIncludePrivatePropertyAttribute : System.Attribute { }
public static partial class JsonExtensions
{
public static Action<JsonTypeInfo> AddPrivateProperties<TAttribute>() where TAttribute : System.Attribute => typeInfo =>
{
if (typeInfo.Kind != JsonTypeInfoKind.Object)
return;
foreach (var type in typeInfo.Type.BaseTypesAndSelf().TakeWhile(b => b != typeof(object)))
AddPrivateProperties(typeInfo, type, p => Attribute.IsDefined(p, typeof(TAttribute)));
};
public static Action<JsonTypeInfo> AddPrivateProperties(Type declaredType) => typeInfo =>
AddPrivateProperties(typeInfo, declaredType, p => true);
public static Action<JsonTypeInfo> AddPrivateProperty(Type declaredType, string propertyName) => typeInfo =>
{
if (typeInfo.Kind != JsonTypeInfoKind.Object || !declaredType.IsAssignableFrom(typeInfo.Type))
return;
var propertyInfo = declaredType.GetProperty(propertyName, BindingFlags.Instance | BindingFlags.NonPublic);
if (propertyInfo == null)
throw new ArgumentException(string.Format("Private roperty {0} not found in type {1}", propertyName, declaredType));
if (typeInfo.Properties.Any(p => p.GetMemberInfo() == propertyInfo))
return;
AddProperty(typeInfo, propertyInfo);
};
static void AddPrivateProperties(JsonTypeInfo typeInfo, Type declaredType, Func<PropertyInfo, bool> filter)
{
if (typeInfo.Kind != JsonTypeInfoKind.Object || !declaredType.IsAssignableFrom(typeInfo.Type))
return;
var propertyInfos = declaredType.GetProperties(BindingFlags.Instance | BindingFlags.NonPublic);
foreach (var propertyInfo in propertyInfos.Where(p => p.GetIndexParameters().Length == 0 && filter(p)))
AddProperty(typeInfo, propertyInfo);
}
static void AddProperty(JsonTypeInfo typeInfo, PropertyInfo propertyInfo)
{
if (propertyInfo.GetIndexParameters().Length > 0)
throw new ArgumentException("Indexed properties are not supported.");
var ignore = propertyInfo.GetCustomAttribute<JsonIgnoreAttribute>();
if (ignore?.Condition == JsonIgnoreCondition.Always)
return;
var name = propertyInfo.GetCustomAttribute<JsonPropertyNameAttribute>()?.Name
?? typeInfo.Options?.PropertyNamingPolicy?.ConvertName(propertyInfo.Name)
?? propertyInfo.Name;
var property = typeInfo.CreateJsonPropertyInfo(propertyInfo.PropertyType, name);
property.Get = CreateGetter(typeInfo.Type, propertyInfo.GetGetMethod(true));
property.Set = CreateSetter(typeInfo.Type, propertyInfo.GetSetMethod(true));
property.AttributeProvider = propertyInfo;
property.CustomConverter = propertyInfo.GetCustomAttribute<JsonConverterAttribute>()?.ConverterType is {} converterType
? (JsonConverter?)Activator.CreateInstance(converterType)
: null;
// TODO: handle ignore?.Condition == JsonIgnoreCondition.Never, WhenWritingDefault, or WhenWritingNull by setting property.ShouldSerialize appropriately
// TODO: handle JsonRequiredAttribute, JsonNumberHandlingAttribute
typeInfo.Properties.Add(property);
}
delegate TValue RefFunc<TObject, TValue>(ref TObject arg);
static Func<object, object?>? CreateGetter(Type type, MethodInfo? method)
{
if (method == null)
return null;
var myMethod = typeof(JsonExtensions).GetMethod(nameof(JsonExtensions.CreateGetterGeneric), BindingFlags.NonPublic | BindingFlags.Static)!;
return (Func<object, object?>)(myMethod.MakeGenericMethod(new[] { type, method.ReturnType }).Invoke(null, new[] { method })!);
}
static Func<object, object?> CreateGetterGeneric<TObject, TValue>(MethodInfo method)
{
if (method == null)
throw new ArgumentNullException();
if(typeof(TObject).IsValueType)
{
// https://mcmap.net/q/753535/-how-can-i-create-an-open-delegate-from-a-struct-39-s-instance-method
// https://mcmap.net/q/753536/-quot-uncurrying-quot-an-instance-method-in-net/1212396#1212396
var func = (RefFunc<TObject, TValue>)Delegate.CreateDelegate(typeof(RefFunc<TObject, TValue>), null, method);
return (o) => {var tObj = (TObject)o; return func(ref tObj); };
}
else
{
var func = (Func<TObject, TValue>)Delegate.CreateDelegate(typeof(Func<TObject, TValue>), method);
return (o) => func((TObject)o);
}
}
static Action<object,object?>? CreateSetter(Type type, MethodInfo? method)
{
if (method == null)
return null;
var myMethod = typeof(JsonExtensions).GetMethod(nameof(JsonExtensions.CreateSetterGeneric), BindingFlags.NonPublic | BindingFlags.Static)!;
return (Action<object,object?>)(myMethod.MakeGenericMethod(new [] { type, method.GetParameters().Single().ParameterType }).Invoke(null, new[] { method })!);
}
static Action<object,object?>? CreateSetterGeneric<TObject, TValue>(MethodInfo method)
{
if (method == null)
throw new ArgumentNullException();
if (typeof(TObject).IsValueType)
{
// TODO: find a performant way to do this. Possibilities:
// Box<T> from Microsoft.Toolkit.HighPerformance
// https://mcmap.net/q/510889/-how-to-mutate-a-boxed-struct-using-il
return (o, v) => method.Invoke(o, new [] { v });
}
else
{
var func = (Action<TObject, TValue?>)Delegate.CreateDelegate(typeof(Action<TObject, TValue?>), method);
return (o, v) => func((TObject)o, (TValue?)v);
}
}
static MemberInfo? GetMemberInfo(this JsonPropertyInfo property) => (property.AttributeProvider as MemberInfo);
static IEnumerable<Type> BaseTypesAndSelf(this Type? type)
{
while (type != null)
{
yield return type;
type = type.BaseType;
}
}
}
Then, if your model looks like e.g.:
public partial class Model
{
List<int> PrivateList { get; set; } = new();
[JsonIgnore] // For testing purposes only
public List<int> SurrogateList { get => PrivateList; set => PrivateList = value; }
}
Then you could mark PrivateList
with [JsonIncludePrivateProperty]
:
public partial class Model
{
[JsonIncludePrivateProperty]
List<int> PrivateList { get; set; } = new();
And serialize using the following options:
var options = new JsonSerializerOptions
{
TypeInfoResolver = new DefaultJsonTypeInfoResolver
{
Modifiers = { JsonExtensions.AddPrivateProperties<JsonIncludePrivatePropertyAttribute>() },
},
};
Or if you can't change your model, you could include all its private properties as follows:
var options = new JsonSerializerOptions
{
TypeInfoResolver = new DefaultJsonTypeInfoResolver
{
Modifiers = { JsonExtensions.AddPrivateProperties(typeof(Model)) },
},
};
Or just the property named PrivateList
as follows:
var options = new JsonSerializerOptions
{
TypeInfoResolver = new DefaultJsonTypeInfoResolver
{
Modifiers = { JsonExtensions.AddPrivateProperty(typeof(Model), "PrivateList") },
},
};
With any of the above options, the JSON generated will be e.g. {"PrivateList":[1,2,3]}
.
Notes:
Automatically serializing all private properties of all types is not recommended, but if you need to do it for some reason, use the following modifier:
public static Action<JsonTypeInfo> AddPrivateProperties() => typeInfo =>
{
if (typeInfo.Kind != JsonTypeInfoKind.Object)
return;
foreach (var type in typeInfo.Type.BaseTypesAndSelf().TakeWhile(b => b != typeof(object)))
AddPrivateProperties(typeInfo, type, p => true);
};
As of .NET 7 there is no access to System.Text.Json's constructor metadata, so there does not seem to be a way to serialize a private property and have it deserialized as a constructor parameter.
For a typeInfo modifer that causes private fields to be serialized, see the documentation example Customize a JSON contract: Example: Serialize private fields.
It is possible to have private properties with the same name in base and derived types. If you try to serialize the private properties of both you may get an exception
System.InvalidOperationException: The JSON property name for 'Type.PropertyName' collides with another property.
If this happens, you will map one of the properties to a different name, e.g. by adding [JsonPropertyName("SomeAlternateName")]
to one of them.
Demo fiddle here.
System.Text.Json
doesn't support internal and private getters and setters out of the box – Javed