4

I have a need to format the output json of a decimal to a currency, with the culture specified my the object I am serializing, the object could be nested so I cannot preset the option in the serializer. The current way I am doing this is by using extra string properties that format the output.

[JsonIgnore]
public decimal Cost {get;set;}

[JsonIgnore]
public CultureInfo Culture {get;set;}

public string AsCurrency(decimal value) {
  return string.Format(this.Culture, "{0:c}", value);
}

[JsonProperty("FormattedCost")]
public string FormatedCost {
  get { return this.AsCurrency(this.Cost); }
}

I have alot of properties to deal with, I'm not bothered about Deserializing, the JsonObject is used by a different language to populated a PDF and so I want the string values.

Ideally I'd like a JsonConverter so I can just do

[JsonProperty("FormattedCost")]
[JsonConverter(typeof(MyCurrencyConverter))]
public decimal Cost {get;set;}

The issue I have is how to access the Culture property of the containing object in the converter.

public class MyCurrencyConverter : JsonConverter
{
    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
       var culture = // How do I get the Culture from the parent object?
       writer.WriteValue(string.format(culture, "{0:c}", (decimal)value);

    }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
    {
        throw new NotImplementedException();
    }

    public override bool CanConvert(Type objectType)
    {
        return typeof(decimal) == objectType;
    }
}

As Requested sample JSON.

for an array of Contract classes that each have a Cost and an Culture.

[{ FormattedCost : "£5000.00"}, { FormattedCost : "$8000.00"}, { FormattedCost : "€599.00"}]

The actual objects are a lot more complicated, multiple fields with nested Assets that would have their own figures. Additionally not all decimals would be currencies.

I don't really want to have to write a custom serializer for the Contract itself as I would then have to modify it each time the properties change.

The ideal solution is being able to tag certain decimal properties with the converter attribute so it can handle it.

The other way I was thinking of going was to make a custom class for the decimal properties with an implicit conversion from decimal, however that gets more complicated as some properties are calculated properties based on previous results.

WORKAROUND

I have a work-around for my use case, but it uses reflection to obtain a private variable in the serializer.

var binding = BindingFlags.NonPublic | BindingFlags.Instance;
var writer = serializer.GetType()
                       .GetMethod("GetInternalSerializer", binding)
                       ?.Invoke(serializer, null);
var parent = writer?.GetType()
                   .GetField("_serializeStack", binding)
                   ?.GetValue(writer) is List<object> stack 
                        && stack.Count > 1 ? stack[stack.Count - 2] as MyType: null;

In my tested use cases this gives me the parent object, but it's not using the public API.

dbc
  • 104,963
  • 20
  • 228
  • 340
