Skip to content

Commit a7ee15f

Browse files
committed
Add gRPC JSON transcoding option for stripping enum prefix
1 parent 2dfd73a commit a7ee15f

File tree

8 files changed

+343
-74
lines changed

8 files changed

+343
-74
lines changed

src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/GrpcJsonSettings.cs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,42 @@ public sealed class GrpcJsonSettings
4848
/// </para>
4949
/// </remarks>
5050
public bool PropertyNameCaseInsensitive { get; set; }
51+
52+
/// <summary>
53+
/// Gets or sets a value indicating whether the enum type name prefix should be removed when reading and writing enum values.
54+
/// The default value is <see langword="false"/>.
55+
/// </summary>
56+
/// <remarks>
57+
/// <para>
58+
/// In Protocol Buffers, enum value names are globally scoped, so they are often prefixed with the enum type name
59+
/// to avoid name collisions. For example, the <c>Status</c> enum might define values like <c>STATUS_UNKNOWN</c>
60+
/// and <c>STATUS_OK</c>.
61+
/// </para>
62+
/// <code>
63+
/// enum Status {
64+
/// STATUS_UNKNOWN = 0;
65+
/// STATUS_OK = 1;
66+
/// }
67+
/// </code>
68+
/// <para>
69+
/// When <see cref="RemoveEnumPrefix"/> is set to <see langword="true"/>:
70+
/// </para>
71+
/// <list type="bullet">
72+
/// <item>
73+
/// <description>The <c>STATUS</c> prefix is removed from enum values. The enum values above will be read and written as <c>UNKNOWN</c> and <c>OK</c> instead of <c>STATUS_UNKNOWN</c> and <c>STATUS_OK</c>.</description>
74+
/// </item>
75+
/// <item>
76+
/// <description>Original prefixed values are used as a fallback when reading JSON. For example, <c>STATUS_OK</c> and <c>OK</c> map to the <c>STATUS_OK</c> enum value.</description>
77+
/// </item>
78+
/// </list>
79+
/// <para>
80+
/// The Protobuf JSON specification requires enum values in JSON to match enum fields exactly.
81+
/// Enabling this option may reduce interoperability, as removing enum prefix might not be supported
82+
/// by other JSON transcoding implementations.
83+
/// </para>
84+
/// <para>
85+
/// For more information, see <see href="https://protobuf.dev/programming-guides/json/"/>.
86+
/// </para>
87+
/// </remarks>
88+
public bool RemoveEnumPrefix { get; set; }
5189
}

src/Grpc/JsonTranscoding/src/Microsoft.AspNetCore.Grpc.JsonTranscoding/Internal/Json/EnumConverter.cs

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
using System.Runtime.CompilerServices;
66
using System.Text.Json;
77
using Google.Protobuf.Reflection;
8-
using Grpc.Shared;
98
using Type = System.Type;
109

