14

While migrating to .NET Core 3 I've switched from Newtonsoft.Json serialization to System.Text.Json.Serialization. Of all the features I want to continue using JsonPropertyName attribute.

Newtonsoft version allowed ordering of serialized attributes:

[JsonProperty(Order = 1)]
public bool Deleted { get; set; }

[JsonProperty(Order = 2)]
public DateTime DeletedDate { get; set; }

Is there a way to achieve the same in System.Text.Json.Serialization?

dbc
  • 104,963
  • 20
  • 228
  • 340
Sergey Nikitin
  • 845
  • 2
  • 13
  • 25
  • 1
    I don't know the answer but after having a look at the code, I'm guessing a more complex Comparer is required in JsonClassInfo.CreatePropertyCache() – Jaybird Sep 29 '19 at 05:56
  • 1
    It's only *the* JSON library for .NET Core 3 if you don't actually need the more advanced features of Json.NET. `System.Text.Json` is a solid performing basis for other frameworks and libraries and clients that don't need frills, but expecting every client scenario to map to it without (possibly extensive) additional effort is asking for too much. For application authors I wouldn't call Json.NET "legacy" just yet... TL;DR: there'll be a substitute if you write the code for it and submit a pull request, but probably no earlier than that. – Jeroen Mostert Sep 30 '19 at 10:42
  • @JeroenMostert that's not my opinion - that's the official opinion of Microsoft (especially important vs e.g. ASP.NET); being a drop-in replacement for Json.NET is one of their official goal, and they explicitly brag about it. I partially agree about the tl;dr part though - until someone reports a feature as missing, the implementation probably won't appear. It's a somewhat community-driven effort, for better or worse. –  Sep 30 '19 at 13:20
  • 3
    Being a drop-in goal for Json.NET for Microsoft's own scenarios is definitely a goal, but they also [state](https://github.com/dotnet/corefx/tree/master/src/System.Text.Json/roadmap) that performance will be prioritized over features, so things like (e.g.) mapping JSON to `dynamic` (another Json.NET feature) might not ever get a place in the base API and be relegated to extension packages. And if ASP.NET doesn't currently need the ability to write properties in order (which a conforming parser wouldn't need), that likewise will probably end up very low on the priority list, if at all. – Jeroen Mostert Sep 30 '19 at 13:47
  • 1
    The saving grace for this feature is that, since properties are semantically not ordered to begin with, actually adding an ordering should be relatively cheap, so implementing it is not likely to clash with any performance goals. – Jeroen Mostert Sep 30 '19 at 13:48
  • Currently there is no "out-of-the-box" support for this. – Svek Dec 06 '19 at 18:16
  • 1
    I have created an issue here: https://github.com/dotnet/corefx/issues/42787 – Jogge Dec 20 '19 at 10:09
  • 1
    An updated issue location: https://github.com/dotnet/runtime/issues/1085 – AndreyCh Feb 21 '20 at 12:00
  • 2
    This might be available in .NET 6.0: https://github.com/dotnet/runtime/issues/728 – tia Feb 09 '21 at 06:29
  • 1
    This is now supported by System.Text.Json using `JsonPropertyOrderAttribute`. See: [Is there a System.Text.Json's substitute for Json.NET's JsonProperty(Order)?](https://stackoverflow.com/q/58150005/3744182). – dbc Feb 08 '22 at 20:12

4 Answers4

10

It's supported in .Net 6 and greater using JsonPropertyOrderAttribute:

JsonPropertyOrderAttribute Class

Specifies the property order that is present in the JSON when serializing. Lower values are serialized first. If the attribute is not specified, the default value is 0.

If multiple properties have the same value, the ordering is undefined between them.

The attribute can be applied e.g. as follows:

[JsonPropertyOrder(order : 1)]
dbc
  • 104,963
  • 20
  • 228
  • 340
Murilo Maciel Curti
  • 2,677
  • 1
  • 21
  • 26
7

While this feature is not implemented in .NET Core, we can apply desired ordering by creating a custom JsonConverter. There are a few ways how that can be achievable. Below is the implementation I've came up with.

Explanation - the JsonPropertyOrderConverter handles the types having at least one property with a custom order value applied. For each of those types, it creates and caches a sorter function that converts an original object into an ExpandoObject with the properties set in a specific order. ExpandoObject maintains the order of properties, so it can be passed back to JsonSerializer for further serialization. The converter also respects JsonPropertyNameAttribute and JsonPropertyOrderAttribute attributes applied to serializing properties.

Please note that Sorter functions deal with PropertyInfo objects that can add some extra latency. If the performance is critical in your scenario, consider implementing Function<object, object> sorter based on Expression trees.

class Program
{
    static void Main(string[] args)
    {
        var test = new Test { Bar = 1, Baz = 2, Foo = 3 };

        // Add JsonPropertyOrderConverter to enable ordering
        var opts = new JsonSerializerOptions();
        opts.Converters.Add(new JsonPropertyOrderConverter());

        var serialized = JsonSerializer.Serialize(test, opts);

        // Outputs: {"Bar":1,"Baz":2,"Foo":3}
        Console.WriteLine(serialized);
    }
}

class Test
{
    [JsonPropertyOrder(1)]
    public int Foo { get; set; }

    [JsonPropertyOrder(-1)]
    public int Bar { get; set; }

    // Default order is 0
    public int Baz { get; set; }

}

/// <summary>
/// Sets a custom serialization order for a property.
/// The default value is 0.
/// </summary>
[AttributeUsage(AttributeTargets.Property, Inherited = true, AllowMultiple = false)]
sealed class JsonPropertyOrderAttribute : Attribute
{
    public int Order { get; }

    public JsonPropertyOrderAttribute(int order)
    {
        Order = order;
    }
}

/// <summary>
/// For Serialization only.
/// Emits properties in the specified order.
/// </summary>
class JsonPropertyOrderConverter : JsonConverter<object>
{
    delegate ExpandoObject SorterFunc(object value, bool ignoreNullValues);

    private static readonly ConcurrentDictionary<Type, SorterFunc> _sorters
        = new ConcurrentDictionary<Type, SorterFunc>();

    public override bool CanConvert(Type typeToConvert)
    {
        // Converter will not run if there is no custom order applied
        var sorter = _sorters.GetOrAdd(typeToConvert, CreateSorter);
        return sorter != null;
    }

    public override object Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        throw new NotSupportedException();
    }

    public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options)
    {
        // Resolve the sorter.
        // It must exist here (see CanConvert).
        var sorter = _sorters.GetOrAdd(value.GetType(), CreateSorter);

        // Convert value to an ExpandoObject
        // with a certain property order
        var sortedValue = sorter(value, options.IgnoreNullValues);

        // Serialize the ExpandoObject
        JsonSerializer.Serialize(writer, (IDictionary<string, object>)sortedValue, options);
    }

    private SorterFunc CreateSorter(Type type)
    {
        // Get type properties ordered according to JsonPropertyOrder value
        var sortedProperties = type
            .GetProperties(BindingFlags.Instance | BindingFlags.Public)
            .Where(x => x.GetCustomAttribute<JsonIgnoreAttribute>(true) == null)
            .Select(x => new
            {
                Info = x,
                Name = x.GetCustomAttribute<JsonPropertyNameAttribute>(true)?.Name ?? x.Name,
                Order = x.GetCustomAttribute<JsonPropertyOrderAttribute>(true)?.Order ?? 0,
                IsExtensionData = x.GetCustomAttribute<JsonExtensionDataAttribute>(true) != null
            })
            .OrderBy(x => x.Order)
            .ToList();

        // If all properties have the same order,
        // there is no sense in explicit sorting
        if (!sortedProperties.Any(x => x.Order != 0))
        {
            return null;
        }
        
        // Return a function assigning property values
        // to an ExpandoObject in a specified order
        return new SorterFunc((src, ignoreNullValues) =>
        {
            IDictionary<string, object> dst = new ExpandoObject();
           
            var isExtensionDataProcessed = false;

            foreach (var prop in sortedProperties)
            {
                var propValue = prop.Info.GetValue(src);

                if (prop.IsExtensionData)
                {
                    if (propValue is IDictionary extensionData)
                    {
                        if (isExtensionDataProcessed)
                        {
                            throw new InvalidOperationException($"The type '{src.GetType().FullName}' cannot have more than one property that has the attribute '{typeof(JsonExtensionDataAttribute).FullName}'.");
                        }

                        foreach (DictionaryEntry entry in extensionData)
                        {
                            dst.Add((string)entry.Key, entry.Value);
                        }
                    }
                    
                    isExtensionDataProcessed = true;
                }
                else if (!ignoreNullValues || !(propValue is null))
                {
                    dst.Add(prop.Name, propValue);
                }
            }

            return (ExpandoObject)dst;
        });
    }
}
AndreyCh
  • 1,298
  • 1
  • 14
  • 16
  • One case where this doesn't look like it works is with [JsonExtensionData] attributes, see https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json-how-to#handle-overflow-json for example: ``` [JsonExtensionData] public Dictionary ExtensionData { get; set; } ``` – Mike S Aug 04 '20 at 02:08
  • 1
    @MikeS thanks for pointing out to the `[JsonExtensionData]` attribute. The code has been updated to handle it too. – AndreyCh Aug 10 '20 at 15:16
  • Thank you. I recently started using this and quickly found that it has issues with contravariance. However, it's fairly simple to get around it by using this technique. https://github.com/dotnet/runtime/issues/1761#issuecomment-723647307 – dkm Mar 03 '22 at 20:36
  • It IS now implemented ref: https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/migrate-from-newtonsoft?pivots=dotnet-7-0 – Mark Schultheiss May 03 '23 at 20:52