Bob Vale
  • 18,094
  • 1
  • 42
  • 49
  • Have you tried writing `MyCurrencyConverter`? (PS your first code block doesn't compile) – DavidG Dec 18 '17 at 10:27
  • @DavidG Yes but the problem is inside writeJson I cannot figure out how to access the parent object and it's properties. – Bob Vale Dec 18 '17 at 10:40
  • Give us what you have so far and then we can help. – DavidG Dec 18 '17 at 10:40
  • So there can be multiple nested objects, each with its own culture? – Evk Dec 18 '17 at 10:45
  • Perhaps you could supply some example JSON too? – DavidG Dec 18 '17 at 10:46
  • Anyway, I do not think it's possible to access "parent" object in json converter – Evk Dec 18 '17 at 10:55
  • Your example JSON doesn't have any culture details in it, we would need to see a more complete, workable sample. – DavidG Dec 18 '17 at 10:58
  • @DavidG OP wants to serialize .NET object to json. .NET object contains culture details (`Culture` property in question). JSON does not contain culture details, they are not serialized. JSON only contains formatted (according to that culture) currency value. – Evk Dec 18 '17 at 11:14
  • @Evk D'oh, of course. So yeah, this isn't possible. Though you could write a converter for the entire class I guess, but that's a lot more work. – DavidG Dec 18 '17 at 11:35
  • @DavidG I've found a reflection based work-around, otherwise I'm stumped. I've raised it as an issue on the github. – Bob Vale Dec 18 '17 at 15:40
  • There's no "issue" with the library though, a JsonConverter works on a type, not an entire class. Like I said above, you would have to create your own converter for your entire class. – DavidG Dec 18 '17 at 15:43
  • @DavidG, Whilst technically it's an enhancement/feature request, it's still something that the library could provide a solution for, allowing the converter to use context when serializing, providing a more powerful and richer serialization experience. – Bob Vale Dec 18 '17 at 15:49
  • But the solution is to move the converter to a higher place. – DavidG Dec 18 '17 at 15:50
  • @DavidG But that solution is inelegant because you have to re-implement the base JsonConverter to handle picking which properties to serialize and is messy when you get multiple classes needing this functionality. Accessing the serialization object tree allow you to write simple DTO objects that have attributes defining their serialization behaviour. Also what if the culture property is in an object 3 levels up. – Bob Vale Dec 18 '17 at 15:55
  • It may not be as elegant, but there's I'd be very surprised if there was a way for the current method to be modified to work. It's conceptually very different. – DavidG Dec 18 '17 at 15:56
  • @DavidG it just needs a private variable exposing, I can do it using reflection and it works. – Bob Vale Dec 18 '17 at 16:03

1 Answers1

5

What you want to do is to intercept and modify the value of a specific property of an object as it is being serialized while using default serialization for all other properties. This can be done with a custom ContractResolver that replaces the ValueProvider of the property in question when a specific attribute is applied.

First, define the following attribute and contract resolver:

[System.AttributeUsage(System.AttributeTargets.Property | System.AttributeTargets.Field, AllowMultiple = false)]
public class JsonFormatAttribute : System.Attribute
{
    public JsonFormatAttribute(string formattingString)
    {
        this.FormattingString = formattingString;
    }

    /// <summary>
    /// The format string to pass to string.Format()
    /// </summary>
    public string FormattingString { get; set; }

    /// <summary>
    /// The name of the underlying property that returns the object's culture, or NULL if not applicable.
    /// </summary>
    public string CulturePropertyName { get; set; }
}

public class FormattedPropertyContractResolver : DefaultContractResolver
{
    protected override IList<JsonProperty> CreateProperties(Type type, MemberSerialization memberSerialization)
    {
        return base.CreateProperties(type, memberSerialization)
            .AddFormatting();
    }
}

public static class JsonContractExtensions
{
    class FormattedValueProvider : IValueProvider
    {
        readonly IValueProvider baseProvider;
        readonly string formatString;
        readonly IValueProvider cultureValueProvider;

        public FormattedValueProvider(IValueProvider baseProvider, string formatString, IValueProvider cultureValueProvider)
        {
            this.baseProvider = baseProvider;
            this.formatString = formatString;
            this.cultureValueProvider = cultureValueProvider;
        }

        #region IValueProvider Members

        public object GetValue(object target)
        {
            var value = baseProvider.GetValue(target);
            var culture = cultureValueProvider == null ? null : (CultureInfo)cultureValueProvider.GetValue(target);
            return string.Format(culture ?? CultureInfo.InvariantCulture, formatString, value);
        }

        public void SetValue(object target, object value)
        {
            // This contract resolver should only be used for serialization, not deserialization, so throw an exception.
            throw new NotImplementedException();
        }

        #endregion
    }

    public static IList<JsonProperty> AddFormatting(this IList<JsonProperty> properties)
    {
        ILookup<string, JsonProperty> lookup = null;

        foreach (var jsonProperty in properties)
        {
            var attr = (JsonFormatAttribute)jsonProperty.AttributeProvider.GetAttributes(typeof(JsonFormatAttribute), false).SingleOrDefault();
            if (attr != null)
            {
                IValueProvider cultureValueProvider = null;
                if (attr.CulturePropertyName != null)
                {
                    if (lookup == null)
                        lookup = properties.ToLookup(p => p.UnderlyingName);
                    var cultureProperty = lookup[attr.CulturePropertyName].FirstOrDefault();
                    if (cultureProperty != null)
                        cultureValueProvider = cultureProperty.ValueProvider;
                }
                jsonProperty.ValueProvider = new FormattedValueProvider(jsonProperty.ValueProvider, attr.FormattingString, cultureValueProvider);
                jsonProperty.PropertyType = typeof(string);
            }
        }
        return properties;
    }
}

Next, define your object as follows:

public class RootObject
{
    [JsonFormat("{0:c}", CulturePropertyName = nameof(Culture))]
    public decimal Cost { get; set; }

    [JsonIgnore]
    public CultureInfo Culture { get; set; }

    public string SomeValue { get; set; }

    public string SomeOtherValue { get; set; }
}

Finally, serialize as follows:

var settings = new JsonSerializerSettings
{
    ContractResolver = new FormattedPropertyContractResolver
    {
        NamingStrategy = new CamelCaseNamingStrategy(),
    },
};
var json = JsonConvert.SerializeObject(root, Formatting.Indented, settings);

Notes:

  1. Since you are not serializing the culture name, I can't see any way to deserialize the Cost property. Thus I threw an exception from the SetValue method.

    (And, even if you were serializing the culture name, since a JSON object is an unordered set of name/value pairs according the standard, there's no way to guarantee the culture name appears before the cost in the JSON being deserialized. This may be related to why Newtonsoft does not provide access to the parent stack. During deserialization there's no guarantee that required properties in the parent hierarchy have been read - or even that the parents have been constructed.)

  2. If you have to apply several different customization rules to your contracts, consider using ConfigurableContractResolver from How to add metadata to describe which properties are dates in JSON.Net.

  3. You may want to cache the contract resolver for best performance.

  4. Another approach would be to add a converter to the parent object that generates a default serialization to JObject by disabling itself temporarily, tweaks the returned JObject, then writes that out. For examples of this approach see JSON.Net throws StackOverflowException when using [JsonConvert()] or Can I serialize nested properties to my class in one operation with Json.net?.

  5. In comments you write, Inside WriteJson I cannot figure out how to access the parent object and it's properties. It should be possible to do this with a custom IValueProvider that returns a Tuple or similar class containing the parent and the value, which would be used in concert with a specific JsonConverter that expects such input. Not sure I'd recommend this though since it's extremely tricky.

Working sample .Net fiddle.

dbc
  • 104,963
  • 20
  • 228
  • 340
  • Brilliant, thankyou, this solves my problem, I adapted this for my specific use case by making use of the `DataTypeAttribute` instead. I've accepted this answer because it answers the question I asked, I've now got to figure out a way of dealing with Culture being in one class higher up but I know know the correct road to go down. – Bob Vale Dec 18 '17 at 17:25