Skip to content

Commit 02cd7f7

Browse files
committed
Add gRPC JSON transcoding option for stripping enum prefix
1 parent 62a224e commit 02cd7f7

File tree

8 files changed

+293
-69
lines changed

8 files changed

+293
-69
lines changed

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

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,25 +11,49 @@ public sealed class GrpcJsonSettings
1111
/// <summary>
1212
/// Gets or sets a value that indicates whether fields with default values are ignored during serialization.
1313
/// This setting only affects fields which don't support "presence", such as singular non-optional proto3 primitive fields.
14-
/// Default value is false.
14+
/// Default value is <see langword="false"/>.
1515
/// </summary>
1616
public bool IgnoreDefaultValues { get; set; }
1717

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

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

3030
/// <summary>
3131
/// Gets or sets a value that indicates whether JSON should use pretty printing.
32-
/// Default value is false.
32+
/// Default value is <see langword="false"/>.
3333
/// </summary>
3434
public bool WriteIndented { get; set; }
35+
36+
/// <summary>
37+
/// Gets or sets a value indicating whether the enum type name prefix should be removed when reading and writing enum values.
38+
/// The default value is <see langword="false"/>.
39+
/// </summary>
40+
/// <remarks>
41+
/// <para>
42+
/// In Protocol Buffers, enum value names are globally scoped, so they are often prefixed with the enum type name
43+
/// to avoid name collisions. For example, the <c>Status</c> enum might define values like <c>STATUS_UNKNOWN</c>
44+
/// and <c>STATUS_OK</c>.
45+
/// </para>
46+
/// <code>
47+
/// enum Status {
48+
/// STATUS_UNKNOWN = 0;
49+
/// STATUS_OK = 1;
50+
/// }
51+
/// </code>
52+
/// <para>
53+
/// When <see cref="RemoveEnumPrefix"/> is set to <see langword="true"/>, the enum values above
54+
/// will be read and written as <c>UNKNOWN</c> and <c>OK</c> instead of <c>STATUS_UNKNOWN</c>
55+
/// and <c>STATUS_OK</c>.
56+
/// </para>
57+
/// </remarks>
58+
public bool RemoveEnumPrefix { get; set; }
3559
}

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

Lines changed: 8 additions & 3 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;
@@ -28,7 +27,7 @@ public EnumConverter(JsonContext context) : base(context)
2827
}
2928

3029
var value = reader.GetString()!;
31-
var valueDescriptor = enumDescriptor.FindValueByName(value);
30+
var valueDescriptor = JsonNamingHelpers.GetEnumFieldReadValue(enumDescriptor, value, Context.Settings);
3231
if (valueDescriptor == null)
3332
{
3433
throw new InvalidOperationException(@$"Error converting value ""{value}"" to enum type {typeToConvert}.");
@@ -52,7 +51,13 @@ public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOpt
5251
}
5352
else
5453
{
55-
var name = Legacy.OriginalEnumValueHelper.GetOriginalName(value);
54+
var enumDescriptor = (EnumDescriptor?)Context.DescriptorRegistry.FindDescriptorByType(value.GetType());
55+
if (enumDescriptor == null)
56+
{
57+
throw new InvalidOperationException($"Unable to resolve descriptor for {value.GetType()}.");
58+
}
59+
60+
var name = JsonNamingHelpers.GetEnumFieldWriteName(enumDescriptor, value, Context.Settings);
5661
if (name != null)
5762
{
5863
writer.WriteStringValue(name);
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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+
var enumType = enumDescriptor.ClrType;
52+
53+
EnumMapping? enumMapping;
54+
lock (_enumMappings)
55+
{
56+
if (!_enumMappings.TryGetValue(enumType, out enumMapping))
57+
{
58+
_enumMappings[enumType] = enumMapping = GetEnumMapping(enumDescriptor.Name, enumType);
59+
}
60+
}
61+
62+
return enumMapping;
63+
}
64+
65+
private static EnumMapping GetEnumMapping(string enumName, Type enumType)
66+
{
67+
var enumFields = enumType.GetTypeInfo().DeclaredFields
68+
.Where(f => f.IsStatic)
69+
.Where(f => f.GetCustomAttributes<OriginalNameAttribute>().FirstOrDefault()?.PreferredAlias ?? true)
70+
.ToList();
71+
72+
var writeMapping = enumFields.ToDictionary(
73+
f => f.GetValue(null)!,
74+
f =>
75+
{
76+
// If the attribute hasn't been applied, fall back to the name of the field.
77+
var fieldName = f.GetCustomAttributes<OriginalNameAttribute>().FirstOrDefault()?.Name ?? f.Name;
78+
79+
return new NameMapping
80+
{
81+
OriginalName = fieldName,
82+
RemoveEnumPrefixName = GetEnumValueName(enumName, fieldName)
83+
};
84+
});
85+
86+
var removeEnumPrefixMapping = writeMapping.Values.ToDictionary(
87+
m => m.RemoveEnumPrefixName,
88+
m => m.OriginalName);
89+
90+
return new EnumMapping { WriteMapping = writeMapping, RemoveEnumPrefixMapping = removeEnumPrefixMapping };
91+
}
92+
93+
// Remove the prefix from the specified value. Ignore case and underscores in the comparison.
94+
private static string TryRemovePrefix(string prefix, string value)
95+
{
96+
var normalizedPrefix = prefix.Replace("_", string.Empty, StringComparison.Ordinal).ToLowerInvariant();
97+
98+
var prefixIndex = 0;
99+
var valueIndex = 0;
100+
101+
while (prefixIndex < normalizedPrefix.Length && valueIndex < value.Length)
102+
{
103+
if (value[valueIndex] == '_')
104+
{
105+
valueIndex++;
106+
continue;
107+
}
108+
109+
if (char.ToLowerInvariant(value[valueIndex]) != normalizedPrefix[prefixIndex])
110+
{
111+
return value;
112+
}
113+
114+
prefixIndex++;
115+
valueIndex++;
116+
}
117+
118+
if (prefixIndex < normalizedPrefix.Length)
119+
{
120+
return value;
121+
}
122+
123+
while (valueIndex < value.Length && value[valueIndex] == '_')
124+
{
125+
valueIndex++;
126+
}
127+
128+
return valueIndex == value.Length ? value : value.Substring(valueIndex);
129+
}
130+
131+
private static string GetEnumValueName(string enumName, string valueName)
132+
{
133+
var result = TryRemovePrefix(enumName, valueName);
134+
135+
// Prefix name starting with a digit with an underscore to ensure it is a valid identifier.
136+
return result.Length > 0 && char.IsDigit(result[0])
137+
? $"_{result}"
138+
: result;
139+
}
140+
141+
private sealed class EnumMapping
142+
{
143+
public required Dictionary<object, NameMapping> WriteMapping { get; init; }
144+
public required Dictionary<string, string> RemoveEnumPrefixMapping { get; init; }
145+
}
146+
147+
private sealed class NameMapping
148+
{
149+
public required string OriginalName { get; init; }
150+
public required string RemoveEnumPrefixName { get; init; }
151+
}
152+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
#nullable enable
2+
Microsoft.AspNetCore.Grpc.JsonTranscoding.GrpcJsonSettings.RemoveEnumPrefix.get -> bool
3+
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: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -229,17 +229,33 @@ public void Enum_ReadNumber(int value)
229229
}
230230