3

I ended up having a 2-pass approach. First pass is my normal json serializer with all converters, pocos, etc. 2nd pass is a "normalizer" to deal with whitespace/indenting/property order/etc.

There are so many corner cases trying to do this with a converter in a single pass. Properties aren't just via reflection, they can be hidden in:

  1. Dictionaries
  2. [JsonExtensionData] attributes
  3. JSonElement
  4. other converters!

It's very challenging to write a converter that deals with all of these. So I went with the 2-pass approach. The 2nd pass just operates on JsonElement and a json writer, and so avoids all the corner cases.

(we're using this in production at: https://github.com/microsoft/PowerApps-Language-Tooling/blob/master/src/PAModel/Utility/JsonNormalizer.cs )


// Write out Json in a normalized sorted order. 
// Orders properties, whitespace/indenting, etc. 
internal class JsonNormalizer
{
    public static string Normalize(string jsonStr)
    {
        using (JsonDocument doc = JsonDocument.Parse(jsonStr))
        {
           return Normalize(doc.RootElement);
        } // free up array pool rent
    }

    public static string Normalize(JsonElement je)
    {
        var ms = new MemoryStream();
        JsonWriterOptions opts = new JsonWriterOptions
        {
            Indented = true,
            Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
        };
        using (var writer = new Utf8JsonWriter(ms, opts))
        {
            Write(je, writer);
        }

        var bytes = ms.ToArray();
        var str = Encoding.UTF8.GetString(bytes);
        return str;
    }

    private static void Write(JsonElement je, Utf8JsonWriter writer)
    {
        switch(je.ValueKind)
        {
            case JsonValueKind.Object:
                writer.WriteStartObject();

                // !!! This is where we can order the properties. 
                foreach (JsonProperty x in je.EnumerateObject().OrderBy(prop => prop.Name))
                {
                    writer.WritePropertyName(x.Name);
                    Write(x.Value, writer);
                }

                writer.WriteEndObject();
                break;

                // When normalizing... original msapp arrays can be in any order...
            case JsonValueKind.Array:
                writer.WriteStartArray();
                foreach(JsonElement x in je.EnumerateArray())
                {
                    Write(x, writer);
                }
                writer.WriteEndArray();
                break;

            case JsonValueKind.Number:
                writer.WriteNumberValue(je.GetDouble());
                break;

            case JsonValueKind.String:
                // Escape the string 
                writer.WriteStringValue(je.GetString());
                break;

            case JsonValueKind.Null:
                writer.WriteNullValue();
                break;
                
            case JsonValueKind.True:
                writer.WriteBooleanValue(true);
                break;

            case JsonValueKind.False:
                writer.WriteBooleanValue(false);
                break;                

            default:
                throw new NotImplementedException($"Kind: {je.ValueKind}");

        }
    }
}    
Mike S
  • 3,058
  • 1
  • 22
  • 12
1

I think the answers here all help with the 'issue'... Here's my custom solution that has been working for me.

JsonPropertyOrderAttribute spot in the @AndreyCh answer.
Adding here as well:

/// <summary>
/// Orders a property to be in a specific order when serailizing
/// </summary>
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public sealed class JsonPropertyOrderAttribute : JsonAttribute
{
   public JsonPropertyOrderAttribute(int order)
   {
      Order = order;
   }
    
   public int Order { get; }
}

But this is my converter... handling the 'reads' as well has allowed me to make it a 'global' converter in my JsonSerializerOptions.

public class JsonPropertyOrderConverter : JsonConverter<object>
{
    public override bool CanConvert(Type typeToConvert) =>
         typeToConvert.GetProperties().Any(x => x.GetCustomAttribute<JsonPropertyOrderAttribute>(true) != null);

    public override object Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        var newOptions = new JsonSerializerOptions(options);
        if (newOptions.Converters.Contains(this))
        {
            newOptions.Converters.Remove(this);
        }

        return JsonSerializer.Deserialize(ref reader, typeToConvert, newOptions);
    }

    public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options)
    {
        var orderedProperites = value.GetType().GetProperties()
            .Where(x => x.GetCustomAttribute<JsonIgnoreAttribute>(true) == null)
            .Select(x => new
            {
                Info = x,
                Order = x.GetCustomAttribute<JsonPropertyOrderAttribute>(true)?.Order ?? 0
            })
            .OrderBy(x => x.Order)
            .Select(x => x.Info);

        var work = new Dictionary<string, object>();
        foreach (var property in orderedProperites)
        {
            if (property.PropertyType.IsClass)
            {
                var propValue = property.GetValue(value, null);
                if (propValue == null && options.IgnoreNullValues)
                {
                    //do nothing
                }
                else
                {
                    var classObj = JsonSerializer.Deserialize<object>(JsonSerializer.Serialize(propValue, options));

                    var jsonPropertyName = property.GetCustomAttribute<JsonPropertyNameAttribute>(true)?.Name;
                    if (!string.IsNullOrEmpty(jsonPropertyName))
                        work[jsonPropertyName] = classObj;
                    else
                        work[options.PropertyNamingPolicy?.ConvertName(property.Name) ?? property.Name] = classObj;
                }
            }
            else
            {
                var propValue = property.GetValue(value, null);
                if (propValue == null && options.IgnoreNullValues)
                {
                    //do nothing
                }
                else
                {
                    var jsonPropertyName = property.GetCustomAttribute<JsonPropertyNameAttribute>(true)?.Name;
                    if (!string.IsNullOrEmpty(jsonPropertyName))
                        work[jsonPropertyName] = propValue;
                    else
                        work[options.PropertyNamingPolicy?.ConvertName(property.Name) ?? property.Name] = propValue;
                }
            }
        }

        var newValue = JsonSerializer.Deserialize<object>(JsonSerializer.Serialize(work));
        JsonSerializer.Serialize(writer, newValue, options);
    }
}