1110
namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json;
@@ -21,18 +20,12 @@ public EnumConverter(JsonContext context) : base(context)
2120
switch (reader.TokenType)
2221
{
2322
case JsonTokenType.String:
24-
var enumDescriptor = (EnumDescriptor?)Context.DescriptorRegistry.FindDescriptorByType(typeToConvert);
25-
if (enumDescriptor == null)
26-
{
27-
throw new InvalidOperationException($"Unable to resolve descriptor for {typeToConvert}.");
28-
}
23+
var enumDescriptor = (EnumDescriptor?)Context.DescriptorRegistry.FindDescriptorByType(typeToConvert)
24+
?? throw new InvalidOperationException($"Unable to resolve descriptor for {typeToConvert}.");
2925

3026
var value = reader.GetString()!;
31-
var valueDescriptor = enumDescriptor.FindValueByName(value);
32-
if (valueDescriptor == null)
33-
{
34-
throw new InvalidOperationException(@$"Error converting value ""{value}"" to enum type {typeToConvert}.");
35-
}
27+
var valueDescriptor = JsonNamingHelpers.GetEnumFieldReadValue(enumDescriptor, value, Context.Settings)
28+
?? throw new InvalidOperationException(@$"Error converting value ""{value}"" to enum type {typeToConvert}.");
3629

3730
return ConvertFromInteger(valueDescriptor.Number);
3831
case JsonTokenType.Number:
@@ -52,7 +45,10 @@ public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOpt
5245
}
5346
else
5447
{
55-
var name = Legacy.OriginalEnumValueHelper.GetOriginalName(value);
48+
var enumDescriptor = (EnumDescriptor?)Context.DescriptorRegistry.FindDescriptorByType(value.GetType())
49+
?? throw new InvalidOperationException($"Unable to resolve descriptor for {value.GetType()}.");
50+
51+
var name = JsonNamingHelpers.GetEnumFieldWriteName(enumDescriptor, value, Context.Settings);
5652
if (name != null)
5753
{
5854
writer.WriteStringValue(name);
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Collections.Concurrent;
5+
using System.Linq;
6+
using System.Reflection;
7+
using Google.Protobuf.Reflection;
8+
9+
namespace Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json;
10+
11+
internal static class JsonNamingHelpers
12+
{
13+
private static readonly ConcurrentDictionary<Type, EnumMapping> _enumMappings = new ConcurrentDictionary<Type, EnumMapping>();
14+
15+
internal static EnumValueDescriptor? GetEnumFieldReadValue(EnumDescriptor enumDescriptor, string value, GrpcJsonSettings settings)
16+
{
17+
string resolvedName;
18+
if (settings.RemoveEnumPrefix)
19+
{
20+
var nameMapping = GetEnumMapping(enumDescriptor);
21+
if (!nameMapping.RemoveEnumPrefixMapping.TryGetValue(value, out var n))
22+
{
23+
return null;
24+
}
25+
26+
resolvedName = n;
27+
}
28+
else
29+
{
30+
resolvedName = value;
31+
}
32+
33+
return enumDescriptor.FindValueByName(resolvedName);
34+
}
35+
36+
internal static string? GetEnumFieldWriteName(EnumDescriptor enumDescriptor, object value, GrpcJsonSettings settings)
37+
{
38+
var enumMapping = GetEnumMapping(enumDescriptor);
39+
40+
// If this returns false, name will be null, which is what we want.
41+
if (!enumMapping.WriteMapping.TryGetValue(value, out var mapping))
42+
{
43+
return null;
44+
}
45+
46+
return settings.RemoveEnumPrefix ? mapping.RemoveEnumPrefixName : mapping.OriginalName;
47+
}
48+
49+
private static EnumMapping GetEnumMapping(EnumDescriptor enumDescriptor)
50+
{
51+
return _enumMappings.GetOrAdd(
52+
enumDescriptor.ClrType,
53+
static (t, descriptor) => GetEnumMapping(descriptor.Name, t),
54+
enumDescriptor);
55+
}
56+
57+
private static EnumMapping GetEnumMapping(string enumName, Type enumType)
58+
{
59+
var nameMappings = enumType.GetTypeInfo().DeclaredFields
60+
.Where(f => f.IsStatic)
61+
.Where(f => f.GetCustomAttributes<OriginalNameAttribute>().FirstOrDefault()?.PreferredAlias ?? true)
62+
.Select(f =>
63+
{
64+
// If the attribute hasn't been applied, fall back to the name of the field.
65+
var fieldName = f.GetCustomAttributes<OriginalNameAttribute>().FirstOrDefault()?.Name ?? f.Name;
66+
67+
return new NameMapping
68+
{
69+
Value = f.GetValue(null)!,
70+
OriginalName = fieldName,
71+
RemoveEnumPrefixName = GetEnumValueName(enumName, fieldName)
72+
};
73+
})
74+
.ToList();
75+
76+
var writeMapping = nameMappings.ToDictionary(m => m.Value, m => m);
77+
78+
// Add original names as fallback when mapping enum values with removed prefixes.
79+
// There are added to the dictionary first so they are overridden by the mappings with removed prefixes.
80+
var removeEnumPrefixMapping = nameMappings.ToDictionary(m => m.OriginalName, m => m.OriginalName);
81+
82+
// Protobuf codegen prevents collision of enum names when the prefix is removed.
83+
// For example, the following enum will fail to build because both fields would resolve to "OK":
84+
//
85+
// enum Status {
86+
// STATUS_OK = 0;
87+
// OK = 1;
88+
// }
89+
//
90+
// Tooling error message:
91+
// ----------------------
92+
// Enum name OK has the same name as STATUS_OK if you ignore case and strip out the enum name prefix (if any).
93+
// (If you are using allow_alias, please assign the same number to each enum value name.)
94+
//
95+
// Just in case it does happen, map to the first value rather than error.
96+
foreach (var item in nameMappings.GroupBy(m => m.RemoveEnumPrefixName).Select(g => KeyValuePair.Create(g.Key, g.First().OriginalName)))
97+
{
98+
removeEnumPrefixMapping[item.Key] = item.Value;
99+
}
100+
101+
return new EnumMapping { WriteMapping = writeMapping, RemoveEnumPrefixMapping = removeEnumPrefixMapping };
102+
}
103+
104+
// Remove the prefix from the specified value. Ignore case and underscores in the comparison.
105+
private static string TryRemovePrefix(string prefix, string value)
106+
{
107+
var normalizedPrefix = prefix.Replace("_", string.Empty, StringComparison.Ordinal).ToLowerInvariant();
108+
109+
var prefixIndex = 0;
110+
var valueIndex = 0;
111+
112+
while (prefixIndex < normalizedPrefix.Length && valueIndex < value.Length)
113+
{
114+
if (value[valueIndex] == '_')
115+
{
116+
valueIndex++;
117+
continue;
118+
}
119+
120+
if (char.ToLowerInvariant(value[valueIndex]) != normalizedPrefix[prefixIndex])
121+
{
122+
return value;
123+
}
124+
125+
prefixIndex++;
126+
valueIndex++;
127+
}
128+
129+
if (prefixIndex < normalizedPrefix.Length)
130+
{
131+
return value;
132+
}
133+
134+
while (valueIndex < value.Length && value[valueIndex] == '_')
135+
{
136+
valueIndex++;
137+
}
138+
139+
return valueIndex == value.Length ? value : value.Substring(valueIndex);
140+
}
141+
142+
private static string GetEnumValueName(string enumName, string valueName)
143+
{
144+
var result = TryRemovePrefix(enumName, valueName);
145+
146+
// Prefix name starting with a digit with an underscore to ensure it is a valid identifier.
147+
return result.Length > 0 && char.IsDigit(result[0])
148+
? $"_{result}"
149+
: result;
150+
}
151+
152+
private sealed class EnumMapping
153+
{
154+
public required Dictionary<object, NameMapping> WriteMapping { get; init; }
155+
public required Dictionary<string, string> RemoveEnumPrefixMapping { get; init; }
156+
}
157+
158+
private sealed class NameMapping
159+
{
160+
public required object Value { get; init; }
161+
public required string OriginalName { get; init; }
162+
public required string RemoveEnumPrefixName { get; init; }
163+
}
164+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
#nullable enable
22
Microsoft.AspNetCore.Grpc.JsonTranscoding.GrpcJsonSettings.PropertyNameCaseInsensitive.get -> bool
33
Microsoft.AspNetCore.Grpc.JsonTranscoding.GrpcJsonSettings.PropertyNameCaseInsensitive.set -> void
4+
Microsoft.AspNetCore.Grpc.JsonTranscoding.GrpcJsonSettings.RemoveEnumPrefix.get -> bool
5+
Microsoft.AspNetCore.Grpc.JsonTranscoding.GrpcJsonSettings.RemoveEnumPrefix.set -> void

src/Grpc/JsonTranscoding/src/Shared/Legacy.cs

Lines changed: 1 addition & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
using System.Text.RegularExpressions;
4141
using Google.Protobuf.Reflection;
4242
using Google.Protobuf.WellKnownTypes;
43+
using Microsoft.AspNetCore.Grpc.JsonTranscoding.Internal.Json;
4344
using Type = System.Type;
4445

4546
namespace Grpc.Shared;
@@ -365,44 +366,4 @@ internal static bool IsPathValid(string input)
365366
}
366367
return true;
367368
}
368-
369-
// Effectively a cache of mapping from enum values to the original name as specified in the proto file,
370-
// fetched by reflection.
371-
// The need for this is unfortunate, as is its unbounded size, but realistically it shouldn't cause issues.
372-
internal static class OriginalEnumValueHelper
373-
{
374-
private static readonly ConcurrentDictionary<Type, Dictionary<object, string>> _dictionaries
375-
= new ConcurrentDictionary<Type, Dictionary<object, string>>();
376-
377-
internal static string? GetOriginalName(object value)
378-
{
379-
var enumType = value.GetType();
380-
Dictionary<object, string>? nameMapping;
381-
lock (_dictionaries)
382-
{
383-
if (!_dictionaries.TryGetValue(enumType, out nameMapping))
384-
{
385-
nameMapping = GetNameMapping(enumType);
386-
_dictionaries[enumType] = nameMapping;
387-
}
388-
}
389-
390-
// If this returns false, originalName will be null, which is what we want.
391-
nameMapping.TryGetValue(value, out var originalName);
392-
return originalName;
393-
}
394-
395-
private static Dictionary<object, string> GetNameMapping(Type enumType)
396-
{
397-
return enumType.GetTypeInfo().DeclaredFields
398-
.Where(f => f.IsStatic)
399-
.Where(f => f.GetCustomAttributes<OriginalNameAttribute>()
400-
.FirstOrDefault()?.PreferredAlias ?? true)
401-
.ToDictionary(f => f.GetValue(null)!,
402-
f => f.GetCustomAttributes<OriginalNameAttribute>()
403-
.FirstOrDefault()
404-
// If the attribute hasn't been applied, fall back to the name of the field.
405-
?.Name ?? f.Name);
406-
}
407-
}
408369
}

