zola25
zola25

Reputation: 1931

Razor @Json.Serialize on individual properties is inconsistent in ASP.NET Core

Simply, I have a bare bones ASP.NET Core 3.1 MVC application that has a model with a custom JsonConverter attribute on one of the properties. If a view has @Json.Serialize with just the property as an input, the custom JsonConverter doesn't get called.

My Views/Home/Index.cshtml view:

@model JsonSerializer.MyModel
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>JsonSerializer</title>
    </head>
    <body>

        <div>
            Number is @Json.Serialize(Model.Number)
        </div>
        <div>
            ConvertedNumber is @Json.Serialize(Model.ConvertedNumber)
        </div>

    </body>
</html>

My controller, model & custom Json converter:

public class HomeController : Controller
{
    public IActionResult Index()
    {
        var model = new MyModel
        {
            Number = 1.234567,
            ConvertedNumber = 1.234567
        };
        return View("~/Views/Home/Index.cshtml", model);
    }
}

public class MyModel
{
    public double Number { get; set; }

    [JsonConverter(typeof(NumberConverter))]
    public double ConvertedNumber { get; set; }

}

public class NumberConverter : JsonConverter<double>
{
    public override void Write(Utf8JsonWriter writer, double value, JsonSerializerOptions options)
    {
        writer.WriteStringValue($"{value:F2}");
    }

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

This renders:

Number is 1.234567

ConvertedNumber is 1.234567

But if I add the NumberConverter to the global JSON options, it will work, but this will apply the customer converter to every attribute that has the double type:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllersWithViews().AddJsonOptions(c=>
    {
        c.JsonSerializerOptions.Converters.Add(new NumberConverter());
    });
}

This renders:

Number is "1.23"

ConvertedNumber is "1.23"

Isn't this a bit inconsistent or am I missing something? Either both the attribute and the global setting should call the custom converter, or neither should. I've checked and it is the same in .NET Core 2.2 which uses Newtonsoft Json to serialize.

You might ask what's the point serializing a single double property into JSON, but sometimes the property could be a more complex type like a list or another object for which you want custom JSON output for.

Upvotes: 2

Views: 2482

Answers (1)

itminus
itminus

Reputation: 25360

The Reason

Isn't this a bit inconsistent or am I missing something?

Actually that's an expected behavior of System.Text.JSON API. As you know, ASP.NET Core uses the new System.Text.Json by default as of 3.0. The new JsonConverter of System.Text.JSON differs quite a lot from Newtonsoft.JSON. As for this scenario, there's official docs covering this. See Converter registration precedence:

During serialization or deserialization, a converter is chosen for each JSON element in the following order, listed from highest priority to lowest:

  1. [JsonConverter] applied to a property.
  2. A converter added to the Converters collection.
  3. [JsonConverter] applied to a custom value type or POCO. If multiple custom converters for a type are registered in the Converters collection, the first converter that returns true for CanConvert is used.

A built-in converter is chosen only if no applicable custom converter is registered.)

Your converter won't be invoked because you're actually passing a @Model.ConvertedNumber property value (which is a double value) instead of @Model:

<div>
    ConvertedNumber is @Json.Serialize(Model.ConvertedNumber)
</div>

The expression @Model.ConvertedNumber is an expression that is evaluated to a double value, while @Model is an expression that is evaluated into a MyModel instance. Your above invocation actually calls Json.Serialize(a_double_value) behind the scenes. According to above priority, the whole process is :

  1. Razor finds there's a function invocation @Json.Serialize(Model.ConvertedNumber). As it is a function invocation, it must know the argument firstly.
  2. Evaluate the argument expression Model.ConvertedNumber and then get a double value
  3. Pass the double result into the Json.Serialize(a_double_value)
  4. Since the double type has no children property to be serialized, the rule 1 won't apply at all.
  5. Since there's no converters collection, the rule 2 doesn't apply
  6. Since the double type has no [JsonConverter] attribute, the rule 3 doesn't apply
  7. At last, the default built-in converter is chosen.

How to apply the JsonConverterAttribute in System.Text.Json?

According to above analysis, if you don't want to add it into the global converter collections, either pass a Model like

@Json.Serialize(Model)      // honor the converter because System.Text.Json knows that property has a `[JsonConverterAttribute]`

or pass an extra options manually:

@{
   var jsonSerializerOptions =new JsonSerializerOptions();
    jsonSerializerOptions.Converters.Add(a_dobule_converter);
}
@JsonSerializer.Serialize(Model.Date, jsonSerializerOptions)

Upvotes: 4

Related Questions