I'm building a library to be consumed by several applications. The library accesses a 3rd party API which returns JSON responses with very unhelpful field names. I'm trying to set up the model objects in the library so that different field names are used during serialisation and deserialisation.
Constraints:
System.Text.Json
Example JSON returned from the 3rd party API:
{
"FID": 0,
"CTRY22CD": "S92000003",
"CTRY22NM": "Scotland"
}
Example JSON I want when the consuming application serialises the model:
{
Id: 0,
CountryCode: "S92000003",
CountryName: "Scotland"
}
Example Model POCO representing these JSON structures:
using System;
using System.Text.Json.Serialization;
using MyLibrary.Model.Serialization;
namespace MyLibrary.Model;
[UseAsymmetricPropertyNames]
public readonly record struct Country
{
[JsonPropertyName("FID")]
public long Id { get; init; }
[JsonPropertyName("CTRY22CD")]
public required string CountryCode { get; init; }
[JsonPropertyName("CTRY22NM")]
public required string CountryName { get; init; }
}
The UseAsymmetricPropertyNames
Attribute on the class is a trivial attribute used to indicate that this model object should use this special serialisation / deserialisation semantics, it looks like this:
using System;
namespace MyLibrary.Model.Serialization;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = false)]
public sealed class UseAsymmetricPropertyNamesAttribute : Attribute
{
}
I have a JsonConverterFactory
, JsonConverter
and JsonNamingPolicy
defined as follows:
JsonConverterFactory
:
using System;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace MyLibrary.Model.Serialization;
public class AsymmetricJsonNamingConverterFactory : JsonConverterFactory
{
public override bool CanConvert(Type typeToConvert)
{
return typeToConvert.GetCustomAttribute<UseAsymmetricPropertyNamesAttribute>() != null;
}
public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options)
{
return (JsonConverter)Activator.CreateInstance(
typeof(AsymmetricJsonNamingConverter<>).MakeGenericType(typeToConvert),
new AsymmetricJsonNamingPolicy())!;
}
}
JsonConverter
:
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace MyLibrary.Model.Serialization;
public class AsymmetricJsonNamingConverter<T> : JsonConverter<T>
{
private readonly JsonNamingPolicy _namingPolicy;
public AsymmetricJsonNamingConverter(JsonNamingPolicy namingPolicy)
{
this._namingPolicy = namingPolicy;
}
public override T? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
// Deserialize using the default naming policy
return JsonSerializer.Deserialize<T>(ref reader, options);
}
public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
{
// Serialize using the provided naming policy
options.PropertyNamingPolicy = this._namingPolicy;
JsonSerializer.Serialize(writer, value, options);
}
}
JsonNamingPolicy
:
using System.Text.Json;
namespace MyLibrary.Model.Serialization;
public class AsymmetricJsonNamingPolicy : JsonNamingPolicy
{
public override string ConvertName(string name)
{
// Use the property name as is during serialization
return name;
}
}
To make setup in all consuming applications relatively trivial, I also have an extension method on IServiceCollection
which is used to set up dependency injection, validation and other requirements for the library, this also includes setup for JsonSerializerOptions
:
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using MyLibrary.Model.Serialization;
namespace MyLibrary.Extensions;
public static class IServiceCollectionMyLibraryExtensions
{
public static IServiceCollection AddMyLibraryConnector(this IServiceCollection services)
{
// ......
services.Configure<JsonSerializerOptions>((options) =>
{
options.Converters.Add(new AsymmetricJsonNamingConverterFactory());
options.WriteIndented = true;
});
// ......
return services;
}
}
The consuming application calls this extension method in ConfigureServices()
while building the application.
The intent is that model objects annotated UseAsymmetricPropertyNames
in the library should deserialise the 3rd party JSON using their property names, but the consuming applications should then see the property names from the model objects when later serialising.
What I'm seeing though is that the JsonConverterFactory
added to the JsonSerializerOptions
during the call to AddMyLibraryConnector()
is ignored.
I'm trying essentially to make the consuming applications blind to the 3rd party property names, and also to avoid the consuming application needing to pass a custom JsonSerializationOptions
object on every serialisation call.
Surely I can't be the first to have needed a relatively simple way to solve this problem? What magic am I missing to enable the library to set itself up to ensure that the consuming applications are blind to the 3rd party field names, taking into account the initial constraints (particularly the requirement to avoid creating duplicate model objects)?
Rather than a converter, you could use a typeInfo modifier to customize your type's contract when deserializing JSON from 3rd party API, by using a custom attribute to specify the name rather than the standard JsonPropertyNameAttribute
.
Following this approach, define the following attributes and typeinfo modifier:
[System.AttributeUsage(AttributeTargets.Property | AttributeTargets.Field, Inherited = true)]
public abstract class JsonAlternativeNameAttributeBase : System.Attribute
{
public JsonAlternativeNameAttributeBase(string? name) => this.Name = name;
public string? Name { get; private set; } // Use null to fall back to member name.
}
public sealed class Json3rdPartyAlternativeNameAttribute : JsonAlternativeNameAttributeBase
{
public Json3rdPartyAlternativeNameAttribute(string name) : base(name) {}
}
public static partial class JsonExtensions
{
public static Action<JsonTypeInfo> UseAternameNames<TAttribute>() where TAttribute : JsonAlternativeNameAttributeBase =>
static typeInfo =>
{
if (typeInfo.Kind != JsonTypeInfoKind.Object)
return;
foreach (var property in typeInfo.Properties)
{
if (property.AttributeProvider?.GetCustomAttributes(typeof(TAttribute), true) is {} list && list.Length > 0)
property.Name = list.OfType<TAttribute>().FirstOrDefault()?.Name ?? property.GetMemberName() ?? property.Name;
}
};
public static string? GetMemberName(this JsonPropertyInfo property) => (property.AttributeProvider as MemberInfo)?.Name;
}
Here JsonAlternativeNameAttributeBase
is a base class that, when derived from, can be used to specify an alternate name, and Json3rdPartyAlternativeNameAttribute
is a concrete implementation to use for this specific 3rd party API.
Next, modify your Country
struct as follows:
public readonly record struct Country
{
[Json3rdPartyAlternativeName("FID")]
public long Id { get; init; }
[Json3rdPartyAlternativeName("CTRY22CD")]
public required string CountryCode { get; init; }
[Json3rdPartyAlternativeName("CTRY22NM")]
public required string CountryName { get; init; }
}
Then finally deserialize and re-serialize using different options e.g.:
var inputJson =
"""
{
"FID": 0,
"CTRY22CD": "S92000003",
"CTRY22NM": "Scotland"
}
""";
// Deserialize using altername names.
var inputOptions = new JsonSerializerOptions
{
TypeInfoResolver = new DefaultJsonTypeInfoResolver
{
Modifiers = { JsonExtensions.UseAternameNames<Json3rdPartyAlternativeNameAttribute>() },
},
};
var model = JsonSerializer.Deserialize<Country>(inputJson, inputOptions);
// Re-serialize using standard names.
var outputOptions = new JsonSerializerOptions
{
// Use your standard options here, e.g.:
WriteIndented = true,
};
var outputJson = JsonSerializer.Serialize(model, outputOptions);
Which generates, as required:
{
"Id": 0,
"CountryCode": "S92000003",
"CountryName": "Scotland"
}
Notes:
The reason your AsymmetricJsonNamingConverter<T>
does not work is that you are trying to override [JsonPropertyName("FID")]
metadata by setting a PropertyNamingPolicy
, however the documentation states
The policy is not used for properties that have a JsonPropertyNameAttribute applied.
So that won't work. To override [JsonPropertyName]
you must use a customized contract, or read & write the properties yourself inside the converter (possibly using reflection).
Incidentally, inside JsonConverter<T>.Read()
or Write()
you should not modify the incoming options, in case they are being used in some other thread. Instead, clone them with the copy constructor and modify the copy.
Demo fiddle here.