src/Grpc/JsonTranscoding/test/Microsoft.AspNetCore.Grpc.JsonTranscoding.Tests/ConverterTests/JsonConverterReadTests.cs

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -264,17 +264,52 @@ public void Enum_ReadNumber(int value)
264264
}
265265

266266
[Theory]
267-
[InlineData("FOO")]
268-
[InlineData("BAR")]
269-
[InlineData("NEG")]
270-
public void Enum_ReadString(string value)
267+
[InlineData("FOO", HelloRequest.Types.DataTypes.Types.NestedEnum.Foo)]
268+
[InlineData("BAR", HelloRequest.Types.DataTypes.Types.NestedEnum.Bar)]
269+
[InlineData("NEG", HelloRequest.Types.DataTypes.Types.NestedEnum.Neg)]
270+
public void Enum_ReadString(string value, HelloRequest.Types.DataTypes.Types.NestedEnum expectedValue)
271271
{
272272
var serviceDescriptorRegistry = new DescriptorRegistry();
273273
serviceDescriptorRegistry.RegisterFileDescriptor(JsonTranscodingGreeter.Descriptor.File);
274274

275275
var json = @$"{{ ""singleEnum"": ""{value}"" }}";
276276

277-
AssertReadJson<HelloRequest.Types.DataTypes>(json, descriptorRegistry: serviceDescriptorRegistry);
277+
var result = AssertReadJson<HelloRequest.Types.DataTypes>(json, descriptorRegistry: serviceDescriptorRegistry);
278+
Assert.Equal(expectedValue, result.SingleEnum);
279+
}
280+
281+
[Theory]
282+
[InlineData("UNSPECIFIED", PrefixEnumType.Types.PrefixEnum.Unspecified)]
283+
[InlineData("PREFIX_ENUM_UNSPECIFIED", PrefixEnumType.Types.PrefixEnum.Unspecified)]
284+
[InlineData("FOO", PrefixEnumType.Types.PrefixEnum.Foo)]
285+
[InlineData("PREFIX_ENUM_FOO", PrefixEnumType.Types.PrefixEnum.Foo)]
286+
[InlineData("BAR", PrefixEnumType.Types.PrefixEnum.Bar)]
287+
public void Enum_RemovePrefix_ReadString(string value, PrefixEnumType.Types.PrefixEnum expectedValue)
288+
{
289+
var serviceDescriptorRegistry = new DescriptorRegistry();
290+
serviceDescriptorRegistry.RegisterFileDescriptor(JsonTranscodingGreeter.Descriptor.File);
291+
292+
var json = @$"{{ ""singleEnum"": ""{value}"" }}";
293+
294+
var result = AssertReadJson<PrefixEnumType>(json, descriptorRegistry: serviceDescriptorRegistry, serializeOld: false, settings: new GrpcJsonSettings { RemoveEnumPrefix = true });
295+
Assert.Equal(expectedValue, result.SingleEnum);
296+
}
297+
298+
[Theory]
299+
[InlineData("UNSPECIFIED", CollisionPrefixEnumType.Types.CollisionPrefixEnum.Unspecified)]
300+
[InlineData("COLLISION_PREFIX_ENUM_UNSPECIFIED", CollisionPrefixEnumType.Types.CollisionPrefixEnum.Unspecified)]
301+
[InlineData("FOO", CollisionPrefixEnumType.Types.CollisionPrefixEnum.Foo)]
302+
[InlineData("COLLISION_PREFIX_ENUM_FOO", CollisionPrefixEnumType.Types.CollisionPrefixEnum.CollisionPrefixEnumFoo)] // Match exact rather than fallback.
303+
[InlineData("COLLISION_PREFIX_ENUM_COLLISION_PREFIX_ENUM_FOO", CollisionPrefixEnumType.Types.CollisionPrefixEnum.CollisionPrefixEnumFoo)]
304+
public void Enum_RemovePrefix_Collision_ReadString(string value, CollisionPrefixEnumType.Types.CollisionPrefixEnum expectedValue)
305+
{
306+
var serviceDescriptorRegistry = new DescriptorRegistry();
307+
serviceDescriptorRegistry.RegisterFileDescriptor(JsonTranscodingGreeter.Descriptor.File);
308+
309+
var json = @$"{{ ""singleEnum"": ""{value}"" }}";
310+
311+
var result = AssertReadJson<CollisionPrefixEnumType>(json, descriptorRegistry: serviceDescriptorRegistry, serializeOld: false, settings: new GrpcJsonSettings { RemoveEnumPrefix = true });
312+
Assert.Equal(expectedValue, result.SingleEnum);
278313
}
279314

280315
[Fact]

0 commit comments

Comments
 (0)