Skip to content

Add gRPC JSON transcoding option for case insensitive field names #62868

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jul 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,25 +11,41 @@ public sealed class GrpcJsonSettings
/// <summary>
/// Gets or sets a value that indicates whether fields with default values are ignored during serialization.
/// This setting only affects fields which don't support "presence", such as singular non-optional proto3 primitive fields.
/// Default value is false.
/// Default value is <see langword="false"/>.
/// </summary>
public bool IgnoreDefaultValues { get; set; }

/// <summary>
/// Gets or sets a value that indicates whether <see cref="Enum"/> values are written as integers instead of strings.
/// Default value is false.
/// Default value is <see langword="false"/>.
/// </summary>
public bool WriteEnumsAsIntegers { get; set; }

/// <summary>
/// Gets or sets a value that indicates whether <see cref="long"/> and <see cref="ulong"/> values are written as strings instead of numbers.
/// Default value is false.
/// Default value is <see langword="false"/>.
/// </summary>
public bool WriteInt64sAsStrings { get; set; }

/// <summary>
/// Gets or sets a value that indicates whether JSON should use pretty printing.
/// Default value is false.
/// Default value is <see langword="false"/>.
/// </summary>
public bool WriteIndented { get; set; }

/// <summary>
/// Gets or sets a value that indicates whether property names are compared using case-insensitive matching during deserialization.
/// The default value is <see langword="false"/>.
/// </summary>
/// <remarks>
/// <para>
/// The Protobuf JSON specification requires JSON property names to match message field names exactly, including case.
/// Enabling this option may reduce interoperability, as case-insensitive property matching might not be supported
/// by other JSON transcoding implementations.
/// </para>
/// <para>
/// For more information, see <see href="https://protobuf.dev/programming-guides/json/"/>.
/// </para>
/// </remarks>
public bool PropertyNameCaseInsensitive { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ internal static JsonSerializerOptions CreateSerializerOptions(JsonContext contex
WriteIndented = writeIndented,
NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
TypeInfoResolver = typeInfoResolver
TypeInfoResolver = typeInfoResolver,
PropertyNameCaseInsensitive = context.Settings.PropertyNameCaseInsensitive,
};
options.Converters.Add(new NullValueConverter());
options.Converters.Add(new ByteStringConverter());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
#nullable enable
Microsoft.AspNetCore.Grpc.JsonTranscoding.GrpcJsonSettings.PropertyNameCaseInsensitive.get -> bool
Microsoft.AspNetCore.Grpc.JsonTranscoding.GrpcJsonSettings.PropertyNameCaseInsensitive.set -> void
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,40 @@ public void JsonCustomizedName()
Assert.Equal("A field name", m.FieldName);
}

[Fact]
public void NonJsonName_CaseInsensitive()
{
var json = @"{
""HIDING_FIELD_NAME"": ""A field name""
}";

var m = AssertReadJson<HelloRequest>(json, serializeOld: false, settings: new GrpcJsonSettings { PropertyNameCaseInsensitive = true });
Assert.Equal("A field name", m.HidingFieldName);
}

[Fact]
public void HidingJsonName_CaseInsensitive()
{
var json = @"{
""FIELD_NAME"": ""A field name""
}";

var m = AssertReadJson<HelloRequest>(json, serializeOld: false, settings: new GrpcJsonSettings { PropertyNameCaseInsensitive = true });
Assert.Equal("", m.FieldName);
Assert.Equal("A field name", m.HidingFieldName);
}

[Fact]
public void JsonCustomizedName_CaseInsensitive()
{
var json = @"{
""JSON_CUSTOMIZED_NAME"": ""A field name""
}";

var m = AssertReadJson<HelloRequest>(json, serializeOld: false, settings: new GrpcJsonSettings { PropertyNameCaseInsensitive = true });
Assert.Equal("A field name", m.FieldName);
}

[Fact]
public void ReadObjectProperties()
{
Expand Down Expand Up @@ -438,6 +472,23 @@ public void MapMessages()
AssertReadJson<HelloRequest>(json);
}

[Fact]
public void MapMessages_CaseInsensitive()
{
var json = @"{
""mapMessage"": {
""name1"": {
""SUBFIELD"": ""value1""
},
""name2"": {
""SUBFIELD"": ""value2""
}
}
}";

AssertReadJson<HelloRequest>(json, serializeOld: false, settings: new GrpcJsonSettings { PropertyNameCaseInsensitive = true });
}

[Fact]
public void MapKeyBool()
{
Expand All @@ -451,6 +502,21 @@ public void MapKeyBool()
AssertReadJson<HelloRequest>(json);
}

[Fact]
public void MapKeyBool_CaseInsensitive()
{
var json = @"{
""mapKeybool"": {
""TRUE"": ""value1"",
""FALSE"": ""value2""
}
}";

// Note: JSON property names here are keys in a dictionary, not fields. So FieldNamesCaseInsensitive doesn't apply.
// The new serializer supports converting true/false to boolean keys while ignoring case.
AssertReadJson<HelloRequest>(json, serializeOld: false);
}

[Fact]
public void MapKeyInt()
{
Expand All @@ -474,6 +540,16 @@ public void OneOf_Success()
AssertReadJson<HelloRequest>(json);
}

