7

I am writing a Cmdlet and need to pass object structures into an API client that may contain PSObjects. Currently, these serialise as a JSON string containing CLIXML. Instead, I need it to be treated like an object (including the NoteProperties in PSObject.Properties as properties, and recursively serialising their values).

I tried writing my own JsonConverter but for some reason it only gets called for the top level object, not for nested PSObjects:

public class PSObjectJsonConverter : JsonConverter {

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) {
        if (value is PSObject) {
            JObject obj = new JObject();
            foreach (var prop in ((PSObject)value).Properties) {
                obj.Add(new JProperty(prop.Name, value));
            }
            obj.WriteTo(writer);
        } else {
            JToken token = JToken.FromObject(value);
            token.WriteTo(writer);
        }
    }

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

    public override bool CanRead {
        get { return false; }
    }

    public override bool CanConvert(Type objectType) {
        return true;
    }
}

Additionally, I am using serializing to camel case using CamelCasePropertyNamesContractResolver. Is there a way to make the converter respect that?

dbc
  • 104,963
  • 20
  • 228
  • 340
felixfbecker
  • 2,273
  • 1
  • 19
  • 24

1 Answers1

7

The following converter should correctly serialize recursively nested objects of type PSObject:

public class PSObjectJsonConverter : JsonConverter
{
    public override bool CanConvert(Type objectType)
    {
        return typeof(PSObject).IsAssignableFrom(objectType);
    }

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        var psObj = (PSObject)value;
        writer.WriteStartObject();
        foreach (var prop in psObj.Properties)
        {
            //Probably we shouldn't try to serialize a property that can't be read.
            //https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.pspropertyinfo.isgettable?view=powershellsdk-1.1.0#System_Management_Automation_PSPropertyInfo_IsGettable
            if (!prop.IsGettable)
                continue;           
            writer.WritePropertyName(prop.Name);
            serializer.Serialize(writer, prop.Value);
        }
        writer.WriteEndObject();
    }

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

    public override bool CanRead { get { return false; } }
}

Notes:

  • In WriteJson you serialize the incoming object value as the value of each property. Surely you meant prop.Value.

  • By only returning true from CanConvert() when the incoming object type is of type PSObject, you avoid the need to implement default serialization for non-PSObject types in WriteJson().

  • When you call JToken.FromObject(value) you are not using the incoming JsonSerializer serializer. Thus, any JsonSerializerSettings (including converters) will be lost. In theory you could use JToken.FromObject(Object, JsonSerializer) instead, which would preserve settings, but if you did, you would encounter the bug described in JSON.Net throws StackOverflowException when using [JsonConvert()]. Luckily, since we now return false from CanConvert when default serialization is required, this is no longer necessary.

  • There is no need to construct an intermediate JObject. You can write directly to the JsonWriter, which will be somewhat more performant.

Update: Additionally, I am using serializing to camel case using CamelCasePropertyNamesContractResolver. Is there a way to make the converter respect that?

Once you introduce a custom JsonConverter for your type, you need to do everything manually, including remapping of property names. Here's a version of WriteJson() that handles this by using DefaultContractResolver.NamingStrategy:

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        var psObj = (PSObject)value;
        writer.WriteStartObject();
        var resolver = serializer.ContractResolver as DefaultContractResolver;
        var strategy = (resolver == null ? null : resolver.NamingStrategy) ?? new DefaultNamingStrategy();

        foreach (var prop in psObj.Properties)
        {
            //Probably we shouldn't try to serialize a property that can't be read.
            //https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.pspropertyinfo.isgettable?view=powershellsdk-1.1.0#System_Management_Automation_PSPropertyInfo_IsGettable
            if (!prop.IsGettable)
                continue;
            writer.WritePropertyName(strategy.GetPropertyName(prop.Name, false));
            serializer.Serialize(writer, prop.Value);
        }
        writer.WriteEndObject();
    }

Note that naming strategies were introduced in Json.NET 9.0.1 so if you are using an earlier version you will need to create your own camel case name mapper such as the one shown in this answer.

dbc
  • 104,963
  • 20
  • 228
  • 340
  • Note - tried to test here but failed due to `SecurityException` exceptions: https://dotnetfiddle.net/AJokws – dbc Sep 01 '18 at 17:33
  • Thank you so much, that works indeed! Except one thing, it doesn't seem to respect `CamelCasePropertyNamesContractResolver` for the property keys. Is there a way to make it respect that? – felixfbecker Sep 01 '18 at 22:18
  • Very great answer! It absolutely helps. – rios0rios0 Jul 24 '21 at 00:54