231231
[Theory]
232-
[InlineData("FOO")]
233-
[InlineData("BAR")]
234-
[InlineData("NEG")]
235-
public void Enum_ReadString(string value)
232+
[InlineData("FOO", HelloRequest.Types.DataTypes.Types.NestedEnum.Foo)]
233+
[InlineData("BAR", HelloRequest.Types.DataTypes.Types.NestedEnum.Bar)]
234+
[InlineData("NEG", HelloRequest.Types.DataTypes.Types.NestedEnum.Neg)]
235+
public void Enum_ReadString(string value, HelloRequest.Types.DataTypes.Types.NestedEnum expectedValue)
236236
{
237237
var serviceDescriptorRegistry = new DescriptorRegistry();
238238
serviceDescriptorRegistry.RegisterFileDescriptor(JsonTranscodingGreeter.Descriptor.File);
239239

240240
var json = @$"{{ ""singleEnum"": ""{value}"" }}";
241241

242-
AssertReadJson<HelloRequest.Types.DataTypes>(json, descriptorRegistry: serviceDescriptorRegistry);
242+
var result = AssertReadJson<HelloRequest.Types.DataTypes>(json, descriptorRegistry: serviceDescriptorRegistry);
243+
Assert.Equal(expectedValue, result.SingleEnum);
244+
}
245+
246+
[Theory]
247+
[InlineData("UNSPECIFIED", PrefixEnumType.Types.PrefixEnum.Unspecified)]
248+
[InlineData("FOO", PrefixEnumType.Types.PrefixEnum.Foo)]
249+
[InlineData("BAR", PrefixEnumType.Types.PrefixEnum.Bar)]
250+
public void Enum_RemovePrefix_ReadString(string value, PrefixEnumType.Types.PrefixEnum expectedValue)
251+
{
252+
var serviceDescriptorRegistry = new DescriptorRegistry();
253+
serviceDescriptorRegistry.RegisterFileDescriptor(JsonTranscodingGreeter.Descriptor.File);
254+
255+
var json = @$"{{ ""singleEnum"": ""{value}"" }}";
256+
257+
var result = AssertReadJson<PrefixEnumType>(json, descriptorRegistry: serviceDescriptorRegistry, serializeOld: false, settings: new GrpcJsonSettings { RemoveEnumPrefix = true });
258+
Assert.Equal(expectedValue, result.SingleEnum);
243259
}
244260

245261
[Fact]

0 commit comments

Comments
 (0)