[Fact]
public void OneOf_CaseInsensitive_Success()
{
var json = @"{
""ONEOFNAME1"": ""test""
}";

AssertReadJson<HelloRequest>(json, serializeOld: false, settings: new GrpcJsonSettings { PropertyNameCaseInsensitive = true });
}

[Fact]
public void OneOf_Failure()
{
Expand All @@ -485,6 +561,17 @@ public void OneOf_Failure()
AssertReadJsonError<HelloRequest>(json, ex => Assert.Equal("Multiple values specified for oneof oneof_test", ex.Message.TrimEnd('.')));
}

[Fact]
public void OneOf_CaseInsensitive_Failure()
{
var json = @"{
""ONEOFNAME1"": ""test"",
""ONEOFNAME2"": ""test""
}";

AssertReadJsonError<HelloRequest>(json, ex => Assert.Equal("Multiple values specified for oneof oneof_test", ex.Message.TrimEnd('.')), deserializeOld: false, settings: new GrpcJsonSettings { PropertyNameCaseInsensitive = true });
}

[Fact]
public void NullableWrappers_NaN()
{
Expand Down Expand Up @@ -528,7 +615,27 @@ public void NullableWrappers()
""bytesValue"": ""SGVsbG8gd29ybGQ=""
}";

AssertReadJson<HelloRequest.Types.Wrappers>(json);
var result = AssertReadJson<HelloRequest.Types.Wrappers>(json);
Assert.Equal("A string", result.StringValue);
}

[Fact]
public void NullableWrappers_CaseInsensitive()
{
var json = @"{
""STRINGVALUE"": ""A string"",
""INT32VALUE"": 1,
""INT64VALUE"": ""2"",
""FLOATVALUE"": 1.2,
""DOUBLEVALUE"": 1.1,
""BOOLVALUE"": true,
""UINT32VALUE"": 3,
""UINT64VALUE"": ""4"",
""BYTESVALUE"": ""SGVsbG8gd29ybGQ=""
}";

var result = AssertReadJson<HelloRequest.Types.Wrappers>(json, serializeOld: false, settings: new GrpcJsonSettings { PropertyNameCaseInsensitive = true });
Assert.Equal("A string", result.StringValue);
}

[Fact]
Expand Down Expand Up @@ -609,8 +716,19 @@ public void JsonNamePriority_JsonName()
{
var json = @"{""b"":10,""a"":20,""d"":30}";

// TODO: Current Google.Protobuf version doesn't have fix. Update when available. 3.23.0 or later?
var m = AssertReadJson<Issue047349Message>(json, serializeOld: false);
var m = AssertReadJson<Issue047349Message>(json);

Assert.Equal(10, m.A);
Assert.Equal(20, m.B);
Assert.Equal(30, m.C);
}

[Fact]
public void JsonNamePriority_CaseInsensitive_JsonName()
{
var json = @"{""B"":10,""A"":20,""D"":30}";

var m = AssertReadJson<Issue047349Message>(json, serializeOld: false, settings: new GrpcJsonSettings { PropertyNameCaseInsensitive = true });

Assert.Equal(10, m.A);
Assert.Equal(20, m.B);
Expand All @@ -622,14 +740,44 @@ public void JsonNamePriority_FieldNameFallback()
{
var json = @"{""b"":10,""a"":20,""c"":30}";

// TODO: Current Google.Protobuf version doesn't have fix. Update when available. 3.23.0 or later?
var m = AssertReadJson<Issue047349Message>(json, serializeOld: false);
var m = AssertReadJson<Issue047349Message>(json);

Assert.Equal(10, m.A);
Assert.Equal(20, m.B);
Assert.Equal(30, m.C);
}

[Fact]
public void JsonNamePriority_CaseInsensitive_FieldNameFallback()
{
var json = @"{""B"":10,""A"":20,""C"":30}";

var m = AssertReadJson<Issue047349Message>(json, serializeOld: false, settings: new GrpcJsonSettings { PropertyNameCaseInsensitive = true });

Assert.Equal(10, m.A);
Assert.Equal(20, m.B);
Assert.Equal(30, m.C);
}

[Fact]
public void FieldNameCase_Success()
{
var json = @"{""a"":10,""A"":20}";

var m = AssertReadJson<FieldNameCaseMessage>(json);

Assert.Equal(10, m.A);
Assert.Equal(20, m.B);
}

[Fact]
public void FieldNameCase_CaseInsensitive_Failure()
{
var json = @"{""a"":10,""A"":20}";

AssertReadJsonError<FieldNameCaseMessage>(json, ex => Assert.Equal("The JSON property name for 'Transcoding.FieldNameCaseMessage.A' collides with another property.", ex.Message), deserializeOld: false, settings: new GrpcJsonSettings { PropertyNameCaseInsensitive = true });
}

private TValue AssertReadJson<TValue>(string value, GrpcJsonSettings? settings = null, DescriptorRegistry? descriptorRegistry = null, bool serializeOld = true) where TValue : IMessage, new()
{
var typeRegistery = TypeRegistry.FromFiles(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -235,3 +235,8 @@ message HelloReply {
message NullValueContainer {
google.protobuf.NullValue null_value = 1;
}

message FieldNameCaseMessage {
int32 a = 1;
int32 b = 2 [json_name="A"];
}
Loading